#! /usr/bin/env python3

from pathlib import Path
import numpy as np
import nmrglue as ng
import warnings

from copy import deepcopy


from . import fit, misc, sim, figures, processing, anal
from .config import cprint

print = cprint

# Suppress warnings that create alarmism
warnings.filterwarnings(action='ignore', message='Error reading the pulse program')
warnings.filterwarnings(action='ignore', message=r'[0-9]*cannot')

# Declare interesting things
proc_keys_1D = ['wf', 'zf', 'fcor', 'tdeff']
wf0 = {
        'mode': None,
        'ssb': 2,
        'lb': 5,
        'gb': 0.1,
        'lb_gm': 0,
        'gb_gm': 0.1,
        'gc': 0,
        'sw': None
        }

r"""
Classes for the management of NMR data.
"""


class Spectrum_1D:
    r"""
    Class: 1D NMR spectrum

    Attributes
    ----------
    datadir : str
        Path to the input file/dataset directory
    filename : str
        Base of the name of the file, without extensions
    fid : 1darray
        FID
    acqus : dict
        Dictionary of acqusition parameters
    ngdic : dict
        Created only if it is an experimental spectrum. Generated by `nmrglue`, contains all the information on the spectrometer and on the spectrum.
    procs : dict
        Dictionary of processing parameters
    S : 1darray
        Complex spectrum
    r : 1darray
        Real part of the spectrum
    i : 1darray
        Imaginary part of the spectrum
    freq : 1darray
        Frequency scale of the spectrum, in Hz
    ppm : 1darray
        ppm scale of the spectrum
    F : fit.Voigt_Fit object
        Used for deconvolution. See :class:`klassez.fit.Voigt_fit`
    baseline : 1darray
        Baseline of the spectrum.
    integrals : dict
        Dictionary where to save the regions and values of the integrals.

    Returns
    -------
    None

    .. seealso::

        :class:`klassez.fit.Voigt_Fit`

    """
    def __str__(self):
        """ Prints info on the object """
        doc = '-'*64
        doc += '\nSpectrum_1D object.\n'
        if 'ngdic' in self.__dict__.keys():
            doc += f'Read from "{self.datadir}"\n'
        else:
            doc += f'Simulated from "{self.filename}" in {self.datadir}\n'
        doc += f'It is a {self.acqus["nuc"]} spectrum recorded over a\nsweep width of {self.acqus["SWp"]} ppm, centered at {self.acqus["o1p"]} ppm.\n'
        if 'fid' in self.__dict__.keys():
            N = self.fid.shape[-1]
            doc += f'The FID is {N} points long.\n'
        elif 'S' in self.__dict__.keys():
            N = self.S.shape[-1]
            doc += f'The spectrum is {N} points long.\n'
        else:
            pass
        doc += '-'*64

        return doc

    @property
    def S(self) -> np.ndarray:
        """ Normal getter for ``self.S`` attribute """
        return self._S

    @S.setter
    def S(self, data: np.ndarray):
        """ Every time ``self.S`` is instanced, ``self.r`` and ``self.i`` are updated automatically """
        self._S = data
        self.r = self._S.real
        self.i = self._S.imag
        if hasattr(self, 'F'):
            self.F.S = self._S

    def __init__(self, in_file, pv=False, isexp=True, spect='bruker'):
        """
        Initialize the class.
        Simulation of the dataset (i.e. ``isexp=False``) employs :func:`klasez.sim.sim_1D`.

        Parameters
        ----------
        in_file : str or dict
            path to file to read, or to the folder of the spectrum, or a dictionary of acquisition parameters
        pv : bool
            True if you want to use pseudo-voigt lineshapes for simulation, False for Voigt
        isexp : bool
            True if this is an experimental dataset, False if it is simulated
        spect : str
            Data file format. Allowed: ``'bruker'``, ``'varian'``, ``'magritek'``, ``'oxford'``, ``'jeol'``

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.sim.load_sim_1D`

            :func:`klassez.sim.sim_1D`
        """
        # Set up the filenames
        self.datadir, self.filename = misc.get_datadir_filename(in_file, isexp)

        # Read the data
        if isexp is False:  # Simulate the dataset
            if isinstance(in_file, dict):   # using the provided dictionary
                self.acqus = deepcopy(in_file)
            else:   # or building it from the file
                self.acqus = sim.load_sim_1D(in_file)
            # Generate the FID
            self.fid = sim.sim_1D(in_file, pv=pv)
            # The acqus dictionary will know your dataset is simulated
            self.acqus['spect'] = 'simulated'
            self.acqus['GRPDLY'] = 0
        else:   # Read the data from the directory using nmrglue
            self.fid, self.acqus, self.ngdic = misc.read_fid_acqus_1D(in_file, spect)

        # Initalize the procs dictionary
        # If there already is a procs dictionary saved as file, load it
        if (self.datadir / f'{self.filename}.procs').exists():
            self.procs = self.read_procs()
        # Otherwise, initialize it with default values
        else:
            self.procs = self._default_procs(self.acqus)
            # Write this in datadir
            self.write_procs()

    @staticmethod
    def _default_procs(acqus):
        """
        Create the procs dictionary with the default values.

        Parameters
        ----------
        acqus : dict
            Dictionary of acquisition parameters

        Returns
        -------
        procs : dict
            Dictionary of processing parameters
        """
        procs = {}
        proc_init_1D = (deepcopy(wf0), None, 0.5, 0)    # Make a shallow copy
        for k, key in enumerate(proc_keys_1D):  # Loop in this way to keep the order
            procs[key] = proc_init_1D[k]
        procs['wf']['sw'] = round(acqus['SW'], 4)
        #   phases
        procs['p0'] = 0
        procs['p1'] = 0
        procs['pv'] = round(acqus['o1p'], 2)
        #   baseline
        procs['basl_c'] = None
        #   calibration
        procs['cal'] = 0
        return procs

    def add_noise(self, s_n=1):
        """
        Adds noise to the FID, using the function :func:`klassez.sim.noisegen`.

        Parameters
        ----------
        s_n : float
            Standard deviation of the noise


        Returns
        -------
        None

        .. seealso::

            :func:`klassez.sim.noisegen`
        """
        self.fid += sim.noisegen(self.fid.shape, self.acqus['o1'], self.acqus['t1'], s_n=s_n)

    def scan(self, ns=1, s_n=1):
        """
        Simulates the acquisition of ``ns`` scans, by adding a different realization of noise at each iteration.
        The function is supposed to start with the FID without noise at all. If not, the results will be biased.
        For reproducible results, fix the seed!

        Parameters
        ----------
        ns : int
            Number of scans to accumulate
        s_n : float
            Standard deviation of the noise for each scan

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.sim.noisegen`
        """
        clean_fid = np.copy(self.fid)   # Save a shallow copy of the FID without noise
        for k in range(ns):
            # Each scan is: clean FID + noise
            self.fid += clean_fid + sim.noisegen(self.fid.shape, self.acqus['o1'], self.acqus['t1'], s_n=s_n)

    def convdta(self, scaling=1):
        """
        Call :func:`klassez.processing.convdta` using ``self.acqus['GRPDLY']``

        .. note::

            It is better to use :func:`klassez.Spectra.Spectrum_1D.pknl`,
            ``convdta`` often causes baseline distortions

        .. seealso::

            :func:`klassez.processing.convdta`
        """
        self.fid = processing.convdta(self.fid, self.acqus['GRPDLY'], scaling)

    def pknl(self):
        """
        Reverses the effect of the digital filter by applying a first order phase correction.
        If the ``S`` attribute exists, the processing is applied on the processed data. Otherwise, the correction is performed on the FID.
        This function calls :func:`klassez.processing.pknl` with the parameter ``grpdly`` read from the ``acqus`` dictionary.

        .. seealso::

            :func:`klassez.processing.pknl`
        """
        if hasattr(self, 'S'):
            self.S = processing.pknl(self.S, grpdly=self.acqus['GRPDLY'], onfid=False)
            self.F.S = self.S
        else:
            self.fid = processing.pknl(self.fid, grpdly=self.acqus['GRPDLY'], onfid=True)

    def blp(self, pred=8, order=8):
        """
        Call :func:`klassez.processing.blp` on ``self.fid`` for the application of backward linear prediction to the data.
        Important for Oxford benchtop data, where you have to predict 8 points to have a usable spectrum.

        Parameters
        ----------
        pred : int
            Number of points to be predicted
        order : int
            Number of coefficients to be used for the prediction
        N : int
            Number of FID points to be used for calculation; used to decrease computation time

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.blp`
        """
        self.fid = processing.blp(self.fid, pred, order=order)

    def process(self, interactive=False):
        """
        Performs the processing of the FID. The parameters are read from ``self.procs``.
        Calls :func:`klassez.processing.interactive_fp` or :func:`klassez.processing.fp` using ``self.acqus`` and ``self.procs``
        Writes the result is ``self.S``, then unpacks it in ``self.r`` and ``self.i``
        Calculates frequency and ppm scales.
        Also initializes ``self.F`` with :class:`klassez.fit.Voigt_Fit` class using the current parameters.

        Parameters
        ----------
        interactive : bool
            True if you want to open the interactive panel, False to read the parameters from ``self.procs``.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.fp`

            :func:`klassez.processing.interactive_fp`

            :class:`klassez.fit.Voigt_Fit`

        """
        if interactive is True:
            self.S, self.procs = processing.interactive_fp(self.fid, self.acqus, self.procs)
        else:
            self.S = processing.fp(self.fid, wf=self.procs['wf'], zf=self.procs['zf'], fcor=self.procs['fcor'], tdeff=self.procs['tdeff'])
        if self.acqus['spect'] not in ['bruker', 'simulated']:   # Bruker data are meant to be ordered already
            self.S = self.S[::-1]
        if self.acqus['SFO1'] < 0:  # Correct for negative gamma nuclei
            self.S = self.S[::-1]

        # Calculate frequency and ppm scales
        self.freq = processing.make_scale(self.r.shape[0], dw=self.acqus['dw'])
        if self.acqus['SFO1'] < 0:  # Correct for negative gamma nuclei
            self.freq = self.freq[::-1]
        self.ppm = misc.freq2ppm(self.freq, B0=self.acqus['SFO1'], o1p=self.acqus['o1p'])

        # Initializes the F attribute
        self.F = fit.Voigt_Fit(self.ppm, self.S, self.acqus['t1'], self.acqus['SFO1'],
                               self.acqus['o1p'], self.acqus['nuc'],
                               filename=self.datadir/self.filename)

        # Compute the baseline, if there is
        if self.procs['basl_c'] is None:    # Initialize it to zero
            self.baseline = np.zeros_like(self.ppm)
        else:   # Baseline was computed before!
            x_basl = np.linspace(-1, 1, self.S.shape[-1])
            self.baseline = misc.polyn(x_basl, self.procs['basl_c'])

        # Apply phase correction according to procs
        self.adjph(p0=self.procs['p0'], p1=self.procs['p1'], pv=self.procs['pv'], update=False)  # Do not update because otherwise it accumulates the phase

        # Initialize the integrals attribute
        self.integrals = {}

    def inv_process(self):
        """
        Performs the inverse processing of the spectrum according to the given parameters.
        Overwrites the ``S`` attribute!!
        Calls :func:`klassez.processing.inv_fp`.

        .. seealso::

            :func:`klassez.processing.inv_fp`
        """
        if self.acqus['SFO1'] < 0:  # Correction for negative gamma nuclei
            self.S = self.S[::-1]
        # Make the inverse processing
        self.S = processing.inv_fp(self.S, wf=self.procs['wf'], size=self.acqus['TD'], fcor=self.procs['fcor'])

    def mc(self):
        r"""
        Calculates the magnitude of the spectrum and overwrites ``self.S``, ``self.r``, ``self.i``

        .. math::

            \text{mag}_S = \sqrt{\Re\{S\}^2 + \Im\{S\}^2}

        """
        # Basically, || self.S ||_2
        self.S = (self.S.real**2 + self.S.imag**2)**0.5

        self.F.S = self.r

    def apk(self, alpha=3, winsize=50, ap1=True, seethrough=False, update=True):
        """
        Automatic phase correction with :func:`klassez.processing.apk`.

        Parameters
        ----------
        alpha : float
            Factor that multiplies the std of the spectrum to set the threshold
        winsize : float
            Minimum size of the window that can contain peaks /Hz
        ap1 : bool
            True to adjust both zero and first order, False for only phase zero
        seethrough : bool
            If True, draws a series of diagnostic figures to see what the algorithm is doing
        update : bool
            Update the procs dictionary

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.apk`
        """

        self.S, (p0, p1) = processing.apk(self.ppm, self.S, self.acqus['SFO1'], alpha, winsize, ap1)

        if update:
            self.procs['p0'] += p0
            self.procs['p1'] += p1
            self.procs['pv'] = round(np.mean(self.ppm), 5)
            self.write_procs()

    def adjph(self, p0=None, p1=None, pv=None, update=True, reference=None):
        """
        Adjusts the phases of the spectrum according to the given parameters, or interactively if they are left as default.
        Calls for :func:`klassez.processing.ps`.

        Parameters
        ----------
        p0 : float or None
            0-th order phase correction /°
        p1 : float or None
            1-st order phase correction /°
        pv : float or None
            1-st order pivot /ppm
        update : bool
            Choose if you want to update the procs dictionary or not
        reference : list of 1darray or Spectrum_1D object
            Reference spectrum to be used for phasing. Can be also given as ``[ppm, spectrum]``

        Returns
        -------
        values : tuple
            ``(p0, p1, pivot)``

        .. seealso::

            :func:`klassez.processing.ps`

            :func:`klassez.processing.interactive_phase_1D`

        """
        # Adjust the phases
        self.S, values = processing.ps(self.S, self.ppm, p0=p0, p1=p1, pivot=pv, reference=reference)

        if update:      # Add the new phases to the previous ones
            self.procs['p0'] += round(values[0], 2)
            self.procs['p1'] += round(values[1], 2)
            if values[2] is not None:
                self.procs['pv'] = round(values[2], 5)

        # Update the .procs file
        self.write_procs()

        # Put the phased spectrum in S
        self.F.S = self.S
        return values

    def acme(self, **method_kws):
        """
        Automatic phase correction based on entropy minimization.
        It calculates the phase angles using :func:`klassez.processing.acme` specified in method, then calls :func:`self.adjph` with those values.

        Parameters
        ----------
        method_kws : keyworded arguments
            Additional parameters for the chosen method.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.acme`
        """
        p0, p1 = processing.acme(np.copy(self.S), **method_kws)
        self.adjph(p0=p0, p1=p1)

    def rpbc(self, **rpbc_kws):
        """
        Computes the phase angles and the baseline using :func:`klassez.processing.rpbc` on ``self.S``.
        Then applies the phase correction and subtracts the baseline, automatically.
        The ``procs`` dictionary is then updated and saved.
        The polynomial baseline is computed according to the given coefficients and stored in ``self.baseline``

        Parameters
        ----------
        rpbc_kws : keyworded arguments
            See :func:`klassez.processing.rpbc` for details.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.rpbc`
        """
        self.S, p0, p1, c = processing.rpbc(self.S, **rpbc_kws)
        # Update the procs dictionary
        self.procs['p0'] += p0
        self.procs['p1'] += p1
        self.procs['basl_c'] = c
        # Make the new baseline
        self.baseline = misc.polyn(np.linspace(-1, 1, self.S.shape[-1]), c)

        # Update the .procs file
        self.write_procs()

    def cal(self, offset=None, isHz=False, update=True, from_procs=True, *, ref=None):
        """
        Calibrates the ppm and frequency scale according to a given value, or interactively.
        Calls :func:`klassez.processing.calibration`.

        Parameters
        ----------
        offset : float or None
            scale shift value
        isHz : bool
            True if ``offset`` is in frequency units, False if ``offset`` is in ppm
        update : bool
            Choose if to update the procs dictionary or not
        from_procs : bool
            Read the parameters from the procs dictionary
        ref  : list of 1darray or Spectrum_1D object
            Reference spectrum to be used for calibration. If list, ``[ppm scale, spectrum]``

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.calibration`
        """
        if offset is not None:
            from_procs = False

        if from_procs and 'cal' in self.procs.keys():
            offppm = self.procs['cal']
            offhz = misc.ppm2freq(offppm, self.acqus['SFO1'])
            # Apply the calibration
            #   to the scales
            self.freq += offhz
            self.ppm += offppm
            #   to the offset, so that it stays in the center of the SW
            self.acqus['o1p'] += offppm
            self.acqus['o1'] += offhz
            print(f'Applied calibration of {offppm:-.3f} ppm ({offhz:-.2f} Hz) as read from the procs dictionary.', c='violet')
        else:
            # Make shallow copies
            in_ppm = np.copy(self.ppm)
            in_S = np.copy(self.r)
            if offset is None:  # Get the values interactively
                offppm = processing.calibration(in_ppm, in_S, ref=ref)
                offhz = misc.ppm2freq(offppm, self.acqus['SFO1'])
            else:               # Calculate the missing one
                if isHz:    # offppm is missing
                    offhz = offset
                    offppm = misc.freq2ppm(offhz, self.acqus['SFO1'])
                else:       # offhz is missing
                    offppm = offset
                    offhz = misc.ppm2freq(offppm, self.acqus['SFO1'])

            # Apply the calibration
            #   to the scales
            self.freq += offhz
            self.ppm += offppm
            #   to the offset, so that it stays in the center of the SW
            self.acqus['o1p'] += offppm
            self.acqus['o1'] += offhz
            if update:  # update the procs dictionary
                if 'cal' not in self.procs.keys():
                    self.procs['cal'] = 0
                self.procs['cal'] += offppm
            # Update the .procs file
            self.write_procs()

        # Update the F attribute
        self.F.S = self.S
        self.F.ppm_scale = self.ppm

    def write_acqus(self, other_dir=None):
        """
        Write the acqus dictionary in a file named ``<self.filename>.acqus``.
        Calls :func:`klassez.misc.write_acqus_1D`

        Parameters
        ----------
        other_dir : str or None
            Different location for the acqus dictionary to write into. If None, ``self.datadir`` is used instead.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.misc.write_acqus_1D`
        """
        if other_dir:
            path = Path(other_dir) / f'{self.filename}.acqus'
        else:
            path = self.datadir / f'{self.filename}.acqus'
        misc.write_acqus_1D(self.acqus, path=path)

    def write_procs(self, other_dir=None):
        """
        Writes the actual procs dictionary in a file named ``filename.procs`` in the same directory of the input file.

        Parameters
        ----------
        other_dir : str or None
            Different location for the procs dictionary to write into. If None, ``self.datadir`` is used instead.

            .. warning::

                Do not put the trailing slash!
        """
        if other_dir:
            path = Path(other_dir) / f'{self.filename}'
        else:
            path = self.datadir / f'{self.filename}'
        path.with_suffix('.procs').write_text(f'{self.procs}')

    def read_procs(self, other_dir=None):
        """
        Reads the procs dictionary from a file named ``filename.procs`` in the same directory of the input file.

        Parameters
        ----------
        other_dir : str or None
            Different location for the procs dictionary to look into. If None, ``self.datadir`` is used instead.

            .. warning::

                Do not put the trailing slash!

        Returns
        -------
        procs: dict
            Dictionary of processing parameters
        """
        if other_dir:
            path = Path(other_dir) / f'{self.filename}.procs'
        else:
            path = self.datadir / f'{self.filename}.procs'
        procs = eval(path.read_text().replace('array', 'np.array'))
        # Check if it was read correctly
        if isinstance(procs, dict):
            return procs
        else:
            raise ValueError(f'{path} cannot be interpreted as a dictionary')

    @staticmethod
    def write_ser(ser, acqus, path=None, filename=None):
        """
        Writes a real/complex array in binary format.
        Calls :func:`klassez.misc.write_ser``. Be sure that ``acqus`` contains the BYTORDA and DTYPA keys.


        Parameters
        ----------
        ser : ndarray
            Array that you want to convert in binary format.
        acqus : dict
            Dictionary of acquisition parameters. It must contain BYTORDA and DTYPA.
        path : str
            Path where to save the binary file.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.misc.write_ser`
        """
        if path is None:
            raise NameError('You must specify a filename!')
        misc.write_ser(ser, path, acqus['BYTORDA'], acqus['DTYPA'], filename=filename)

    def plot(self, fqscale=False, name=None):
        """
        Plots the real part of the spectrum in an interactive panel for inspection.

        Parameters
        ----------
        fqscale : bool
            Display using frequency scale instead of ppm
        name : str
            Filename for the figure. If ``None``, ``self.filename`` is used
        """

        if name is None:
            name = self.filename
        if fqscale:
            x = self.freq
            X_label = r'$\nu\ $'+misc.nuc_format(self.acqus['nuc'])+' /Hz'
        else:
            x = self.ppm
            X_label = r'$\delta\ $'+misc.nuc_format(self.acqus['nuc'])+' /ppm'
        figures.plot_1D(x, self.r, SFO1=self.acqus['SFO1'], name=name, X_label=X_label)

    def qfil(self, u=None, s=None, from_procs=True, fqscale=False):
        """
        Gaussian filter to suppress signals.
        Tries to read ``self.procs['qfil']``, which is

        .. code-block:: python

            { 'u': u, 's': s }

        Otherwise, these are set interactively by :func:`klassez.processing.interactive_qfil` and then added to ``self.procs``.

        Parameters
        ----------
        u : float
            Position of the filter /ppm
        s : float
            Width (standard deviation) of the filter /ppm
        from_procs : bool
            Tries to read the values from ``self.procs``
        fqscale : bool
            True to use the frequency scale, False for the ppm scale.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.interactive_qfil`
        """
        if fqscale:
            x = self.freq
            SFO1 = 1
        else:
            x = self.ppm
            SFO1 = self.acqus['SFO1']
        if 'qfil' not in self.procs.keys():     # Then add it
            self.procs['qfil'] = {'u': u, 's': s}
        for key, value in self.procs['qfil'].items():
            if value is None or not from_procs:   # At the first non-set value, do the interactive correction
                u, self.procs['qfil']['s'] = processing.interactive_qfil(x, self.r, SFO1)
                if fqscale:     # convert chemical shift from Hz to ppm
                    self.procs['qfil']['u'] = misc.freq2ppm(u, self.acqus['SFO1'], self.acqus['o1p'])
                else:
                    self.procs['qfil']['u'] = u
                break
        # Apply it
        self.r = processing.qfil(self.ppm, self.S, self.procs['qfil']['u'], self.procs['qfil']['s'], self.acqus['SFO1'])
        self.S = processing.hilbert(self.r)
        self.F.S = self.S

        # Update the .procs file
        self.write_procs()

    def basl(self, from_procs=False, phase=True):
        """
        Apply the baseline correction by subtracting ``self.baseline`` from ``self.S``. Then, ``self.S`` is unpacked in ``self.r`` and ``self.i``

        Parameters
        ----------
        from_procs : bool
            If True, computes the baseline using the polynomion model reading ``self.procs['basl_c']`` as coefficients
        phase : bool
            Choose if to apply the same phase correction of the spectrum to the baseline. This should be done if the baseline was computed before the phase adjustment!

        """
        if from_procs:  # Compute the baseline with a polynomial model
            x_basl = np.linspace(-1, 1, self.S.shape[-1])
            self.baseline = misc.polyn(x_basl, self.procs['basl_c'])
        if phase and np.iscomplexobj(self.baseline):       # Phase the baseline as the spectrum, only if it is complex
            self.baseline, *_ = processing.ps(self.baseline, self.ppm, p0=self.procs['p0'], p1=self.procs['p1'], pivot=self.procs['pv'])
        # Subtract the baseline
        self.S -= self.baseline

    def integrate(self, filename=None, lims=None, use_bas=False):
        """
        Integrate the spectrum with a dedicated GUI.
        Calls :func:`klassez.anal.integrate` and writes in ``self.integrals`` with keys ``[ppm1:ppm2]``
        The values are saved in ``<filename>.igrl``.

        Parameters
        ----------
        filename : str
            The integral values will be saved in ``<filename>.igrl``.
            If ``None``, ``<self.filename>`` will be used instead.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.anal.integrate`

            :func:`klassez.anal.write_igrl`
        """
        if filename is None:
            filename = self.filename

        if lims is None:
            X_label = r'$\delta\,$' + misc.nuc_format(self.acqus['nuc']) + ' /ppm'
            self.integrals = anal.integrate(self.ppm, self.r, self.acqus['SFO1'],
                                            filename=filename,
                                            X_label=X_label, dx=2*self.acqus['dw'])
        else:
            if len(np.asarray(lims).shape) == 1:
                lims = [lims]
            keys = misc.limits_to_key(lims)
            if isinstance(keys, str):
                keys = [keys]

            self.integrals = {
                    key: 2 * self.acqus['dw'] * processing.integrate(self.r, x=self.ppm,
                                                                     dx=misc.calcres(self.freq),
                                                                     lims=lim, use_bas=use_bas)
                    for key, lim in zip(keys, lims)
                    }
            anal.write_igrl(filename, self.integrals, header=True)

    def write_integrals(self, other_dir=None):
        """
        Write the integrals in a file named ``{self.filename}.igrl``.
        The default path is ``self.datadir``.

        Parameters
        ----------
        other_dir : str or None
            Different location for the integrals file to write into. If None, ``self.datadir`` is used instead.

        Returns
        -------
        None

        .. seealso ::

            :func:`klassez.anal.write_igrl`

        """
        # Detect the file position
        if other_dir:
            path = Path(other_dir) / f'{self.filename}'
        else:
            path = self.datadir / f'{self.filename}'

        anal.write_igrl(path, self.integrals, header=True)

    def read_integrals(self, filename, n=-1):
        """
        Reads the integrals in the `.igrl` file named ``filename``, and stores the result in ``self.integrals``

        Parameters
        ----------
        filename : str
            Path to the filename to be read
        n : int
            Number of performed integrating procedure 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 attempt is ``n=1``.

        Returns
        -------
        None

        .. seealso ::

            :func:`klassez.anal.read_igrl`

        """
        self.integrals, _ = anal.read_igrl(filename, n)
        print(f'{filename} read.', 'tab:blue')

    def to_vf(self, filename=None, Hs=None, fvf=True):
        """
        Transform a simulated spectrum in a `.ivf` or `.fvf` file to be used in a deconvolution procedure.
        To do this, it reads the peak parameters saved in ``self.acqus``.
        The number of signals is determined by ``acqus['amplitudes']``.
        Multiplets are splitted according to their Js, and saved as components.

        Parameters
        ----------
        filename : str or None
            Path to the filename to be saved, without extension. If None, ``self.filename`` is used
        Hs : int or None
            Number of nuclei the spectrum integrates for. If None, the sum of the amplitudes is used.
        fvf : bool
            If True, adds the `'.fvf'` extension to the filename, if False, adds `'.ivf'`
        """

        # Create default filename
        if filename is None:
            filename = self.datadir / self.filename
        if fvf:
            filename_x = filename.with_suffix('.fvf')
        else:
            filename_x = filename.with_suffix('.ivf')

        # If Hs is not given, sum the intensities
        if Hs is None:
            Hs = np.sum(self.acqus['amplitudes'])
        # Correct the intensities to get the relative ones
        A, _ = misc.molfrac(self.acqus['amplitudes'])

        dic = {}    # Placeholder
        mult_counter = 0    # Multiplets counter, to set "group"
        peak_counter = 0    # Peak counter, for the dictionary keys

        # Make the fit.Peak objects
        for k, _ in enumerate(A):
            # Singlets
            if self.acqus['mult'][k] == 's':
                peak_counter += 1       # Increase peak counter
                # Make the fit.Peak object straightforwardly
                dic[peak_counter] = fit.Peak(self.acqus,
                                             u=self.acqus['shifts'][k],
                                             fwhm=self.acqus['fwhm'][k],
                                             k=A[k],
                                             b=self.acqus['b'][k],
                                             phi=self.acqus['phases'][k] * 180 / np.pi,
                                             N=self.procs['zf'],
                                             group=0,      # Identifier for singlets
                                             )

            else:
                mult_counter += 1       # Increase multiplets counter
                # Get the chemical shifts and intensities of the components of the multiplet
                u_in, I_in = sim.multiplet(
                        # CS must be given in Hz
                        misc.ppm2freq(self.acqus['shifts'][k], self.acqus['SFO1'], self.acqus['o1p']),
                        A[k],
                        self.acqus['mult'][k],
                        self.acqus['Jconst'][k],
                        )
                # Make the components one by one
                for u, I in zip(u_in, I_in):
                    peak_counter += 1   # Increase peak counter FOR EACH COMPONENT
                    dic[peak_counter] = fit.Peak(self.acqus,
                                                 # Convert the chemical shift given by sim.multiplet back to ppm
                                                 u=misc.freq2ppm(u, self.acqus['SFO1'], self.acqus['o1p']),
                                                 fwhm=self.acqus['fwhm'][k],
                                                 k=I,  # Relative intensity of the component, A[k] is already accounted for
                                                 b=self.acqus['b'][k],
                                                 phi=self.acqus['phases'][k] * 180 / np.pi,
                                                 N=self.procs['zf'],
                                                 group=mult_counter,   # Identify all the components as part of the same multiplet
                                                 )

        # Compute limits to save the spectrum
        limits = []     # Placeholder
        i_umax = np.argmax(self.acqus['shifts'])        # Index of the most deshielded peak
        umax = self.acqus['shifts'][i_umax]             # Chemical shift of the most deshielded peak
        # FWHM of the most deshielded peak converted to ppm
        f_umax = misc.freq2ppm(self.acqus['fwhm'][i_umax], self.acqus['SFO1'])
        i_umin = np.argmin(self.acqus['shifts'])        # Index of the most shielded peak
        umin = self.acqus['shifts'][i_umin]             # Chemical shift of the most shielded peak
        # FWHM of the most shielded peak converted to ppm
        f_umin = misc.freq2ppm(self.acqus['fwhm'][i_umin], self.acqus['SFO1'])
        # Limits are taken with an extra space of 64 FWHM, to allow them to go to zero
        limits = umax + 64*f_umax, umin - 64*f_umin

        # Write the file
        filename_x.write_text('!\n\n')
        fit.write_vf(filename_x, dic, lims=limits, Int=Hs)
        print(f'File {filename_x} generated successfully.', c='tab:cyan')

    def to_wav(self, filename=None, cutoff=None, rate=44100):
        """
        Converts the FID in an audio file by using :func:`klasse.misc.data2wav`.

        Parameters
        ----------
        filename : str
            Path where to save the file. If None, ``self.filename`` is used
        cutoff : float
            Clipping limits for the FID
        rate : int
            Sampling rate in samples/sec

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.misc.data2wav`
        """
        if filename is None:
            filename = Path.cwd / self.filename
        else:
            filename = Path(filename)
        # Make a shallow copy of the FID
        data = deepcopy(self.fid)
        misc.data2wav(data, filename, cutoff, rate)

    def abs(self, n=5, lims=None, alpha=2.75, qfil=False, qfilp={'u': 4.7, 's': 10}):
        """
        Correct the baseline automatically on the real part of the spectrum, then retrieve
        the imaginary part through Hilbert transform.

        Parameters
        ----------
        n : int
            Number of coefficients of the polynomial baseline
        lims : tuple or None
            Limits for the region on which to compute the baseline, in ppm
        alpha : float
            The threshold will be set as ``thr = alpha * np.std(np.gradient(data))``
        qfil : bool
            Choose whether to apply a filter on the solvent region (True) or not (False)
        qfilp : dict
            Parameters to be used to compute the filter if ``qfil=True``. Keys:

            * 'u' = center of the filter in ppm
            * 's' = width of the filter in Hz

        Returns
        -------
        baseline : 1darray
            Employed baseline for correction

        .. seealso::

            :func:`klassez.processing.abs`

            :func:`klassez.processing.hilbert`
        """
        original = deepcopy(self.S)
        # Correct the baseline
        datap = processing.abs(self.ppm, self.r, n=n, lims=lims, alpha=alpha, qfil=qfil, qfilp=qfilp)
        # Retrieve imaginary part
        self.S = processing.hilbert(datap)
        # Overwrite it
        return original - datap

    def abs_v2(self, n=5, lims=None, alpha=5, winsize=2, qfil=False, qfilp={'u': 4.7, 's': 10}):
        """
        Correct the baseline automatically on the real part of the spectrum, then retrieve
        the imaginary part through Hilbert transform.

        Parameters
        ----------
        n : int
            Number of coefficients of the polynomial baseline
        lims : tuple or None
            Limits for the region on which to compute the baseline, in ppm
        alpha : float
            The threshold will be set as ``thr = alpha * np.std(np.gradient(data))``
        qfil : bool
            Choose whether to apply a filter on the solvent region (True) or not (False)
        qfilp : dict
            Parameters to be used to compute the filter if ``qfil=True``. Keys:
            * 'u' = center of the filter in ppm
            * 's' = width of the filter in Hz

        Returns
        -------
        baseline : 1darray
            Employed baseline for correction

        .. seealso::

            :func:`klassez.processing.abs_v2`

            :func:`klassez.processing.hilbert`
        """
        original = deepcopy(self.S)
        # Correct the baseline
        datap = processing.abs_v2(self.ppm, self.r, self.acqus['SFO1'], n=n, lims=lims, alpha=alpha, winsize=winsize, qfil=qfil, qfilp=qfilp)
        # Retrieve imaginary part
        self.S = processing.hilbert(datap)
        return original - datap


class pSpectrum_1D(Spectrum_1D):
    """
    Subclass of :class:`klassez.Spectra.Spectrum_1D` that allows to handle processed 1D NMR spectra.
    Useful when dealing with traces of 2D spectra.
    Shares the same attributes with :class:`klassez.Spectra.Spectrum_1D` .

    Of course, there is no ``fid`` attribute.

    .. seealso::

        :class:`klassez.Spectra.Spectrum_1D`

    """
    def __init__(self, in_file, acqus=None, procs=None, istrace=False, filename='T'):
        """
        Initialize the class.

        Parameters
        ----------
        in_file : str or 1darray
            If ``istrace=True``, ``in_file`` is the NMR spectrum. Else, it is the directory of the processed data. Only supports Bruker format!
        acqus : dict or None
            If `istrace=True`, you must supply the associated ``acqus`` dictionary. Else, it is not necessary as it is read from the input directory
        procs : dict or None
            You can pass the dictionary of processing parameters, if you want. Otherwise, it is initialized with standard values.
        istrace : bool
            Declare the object as trace extracted from a 2D (True) or as true experimental spectrum (False)
        filename : str
            If ``istrace=True``, this will be the filename of ``self.acqus`` and ``self.procs``
        """
        if istrace is True:     # it comes from a 2D
            assert isinstance(in_file, np.ndarray), 'The first parameter must be an array if istrace=True!'
            self.datadir = Path.cwd()
            self.filename = filename
            if np.iscomplexobj(in_file):    # There is the whole information
                self.r = in_file.real
                self.i = in_file.imag
            else:                           # There is the real part only
                self.r = in_file
                self.i = np.zeros_like(in_file)
            self.S = self.r + 1j * self.i   # Make the complex spectrum
            self.acqus = acqus

        else:
            self.datadir, self.filename = misc.get_datadir_filename(in_file)
            with warnings.catch_warnings():   # Suppress errors due to CONVDTA in TopSpin
                warnings.simplefilter("ignore")
                # Read the dictionary
                dic, _ = ng.bruker.read_pdata(in_file)  # this might not contain acqus
                base_dic, _ = ng.bruker.read(in_file.rsplit('pdata', 1)[0])     # this for sure does
                dic = misc.merge_dict(dic, base_dic)
                # Read the real and imaginary part, separately
                _, self.r = ng.bruker.read_pdata(in_file, bin_files=['1r'])
                _, self.i = ng.bruker.read_pdata(in_file, bin_files=['1i'])
                # Mount them together to make the complex spectrum
                self.S = self.r + 1j * self.i
                # Make acqus
                self.acqus = misc.makeacqus_1D(dic)
                self.acqus['BYTORDA'] = dic['acqus']['BYTORDA']
                self.acqus['DTYPA'] = dic['acqus']['DTYPA']
                self.ngdic = dic
                del dic
        # Set the group delay, if there is
        try:
            self.acqus['GRPDLY'] = int(self.ngdic['acqus']['GRPDLY'])
        except Exception:
            self.acqus['GRPDLY'] = 0

        if procs is None:   # Make it
            if (self.datadir / self.filename).with_suffix('.procs').exists():
                self.procs = self.read_procs()
            else:
                self.procs = self._default_procs(self.acqus)
        else:   # Copy it
            self.procs = deepcopy(procs)

        # Write the .procs file
        self.write_procs()

        # Calculate frequency and ppm scales
        self.freq = processing.make_scale(self.r.shape[0], dw=self.acqus['dw'])
        if self.acqus['SFO1'] < 0:      # Correct for negative gamma nuclei
            self.freq = self.freq[::-1]
        self.ppm = misc.freq2ppm(self.freq, B0=self.acqus['SFO1'], o1p=self.acqus['o1p'])

        # Initialize fit object
        self.F = fit.Voigt_Fit(self.ppm, self.S, self.acqus['t1'], self.acqus['SFO1'], self.acqus['o1p'], self.acqus['nuc'])


class Spectrum_2D:
    """
    Class: 2D NMR spectrum

    Attributes
    ----------
    datadir : str
        Path to the input file/dataset directory
    filename : str
        Base of the name of the file, without extensions
    fid : 2darray
        FID
    acqus : dict
        Dictionary of acqusition parameters
    ngdic : dict
        Created only if it is an experimental spectrum. Generated by :func:`nmrglue.<spectrometer>.read`, contains all the information on the spectrometer and on the spectrum.
    procs : dict
        Dictionary of processing parameters
    eaeflag : int
        If ``FnMODE='Echo-Antiecho'``, keeps track of the manipulation of the data so to not repeat the same process twice
    S : 2darray
        Complex (or hypercomplex, depending on ``FnMODE``) spectrum
    rr : 2darray
        Real part F2, real part F1
    ii : 2darray
        Imaginary part F2, imaginary part F1
    ir : 2darray
        Real part F2, imaginary part F1. Only exist if F1 is acquired in phase-sensitive mode
    ri : 2darray
        Imaginary part F2, real part F1. Only exist if F1 is acquired in phase-sensitive mode
    freq_f1 : 1darray
        Frequency scale of the indirect dimension, in Hz
    freq_f2 : 1darray
        Frequency scale of the direct dimension, in Hz
    ppm_f1 : 1darray
        ppm scale of the indirect dimension
    ppm_f2 : 1darray
        ppm scale of the direct dimension
    trf1 : dict
        Projections of the indirect dimension, as ``1darrays``. Keys: 'ppm_f2' where they were taken
    trf2 : dict
        Projections of the direct dimension, as ``1darrays``. Keys: 'ppm_f1' where they were taken
    Trf1 : dict
        Projections of the indirect dimension, as :class:`pSpectrum_1D` objects. Keys: 'ppm_f2' where they were taken
    Trf2 : dict
        Projections of the direct dimension, as :class:`pSpectrum_1D` objects. Keys: 'ppm_f1' where they were taken
    integrals : dict
        Dictionary where to save the regions and values of the integrals.
    """
    def __str__(self):
        """ Prints info on the object """
        doc = '-'*64
        doc += '\nSpectrum_2D object.\n'
        if 'ngdic' in self.__dict__.keys():
            doc += f'Read from "{self.datadir}"\n'
        else:
            doc += f'Simulated from "{self.filename}" in {self.datadir}\n'
        N = self.fid.shape
        doc += f'''It is a {self.acqus["nuc1"]}-{self.acqus["nuc2"]} spectrum recorded over a \nsweep width of \n
        {self.acqus["SW1p"]} ppm centered at {self.acqus["o1p"]} ppm in F1, and\n{self.acqus["SW2p"]} ppm centered at
        {self.acqus["o2p"]} ppm in F2.\n'''
        doc += f'The FID is {N[0]}x{N[1]} points long.\n'
        doc += '-'*64

        return doc

    @property
    def S(self) -> np.ndarray:
        """ Normal getter for ``self.S`` attribute """
        return self._S

    @S.setter
    def S(self, data: np.ndarray):
        """ Every time ``self.S`` is instanced, ``self.r`` and ``self.i`` are updated automatically """
        self._S = data
        if 'FnMODE' not in self.acqus.keys():
            self.rr = self._S.real
            self.ii = self._S.imag
        if self.acqus['FnMODE'] in ['No', 'QF', 'QF-nofreq']:
            self.rr = self._S.real
            self.ii = self._S.imag
        else:
            self.rr, self.ir, self.ri, self.ii = processing.unpack_2D(self._S)

    def __init__(self, in_file, spect='bruker', pv=False, isexp=True):
        """
        Initialize the class.

        Parameters
        ----------
        in_file : str
            path to file to read, or to the folder of the spectrum
        spect : str
            Data file format. Allowed: ``'bruker'`` only
        pv : bool
            True if you want to use pseudo-voigt lineshapes for simulation, False for Voigt
        isexp : bool
            True if this is an experimental dataset, False if it is simulated

        """
        self.datadir, self.filename = misc.get_datadir_filename(in_file, isexp)

        if isexp is False:   # Simulate the data
            self.acqus = sim.load_sim_2D(in_file)   # Read the acqus dictionary from the file
            self.acqus['FnMODE'] = 'States-TPPI'
            self.acqus['GRPDLY'] = 0
            self.fid = sim.sim_2D(in_file, pv=pv)   # Read the FID
        else:
            self.fid, self.acqus, self.ngdic = misc.read_fid_acqus_2D(in_file, spect)

        # put a flag for shuffling EAE data
        if self.acqus['FnMODE'] == 'Echo-Antiecho':
            self.eaeflag = 1    # i.e. to be shuffled
        else:
            self.eaeflag = 0    # no need of shuffling

        # initialize the procs dictionary with default values
        if (self.datadir / self.filename).with_suffix('.procs').exists():
            self.procs = self.read_procs()
        else:
            self.procs = self._default_procs(self.acqus)
            self.write_procs()

        # Create empty dictionary where to save the projections
        self.trf1 = {}
        self.trf2 = {}
        self.Trf1 = {}
        self.Trf2 = {}

    @staticmethod
    def _default_procs(acqus):
        """
        Create the procs dictionary with the default values.

        Parameters
        ----------
        acqus : dict
            Dictionary of acquisition parameters

        Returns
        -------
        procs : dict
            Dictionary of processing parameters
        """
        proc_init_2D = (
                [deepcopy(wf0), deepcopy(wf0)],     # window function
                [None, None],   # zero-fill
                [0.5, 0.5],     # fcor
                [0, 0]           # tdeff
                )

        procs = {}
        for k, key in enumerate(proc_keys_1D):
            procs[key] = proc_init_2D[k]         # Processing parameters
        procs['wf'][0]['sw'] = round(acqus['SW1'], 4)
        procs['wf'][1]['sw'] = round(acqus['SW2'], 4)

        # Then, phases
        procs['p0_1'] = 0
        procs['p1_1'] = 0
        procs['pv_1'] = round(acqus['o1p'], 2)
        procs['p0_2'] = 0
        procs['p1_2'] = 0
        procs['pv_2'] = round(acqus['o2p'], 2)
        # Calibration
        procs['cal_1'] = 0
        procs['cal_2'] = 0
        return procs

    def convdta(self, scaling=1):
        """
        Calls :func:`klassez.processing.convdta` to compensate for the group delay.
        It does not always work, depends on TopSpin version and planets alignment.

        .. warning::

            Better to use :func:`klassez.Spectra.Spectrum_2D.pknl`


        Parameters
        ----------
        scaling : float
            Scaling factor for processingconvdta.
        """
        self.fid = processing.convdta(self.fid, self.acqus['GRPDLY'], scaling)

    def eae(self):
        """
        Calls :func:`klassez.processing.eae` to shuffle the data and make a States-like FID.
        Sets ``self.eaeflag`` to 0.
        """
        if self.eaeflag:    # Do it only if it was not done before
            self.fid = processing.eae(self.fid)
            self.eaeflag = 0

    def add_noise(self, s_n=1):
        """
        Adds noise to the FID, using the function :func:`klassez.sim.noisegen`.

        Parameters
        ----------
        s_n : float
            Standard deviation of the noise
        """
        self.fid += sim.noisegen(self.fid.shape, self.acqus['o2'], self.acqus['t2'], s_n=s_n)

    def scan(self, ns=1, s_n=1):
        """
        Simulates the acquisition of ``ns`` scans, by adding a different realization of noise at each iteration.
        The function is supposed to start with the FID without noise at all. If not, the results will be biased.
        For reproducible results, fix the seed!

        Parameters
        ----------
        ns : int
            Number of scans to accumulate
        s_n : float
            Standard deviation of the noise for each scan

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.sim.noisegen`
        """
        clean_fid = np.copy(self.fid)   # Save a shallow copy of the FID without noise
        for k in range(ns):
            # Each scan is: clean FID + noise
            self.fid += clean_fid + sim.noisegen(self.fid.shape, self.acqus['o2'], self.acqus['t2'], s_n=s_n)

    def xf2(self):
        """
        Process only the direct dimension.
        Calls :func:`klassez.processing.fp` using ``self.procs[keys][1]``
        The result is stored in ``self.S``, then ``self.rr`` and ``self.ii`` are written.
        ``self.freq_f1`` and ``self.ppm_f1`` are assigned with the indexes of the transients.

        .. seealso::

            :func:`klassez.processing.fp`
        """
        # Make empty self.S according to the zero-filling option in F2
        if self.procs['zf'][1] is None:
            self.S = np.zeros_like(self.fid).astype(self.fid.dtype)
        else:
            self.S = np.zeros((self.fid.shape[0], self.procs['zf'][1])).astype(self.fid.dtype)

        # Apply 1D processing on F2 only, row-by-row
        for k in range(self.fid.shape[0]):
            self.S[k] = processing.fp(self.fid[k], wf=self.procs['wf'][1], zf=self.procs['zf'][1], fcor=self.procs['fcor'][1], tdeff=self.procs['tdeff'][1])

        # Make frequency and ppm scales
        self.freq_f2 = processing.make_scale(self.S.shape[1], dw=self.acqus['dw2'])
        if self.acqus['SFO2'] < 0:  # Correct for negative gamma nuclei
            self.freq_f2 = self.freq_f2[::-1]
        self.ppm_f2 = misc.freq2ppm(self.freq_f2, B0=self.acqus['SFO2'], o1p=self.acqus['o2p'])

        # Correct also the spectrum for negative gamma nuclei
        if self.acqus['SFO2'] < 0:
            self.S = self.S[..., ::-1]

        # Use the spectrum index as fake scale
        self.freq_f1 = np.arange(self.S.shape[0])
        self.ppm_f1 = np.arange(self.S.shape[0])

    def splitcomb(self, J=53.8):
        """
        Applies the IPAP virtual decoupling scheme by calling :func:`klassez.processing.splitcomb`` with appropriate parameters.
        Replaces the FID.

        Parameters
        ----------
        J : float
            Scalar coupling constant, in Hz, of the coupling to be suppressed.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.splitcomb`
        """
        fid = deepcopy(self.fid)
        newfid = processing.splitcomb(fid, self.acqus['t2'], J=J)
        self.fid = newfid

    def xf1(self):
        """
        Process only the indirect dimension.
        Transposes the spectrum in hypermode or normally if ``FnMODE != 'QF'``, ``'QF-nofreq'`` or ``'No'``,
        then calls for :func:`klassez.processing.fp` using ``self.procs[keys][0]``, finally transposes it back.
        The result is stored in ``self.S``, then ``self.rr`` and ``self.ii`` are written.
        ``freq_f2`` and ``ppm_f2`` are assigned with the indexes of the indirect evolutions.
        """
        # Transpose
        if self.acqus['FnMODE'] in ['QF', 'No', 'QF-nofreq']:    # Just complex spectrum
            self.fid = self.fid.T
        else:   # Hypercomplex spectrum
            self.fid = processing.tp_hyper(self.fid)

        # Make empty self.S according to the zero-filling option in f1
        if self.procs['zf'][0] is None:
            self.S = np.zeros_like(self.fid).astype(self.fid.dtype)
        else:
            self.S = np.zeros((self.fid.shape[0], self.procs['zf'][0])).astype(self.fid.dtype)

        # Apply 1D processing on F1 only, row by row because fid is transposed
        for k in range(self.fid.shape[0]):
            self.S[k] = processing.fp(self.fid[k], wf=self.procs['wf'][0], zf=self.procs['zf'][0], fcor=self.procs['fcor'][0], tdeff=self.procs['tdeff'][0])

        # Transpose it back
        if self.acqus['FnMODE'] in ['QF', 'No', 'QF-nofreq']:
            self.fid = self.fid.T
            self.S = self.S.T
        else:
            self.fid = processing.tp_hyper(self.fid)
            self.S = processing.tp_hyper(self.S)

        # Make frequency and ppm scales
        self.freq_f1 = processing.make_scale(self.S.shape[0], dw=self.acqus['dw1'])
        if self.acqus['SFO1'] < 0:  # Correct for negative gamma nuclei
            self.freq_f1 = self.freq_f1[::-1]
        self.ppm_f1 = misc.freq2ppm(self.freq_f1, B0=self.acqus['SFO1'], o1p=self.acqus['o1p'])

        # Use evolution index as fake scale
        self.freq_f2 = np.arange(self.S.shape[1])
        self.ppm_f2 = np.arange(self.S.shape[1])

    def process(self, interactive=False, **int_kwargs):
        """
        Performs the full processing of the FID on both dimensions. The parameters are read from ``self.procs``.
        If ``FnMODE='Echo-Antiecho'`` and you did not call :func:`self.eae` before, the FID is converted to States-TPPI with :func:`klassez.processing.eae` before to start.
        If ``interactive=True``, calls :func:`klassez.processing.interactive_xfb` with ``int_kwargs``, else calls :func:`klassez.processing.xfb`.

        The complex/hypercomplex spectrum is stored in ``self.S``, then unpacked into ``self.rr``, ``self.ri``, ``self.ir``, ``self.ii``.
        If ``FnMODE='Echo-Antiecho'``, a phase correction of -90 degrees is applied on the indirect dimension.

        Parameters
        ----------
        interactive : bool
            True if you want to open the interactive panel, False to read the parameters from ``self.procs``.
        int_kwargs : keyworded arguments
            Additional parameters for :func:`klassez.processing.interactive_xfb`, if ``interactive=True``.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.eae`

            :func:`klassez.processing.interactive_xfb`

            :func:`klassez.processing.xfb`

            :func:`klassez.processing.unpack_2D`

        """
        # If Echo-Antiecho, pre-process the FID to get the correct spectral arrangement
        self.eae()

        # Call for the interacrive processing
        if interactive is True:
            self.S, self.procs = processing.interactive_xfb(self.fid, self.acqus, self.procs, **int_kwargs)
        else:
            self.S = processing.xfb(self.fid, wf=self.procs['wf'], zf=self.procs['zf'], fcor=self.procs['fcor'], tdeff=self.procs['tdeff'], FnMODE=self.acqus['FnMODE'], u=False)

        # For EAE, correct the 90° phase shift in F1
        if self.acqus['FnMODE'] == 'Echo-Antiecho':
            self.S = processing.tp_hyper(self.S)
            self.S = processing.ps(self.S, p0=-90)[0]
            self.S = processing.tp_hyper(self.S)

        # Correct for negative gamma nuclei
        if self.acqus['SFO2'] < 0:
            self.S = self.S[:, ::-1]
        if self.acqus['SFO1'] < 0:
            # Reversing the spectrum in the indirect dimension causes a 90° dephasing
            self.S = self.S[::-1, :]
            if self.acqus['FnMODE'] in ['QF', 'QF-nofreq']:
                self.S = self.S.T
            else:
                self.S = processing.tp_hyper(self.S)
            self.S = processing.ps(self.S, p0=-90)[0]   # ...that has to be corrected
            if self.acqus['FnMODE'] in ['QF', 'QF-nofreq']:
                self.S = self.S.T
            else:
                self.S = processing.tp_hyper(self.S)

        # Calculates the frequency and ppm scales
        self.freq_f1 = processing.make_scale(self.rr.shape[0], dw=self.acqus['dw1'])
        if self.acqus['SFO1'] < 0:  # Correct for negative gamma nuclei
            self.freq_f1 = self.freq_f1[::-1]
        self.ppm_f1 = misc.freq2ppm(self.freq_f1, B0=self.acqus['SFO1'], o1p=self.acqus['o1p'])
        self.freq_f2 = processing.make_scale(self.rr.shape[1], dw=self.acqus['dw2'])
        if self.acqus['SFO2'] < 0:  # Correct for negative gamma nuclei
            self.freq_f2 = self.freq_f2[::-1]
        self.ppm_f2 = misc.freq2ppm(self.freq_f2, B0=self.acqus['SFO2'], o1p=self.acqus['o2p'])

        # Apply phase correction according to procs
        self.adjph(p02=self.procs['p0_2'], p12=self.procs['p1_2'], pv2=self.procs['pv_2'], p01=self.procs['p0_1'], p11=self.procs['p1_1'], pv1=self.procs['pv_1'], update=False)

    def inv_process(self):
        """
        Performs the inverse processing of the spectrum according to the given parameters.
        Overwrites the ``S`` attribute!!
        Calls :func:`klassez.processing.inv_xfb`.
        """

        # For EAE, correct the 90° phase shift in F1
        if self.acqus['FnMODE'] == 'Echo-Antiecho':
            self.S = processing.tp_hyper(self.S)
            self.S = processing.ps(self.S, p0=90)[0]
            self.S = processing.tp_hyper(self.S)

        # Correction for negative gamma nuclei
        if self.acqus['SFO2'] < 0:
            self.S = self.S[:, ::-1]
        if self.acqus['SFO1'] < 0:
            self.S = self.S[::-1, :]
            self.S = processing.tp_hyper(self.S)
            self.S = processing.ps(self.S, p0=-90)[0]
            self.S = processing.tp_hyper(self.S)

        # Compute
        self.S = processing.inv_xfb(self.S, wf=self.procs['wf'], size=(self.acqus['TD1'], self.acqus['TD2']), fcor=self.procs['fcor'], FnMODE=self.acqus['FnMODE'])

    def mc(self):
        """
        Computes the magnitude of the spectrum on ``self.S``.
        Then, updates ``rr``, ``ri``, ``ir``, ``ii``.

        .. seealso::

            :func:`klassez.processing.unpack_2D`
        """
        self.S = (self.S.real**2 + self.S.imag**2)**0.5

    def strip(self, xlim=None, ylim=None):
        """
        Perform the strip transform in F2 with ``xlim`` and on F1 with ``ylim``.
        The frequency and ppm scales are updated as well.
        The pivot for phase correction is moved to the center of the new scale.
        The imaginary parts are reconstructed via Hilbert transform.

        Parameters
        ----------
        xlim : tuple or None
            Limits in F2 in ppm
        ylim : tuple or None
            Limits in F1 in ppm

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.misc.trim_data_2D`

            :func:`klassez.processing.hilbert`

            :func:`klassez.processing.hilbert2`
        """
        # Cut the spectrum on the rr part
        self.ppm_f2, self.ppm_f1, self.rr = misc.trim_data_2D(self.ppm_f2, self.ppm_f1, self.rr, xlim, ylim)

        # Reconstruct the imaginary components
        if self.acqus['FnMODE'] in ['QF', 'QF-nofreq']:
            self.S = processing.hilbert(self.rr)
        else:
            self.S = processing.repack_2D(processing.hilbert2(self.rr))

        # Cut the frequency scales as well
        self.freq_f2 = misc.ppm2freq(self.ppm_f2, self.acqus['SFO2'], self.acqus['o2p'])
        self.freq_f1 = misc.ppm2freq(self.ppm_f1, self.acqus['SFO1'], self.acqus['o1p'])

        # Update the pivot points to fix them in the center of the stripped part
        self.procs['pv2'] = round(np.mean(self.ppm_f2), 5)
        self.procs['pv1'] = round(np.mean(self.ppm_f1), 5)

    def adjph(self, p01=None, p11=None, pv1=None, p02=None, p12=None, pv2=None, update=True):
        """
        Adjusts the phases of the spectrum according to the given parameters, or interactively if they are left as default.

        The non-interactive workflow is to apply :func:`klassez.processing.ps` on F2, transpose according to ``FnMODE``, apply :func:`klassez.processing.ps` on F1, transpose back.
        If ``FnMODE='No'``, the phase correction is applied only on F2, as it should be done in a pseudo-2D experiment.
        Once ``self.S`` was updated and unpacked, the phase values are added to the procs dictionary to keep track of multiple phase adjustments.

        Parameters
        ----------
        p01 : float or None
            0-th order phase correction /° of the indirect dimension
        p11 : float or None
            1-st order phase correction /° of the indirect dimension
        pv1 : float or None
            1-st order pivot /ppm of the indirect dimension
        p02 : float or None
            0-th order phase correction /° of the direct dimension
        p12 : float or None
            1-st order phase correction /° of the direct dimension
        pv2 : float or None
            1-st order pivot /ppm of the direct dimension
        update : bool
            Choose if to update the procs dictionary or not

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.ps`

            :func:`klassez.processing.interactive_phase_2D`

        """
        interactive = True      # by default
        # Set pivot to carrier if not specified
        if pv1 is None:
            pv1 = self.acqus['o1p']
        if pv2 is None:
            pv2 = self.acqus['o2p']
        ph = [p01, p11, p02, p12]   # for easier handling
        for p in ph:
            # If a phase is specified, interactive is set to False...
            if p is not None:
                interactive = False
        if interactive is False:
            # ... and the not-set phases are put to 0
            for i, p in enumerate(ph):
                if p is None:
                    ph[i] = 0
            # Adjust the phases according to the given values

            # Phase F2
            self.S, values_f2 = processing.ps(self.S, self.ppm_f2, p0=ph[2], p1=ph[3], pivot=pv2)
            # Phase F1
            if self.acqus['FnMODE'] in ['No', 'QF-nofreq']:    # Skip it
                pass
            # else, transpose according to FnMODE, phase, transpose back
            elif self.acqus['FnMODE'] == 'QF':
                self.S = self.S.T
                self.S, values_f1 = processing.ps(self.S, self.ppm_f1, p0=ph[0], p1=ph[1], pivot=pv1)
                self.S = self.S.T
            else:
                self.S = processing.tp_hyper(self.S)
                self.S, values_f1 = processing.ps(self.S, self.ppm_f1, p0=ph[0], p1=ph[1], pivot=pv1)
                self.S = processing.tp_hyper(self.S)

        else:
            # Call interactive phase correction
            if self.acqus['FnMODE'] in ['QF', 'No', 'QF-nofreq']:
                self.S, values_f1, values_f2 = processing.interactive_phase_2D(self.ppm_f1, self.ppm_f2, self.S, False)
            else:
                self.S, values_f1, values_f2 = processing.interactive_phase_2D(self.ppm_f1, self.ppm_f2, self.S)

        # update the procs dictionary
        if update:
            self.procs['p0_2'] += round(values_f2[0], 2)
            self.procs['p1_2'] += round(values_f2[1], 2)
            if values_f2[2] is not None:
                self.procs['pv_2'] = round(values_f2[2], 5)
            self.procs['p0_1'] += round(values_f1[0], 2)
            self.procs['p1_1'] += round(values_f1[1], 2)
            if values_f1[2] is not None:
                self.procs['pv_1'] = round(values_f1[2], 5)

        # Update the .procs file
        self.write_procs()

    def pknl(self):
        """
        Reverses the effect of the digital filter by applying a first order phase correction.
        If the ``S`` attribute exists, the processing is applied on the processed data. Otherwise, the correction is performed on the FID.
        This function calls :func:`klassez.processing.pknl` with the parameter ``grpdly`` read from the ``acqus`` dictionary.

        .. seealso::

            :func:`klassez.processing.pknl`
        """
        if hasattr(self, 'S'):
            self.S = processing.pknl(self.S, grpdly=self.acqus['GRPDLY'], onfid=False)
        else:
            self.fid = processing.pknl(self.fid, grpdly=self.acqus['GRPDLY'], onfid=True)

    def qfil(self, which=None, u=None, s=None, from_procs=True, fqscale=False, *, SFO=None, O1P=None):
        """
        Gaussian filter to suppress signals.
        Tries to read ``self.procs['qfil']``, which is ``{ 'u': u, 's': s }``
        Otherwise, these are set interactively by :func:`klassez.processing.interactive_qfil` and then added to ``self.procs``.

        Parameters
        ----------
        which : int or None
            Index of the F2 trace to be used for :func:`klassez.processing.interactive_qfil`. If None, a suitable trace can be selected using :func:`klassez.anal.select_traces`.
        u : float
            Position /ppm
        s : float
            Width (standard deviation) /Hz
        from_procs : bool
            Read the parameters from the ``procs`` dictionary
        fqscale : bool
            True to use the frequency scale, False for the ppm scale.
        SFO : float
            Nucleus Larmor frequency to use for the conversion to Hz. If None, ``self.acqus['SFO2']`` is used by default.
        O1P : float
            Carrier position in ppm. If None, ``self.acqus['o2p']`` is used by default.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.interactive_qfil`

            :func:`klassez.anal.select_traces`

        """
        if SFO is None:
            SFO = self.acqus['SFO2']
        if O1P is None:
            O1P = self.acqus['o2p']

        if fqscale:
            x = self.freq_f2
            SFO1 = 1
        else:
            x = self.ppm_f2
            SFO1 = SFO

        if 'qfil' not in self.procs.keys():     # Then add it
            self.procs['qfil'] = {'u': u, 's': s}

        if 'qfil' not in self.procs.keys():  # Then add it
            self.procs['qfil'] = {'u': u, 's': s}

        for key, value in self.procs['qfil'].items():
            if value is None or not from_procs:   # missing value --> call for interaction
                if which is None:   # select a spectrum to be used
                    which_list = anal.select_traces(self.ppm_f1, self.ppm_f2, self.rr, Neg=True)
                    which, _ = misc.ppmfind(self.ppm_f1, which_list[0][1])
                # Now get the values
                u, self.procs['qfil']['s'] = processing.interactive_qfil(x, self.rr[which], SFO1)
                if fqscale:     # convert the chemical shift to ppm
                    self.procs['qfil']['u'] = misc.freq2ppm(u, SFO, O1P)
                else:
                    self.procs['qfil']['u'] = u
                break

        # Apply the filter
        self.rr = processing.qfil(self.ppm_f2, self.rr, self.procs['qfil']['u'], self.procs['qfil']['s'], SFO)
        # Unpack according to procs
        if 'FnMODE' not in self.acqus.keys():
            self.S = processing.hilbert(self.rr)
        elif self.acqus['FnMODE'] in ['QF', 'No', 'QF-nofreq']:
            self.S = processing.hilbert(self.rr)
        else:
            self.S = processing.repack_2D(*processing.hilbert2(self.rr))

        self.write_procs()

    def cal(self, offset=[None, None], isHz=False, update=True, from_procs=True, *, ref1=None, ref2=None):
        """
        Calibration of the ppm and frequency scales according to a given value, or interactively. In this latter case, a reference peak must be chosen.

        Parameters
        ----------
        offset : tuple
            (scale shift F1, scale shift F2)
        isHz : tuple of bool
            True if ``offset`` is in frequency units, False if ``offset`` is in ppm
        update : bool
            Choose if to update the ``procs`` dictionary or not
        from_procs : bool
            Read the parameters from the ``procs`` dictionary
        ref1 : list of 1darray
            Reference ppm scale and spectrum to be used as reference for calibration of F1 ``[ppm, spectrum]``
        ref2 : list of 1darray
            Reference ppm scale and spectrum to be used as reference for calibration of F2 ``[ppm, spectrum]``

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.calibration`

            :func:`klassez.anal.select_traces`
        """
        def _calibrate(ppm, trace, SFO1, ref=None):
            """ Main function that calls the real calibration """
            offppm = processing.calibration(ppm, trace, ref=ref)
            offhz = misc.ppm2freq(offppm, SFO1)
            return offppm, offhz

        if from_procs:
            if 'cal_2' in self.procs.keys():
                offp2 = self.procs['cal_2']
                offh2 = misc.ppm2freq(offp2, self.acqus['SFO2'])
                # Apply the calibration
                self.freq_f2 += offh2
                self.ppm_f2 += offp2
                # Move the offsets to the center of the SW
                self.acqus['o2p'] += offp2
                self.acqus['o2'] += offh2
                print(f'Applied calibration of {offp2:-.3f} ppm ({offh2:-.2f} Hz) on F2 as read from the procs dictionary.', c='violet')
            if 'cal_1' in self.procs.keys():
                offp1 = self.procs['cal_1']
                offh1 = misc.ppm2freq(offp1, self.acqus['SFO1'])
                # Apply the calibration
                self.freq_f1 += offh1
                self.ppm_f1 += offp1
                # Move the offsets to the center of the SW
                self.acqus['o1p'] += offp1
                self.acqus['o1'] += offh1
                print(f'Applied calibration of {offp1:-.3f} ppm ({offh1:-.2f} Hz) on F1 as read from the procs dictionary.', c='violet')
        else:
            # Get the missing entries
            if offset[0] is None or offset[1] is None:  # Select the reference traces
                coord = anal.select_traces(self.ppm_f1, self.ppm_f2, self.rr, Neg=True)
                ix, iy = coord[0][0], coord[0][1]   # Position of the first crosshair
                # F2 reference spectrum
                X = anal.get_trace(self.rr, self.ppm_f2, self.ppm_f1, iy, column=False)
                # F1 reference spectrum
                Y = anal.get_trace(self.rr, self.ppm_f2, self.ppm_f1, ix, column=True)

            if offset[1] is None:   # Get it
                ppm_f2 = np.copy(self.ppm_f2)
                offp2, offh2 = _calibrate(ppm_f2, X, self.acqus['SFO2'], ref=ref2)
            else:   # Calculate offh2 from offp2 or viceversa
                if isHz:    # offp2 is missing
                    offh2 = offset[1]
                    offp2 = misc.freq2ppm(offh2, self.acqus['SFO2'])
                else:       # offh2 is missing
                    offp2 = offset[1]
                    offh2 = misc.ppm2freq(offp2, self.acqus['SFO2'])

            if offset[0] is None:   # Get it
                ppm_f1 = np.copy(self.ppm_f1)
                offp1, offh1 = _calibrate(ppm_f1, Y, self.acqus['SFO1'], ref=ref1)
            else:   # Calculate offh1 from offp1 or viceversa
                if isHz:    # offp1 is missing
                    offh1 = offset[0]
                    offp1 = misc.freq2ppm(offh1, self.acqus['SFO1'])
                else:       # offh1 is missing
                    offp1 = offset[0]
                    offh1 = misc.ppm2freq(offp1, self.acqus['SFO1'])

            # Apply the calibration
            self.freq_f2 += offh2
            self.ppm_f2 += offp2
            self.freq_f1 += offh1
            self.ppm_f1 += offp1
            # Move the offsets to the center of the SW
            self.acqus['o1p'] += offp1
            self.acqus['o1'] += offh1
            self.acqus['o2p'] += offp2
            self.acqus['o2'] += offh2
            # Update the procs dictionary
            if update:
                self.procs['cal_1'] += offp1
                self.procs['cal_2'] += offp2
            # Update the .procs file
            self.write_procs()

    def calf2(self, value=None, isHz=False, update=True, from_procs=True, *, ref=None):
        """
        Calibrates the ppm and frequency scale of the direct dimension according to a given value, or interactively.
        Calls :func:`self.cal` on F2 only

        Parameters
        ----------
        value : float or None
            scale shift value
        isHz : bool
            True if ``offset`` is in frequency units, False if ``offset`` is in ppm
        update : bool
            Choose if to update the ``procs`` dictionary or not
        from_procs : bool
            Read the parameters from the ``procs`` dictionary
        ref : list of 1darray
            Reference ppm scale and spectrum to be used as reference for calibration ``[ppm, spectrum]``

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.Spectra.Spectrum_2D.cal`
        """
        offset = [0, value]
        self.cal(offset, isHz, update=update, from_procs=from_procs, ref2=ref)

    def calf1(self, value=None, isHz=False, update=True, from_procs=True, *, ref=None):
        """
        Calibrates the ppm and frequency scale of the indirect dimension according to a given value, or interactively.
        Calls :func:`self.cal` on F1 only.

        Parameters
        ----------
        value : float or None
            scale shift value
        isHz : bool
            True if ``offset`` is in frequency units, False if ``offset`` is in ppm
        update : bool
            Choose if to update the ``procs`` dictionary or not
        from_procs : bool
            Read the parameters from the ``procs`` dictionary
        ref : list of 1darray
            Reference ppm scale and spectrum to be used as reference for calibration ``[ppm, spectrum]``

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.Spectra.Spectrum_2D.cal`
        """
        offset = [value, 0]
        self.cal(offset, isHz, update=update, from_procs=from_procs, ref1=ref)

    def write_acqus(self, other_dir=None):
        """
        Write the ``acqus`` dictionary in a file named "filename.acqus".
        Calls :func:`klassez.misc.write_acqus_1D`

        Parameters
        ----------
        other_dir: str or None
            Different location for the ``acqus`` dictionary to write into. If None, ``self.datadir`` is used instead.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.misc.write_acqus_1D`
        """
        if other_dir:
            path = Path(other_dir) / f'{self.filename}'
        else:
            path = self.datadir / f'{self.filename}'

        misc.write_acqus_2D(self.acqus, path=path.with_suffix('.acqus'))

    def write_procs(self, other_dir=None):
        """
        Writes the actual ``procs`` dictionary in a file named ``"filename.procs"`` in the same directory of the input file.

        Parameters
        ----------
        other_dir: str or None
            Different location for the ``procs`` dictionary to write into. If None, ``self.datadir`` is used instead.

            .. warning::

                Do not put the trailing slash!
        """
        if other_dir:
            path = Path(other_dir) / f'{self.filename}'
        else:
            path = self.datadir / f'{self.filename}'
        path.with_suffix('.procs').write_text(f'{self.procs}')

    def read_procs(self, other_dir=None):
        """
        Reads the procs dictionary from a file named `"filename.procs"` in the same directory of the input file.

        Parameters
        ----------
        other_dir : str or None
            Different location for the ``procs`` dictionary to look into. If None, ``self.datadir`` is used instead.

            .. warning::

                Do not put the trailing slash!

        Returns
        -------
        procs : dict
            Dictionary of processing parameters
        """
        if other_dir:
            path = Path(other_dir) / f'{self.filename}.procs'
        else:
            path = self.datadir / f'{self.filename}.procs'
        procs = eval(path.read_text().replace('array', 'np.array'))
        # Check if it was read correctly
        if isinstance(procs, dict):
            return procs
        else:
            raise ValueError(f'{path} cannot be interpreted as a dictionary')

    @staticmethod
    def write_ser(ser, acqus, path=None, filename=None):
        """
        Writes a real/complex array in binary format.
        Calls :func:`klassez.misc.write_ser`. Be sure that ``acqus`` contains the BYTORDA and DTYPA keys.


        Parameters
        ----------
        ser : ndarray
            Array that you want to convert in binary format.
        acqus : dict
            Dictionary of acquisition parameters. It must contain BYTORDA and DTYPA.
        path : str
            Path where to save the binary file.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.misc.write_ser`
        """
        if path is None:
            raise NameError('You must specify a filename!')
        misc.write_ser(ser, path, acqus['BYTORDA'], acqus['DTYPA'], filename=filename)

    def write_pdata(self, path=None, procno=666):
        """
        Writes a real/complex array in binary format in ``<path>/pdata/<procno>``.
        Calls misc.write_ser. Be sure that acqus contains the BYTORDA and DTYPA keys.
        Calls :func:`klassez.misc.write_ser`. Be sure that ``acqus`` contains the BYTORDA and DTYPA keys.

        Parameters
        ----------
        path: str
            Directory where to save the files. If None, ``self.datadir`` is used.
        procno: int or str
            Folder

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.misc.write_ser`
        """
        if path is None:
            path = self.datadir
        else:
            path = Path(path)

        proc_dir = path / 'pdata' / f'{procno}'
        misc.write_ser(self.rr, proc_dir, self.acqus['BYTORDA'], self.acqus['DTYPA'], filename='2rr')
        misc.write_ser(self.ir, proc_dir, self.acqus['BYTORDA'], self.acqus['DTYPA'], filename='2ir')
        misc.write_ser(self.ri, proc_dir, self.acqus['BYTORDA'], self.acqus['DTYPA'], filename='2ri')
        misc.write_ser(self.ii, proc_dir, self.acqus['BYTORDA'], self.acqus['DTYPA'], filename='2ii')

    def projf1(self, a, b=None):
        """
        Calculates the sum trace of the indirect dimension, from ``a`` ppm to ``b`` ppm in F2.
        Store the trace in the dictionary ``self.trf1`` and as :class:`pSpectrum_1D` in ``self.Trf1``.
        The key is ``'a'`` or ``'a:b'`` with two decimal figures.

        Calls :func:`klassez.anal.get_trace` on ``self.rr`` with ``column=True``

        Parameters
        ----------
        a : float
            ppm F2 value where to extract the trace.
        b : float or None.
            If it is None, extract the trace in ``a``. Else, sum from ``a`` to ``b`` in F2.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.anal.get_trace`
        """
        # make dictionary label
        if b is None:
            label = f'{a:.2f}'
        else:
            label = f'{a:.2f}:{b:.2f}'
        # Compute the trace
        f1 = anal.get_trace(self.rr, self.ppm_f2, self.ppm_f1, a, b, column=True)
        # Store it
        self.trf1[label] = f1
        self.Trf1[label] = pSpectrum_1D(f1, acqus=misc.split_acqus_2D(self.acqus)[0], procs=misc.split_procs_2D(self.procs)[0], istrace=True, filename=f'T1_{label}')

    def projf2(self, a, b=None):
        """
        Calculates the sum trace of the direct dimension, from ``a`` ppm to ``b`` ppm in F1.
        Store the trace in the dictionary ``self.trf2`` and as :class:`pSpectrum_1D` in ``self.Trf2``.
        The key is ``'a'`` or ``'a:b'`` with two decimal figures.

        Calls :func:`klassez.anal.get_trace` on ``self.rr`` with ``column=False``

        Parameters
        ----------
        a : float
            ppm F1 value where to extract the trace.
        b : float or None.
            If it is None, extract the trace in ``a``. Else, sum from ``a`` to ``b`` in F1.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.anal.get_trace`
        """
        # make dictionary label
        if b is None:
            label = f'{a:.2f}'
        else:
            label = f'{a:.2f}:{b:.2f}'
        # Compute the trace
        f2 = anal.get_trace(self.rr, self.ppm_f2, self.ppm_f1, a, b, column=False)
        # Store it
        self.trf2[label] = f2
        self.Trf2[label] = pSpectrum_1D(f2, acqus=misc.split_acqus_2D(self.acqus)[1], procs=misc.split_procs_2D(self.procs)[1], istrace=True, filename=f'T2_{label}')

    def integrate(self, **kwargs):
        """
        Integrates the spectrum with a dedicated GUI.
        Stores the results in ``self.integral``.
        Calls :func:`klassez.anal.integrate_2D`

        Parameters
        ----------
        kwargs: keyworded arguments
            Additional parameters for :func:`klassez.anal.integrate_2D`

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.anal.integrate_2D`
        """
        self.integrals = anal.integrate_2D(self.ppm_f1, self.ppm_f2, self.rr, self.acqus['SFO1'], self.acqus['SFO2'], **kwargs)

    def write_integrals(self, other_dir=None):
        """
        Write the integrals in a file named ``"{self.filename}.int"``.

        Parameters
        ----------
        other_dir : str or None
            Different location for the integrals file to write into. If None, ``self.datadir`` is used instead.

        """
        # Detect the file position
        if other_dir:
            path = Path(other_dir) / f'{self.filename}'
        else:
            path = self.datadir / f'{self.filename}'

        with path.with_suffix('.int').open('w') as f:
            f.write('{:12}\t{:12}\t\t{:20}\n'.format('ppm F2', 'ppm F1', 'Value'))
            f.write('-'*60+'\n')
            for key, value in self.integrals.items():
                ppm2, ppm1 = tuple(key.split(':'))
                f.write('{:12}\t{:12}\t\t{:20.5e}\n'.format(ppm2, ppm1, value))

    def plot(self, fqscale=False, Neg=True, lvl0=0.2, name=None):
        """
        Plots the real part of the spectrum (``self.rr``). Use the mouse scroll to adjust the contour starting level.

        Parameters
        ----------
        fqscale : bool
            Display using frequency scale instead of ppm
        Neg : bool
            Plot (True) or not (False) the negative contours.
        lvl0 : float
            Starting contour value with respect to the maximum of the spectrum
        name : str
            Name of the figure. If ``None``, ``self.filename`` is used.
        """
        if name is None:
            name = self.filename
        if fqscale:
            x_f1 = self.freq_f1
            x_f2 = self.freq_f2
            X_label = r'$\nu\ $'+misc.nuc_format(self.acqus['nuc2'])+' /Hz'
            Y_label = r'$\nu\ $'+misc.nuc_format(self.acqus['nuc1'])+' /Hz'
        else:
            x_f1 = self.ppm_f1
            x_f2 = self.ppm_f2
            X_label = r'$\delta\ $'+misc.nuc_format(self.acqus['nuc2'])+' /ppm'
            Y_label = r'$\delta\ $'+misc.nuc_format(self.acqus['nuc1'])+' /ppm'

        figures.plot_2D(x_f1, x_f2, self.rr, self.acqus['SFO1'], self.acqus['SFO2'],
                        name=name, Neg=Neg, lvl0=lvl0, X_label=X_label, Y_label=Y_label)

    def to_wav(self, filename=None, cutoff=None, rate=44100):
        """
        Converts the FID in an audio file by using :func:`klassez.misc.data2wav`.

        Parameters
        ----------
        filename : str
            Path where to save the file. If None, self.filename is used
        cutoff : float
            Clipping limits for the FID
        rate : int
            Sampling rate in samples/sec

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.misc.data2wav`
        """
        if filename is None:
            filename = Path.cwd() / self.filename
        else:
            filename = Path(filename)
        # Make a shallow copy of the FID
        data = deepcopy(self.fid)
        misc.data2wav(data, filename, cutoff, rate)


class pSpectrum_2D(Spectrum_2D):
    """
    Subclass of :class:`klassez.Spectra.Spectrum_2D` that allows to handle processed 2D NMR spectra.
    Only supports Bruker format.


    Attributes
    ----------
    datadir : str
        Path to the input file/dataset directory
    filename : str
        Base of the name of the file, without extensions
    acqus : dict
        Dictionary of acqusition parameters
    ngdic : dict
        Generated by :func:`nmrglue.bruker.read`, contains all the information on the spectrometer and on the spectrum.
    procs : dict
        Dictionary of processing parameters
    S : 2darray
        Complex (or hypercomplex, depending on ``FnMODE``) spectrum
    rr : 2darray
        Real part F2, real part F1
    ii : 2darray
        Imaginary part F2, imaginary part F1
    ir : 2darray
        Real part F2, imaginary part F1. Only exist if F1 is acquired in phase-sensitive mode
    ri : 2darray
        Imaginary part F2, real part F1. Only exist if F1 is acquired in phase-sensitive mode
    freq_f1 : 1darray
        Frequency scale of the indirect dimension, in Hz
    freq_f2 : 1darray
        Frequency scale of the direct dimension, in Hz
    ppm_f1 : 1darray
        ppm scale of the indirect dimension
    ppm_f2 : 1darray
        ppm scale of the direct dimension
    trf1 : dict
        Projections of the indirect dimension, as ``1darrays``. Keys: 'ppm_f2' where they were taken
    trf2 : dict
        Projections of the direct dimension, as ``1darrays``. Keys: 'ppm_f1' where they were taken
    Trf1 : dict
        Projections of the indirect dimension, as ``pSpectrum_1D`` objects. Keys: 'ppm_f2' where they were taken
    Trf2 : dict
        Projections of the direct dimension, as ``pSpectrum_1D`` objects. Keys: 'ppm_f1' where they were taken
    integrals : dict
        Dictionary where to save the regions and values of the integrals.
    """

    def __init__(self, in_file):
        """
        Initialize the class.

        Parameters
        ----------
        in_file : str
            Path to the spectrum. Here, the `'pdata/#'` folder must be specified.
        """
        self.datadir, self.filename = misc.get_datadir_filename(in_file)

        # Read the dictionary
        with warnings.catch_warnings():   # Suppress errors due to CONVDTA in TopSpin
            warnings.simplefilter("ignore")
            dic, _ = ng.bruker.read(self.datadir.parent.parent, cplex=True)
            # Read the files
            _, self.rr = ng.bruker.read_pdata(self.datadir, bin_files=['2rr'])
            _, self.ii = ng.bruker.read_pdata(self.datadir, bin_files=['2ii'])
            # Check for existence of hypercomplex data parts

            if (self.datadir / '2ir').is_file() and (self.datadir / '2ri').is_file():
                _, self.ir = ng.bruker.read_pdata(self.datadir, bin_files=['2ir'])
                _, self.ri = ng.bruker.read_pdata(self.datadir, bin_files=['2ri'])
                self.S = processing.repack_2D(self.rr, self.ir, self.ri, self.ii)
            else:
                self.ir = self.rr.copy()
                self.ri = self.ii.copy()
                self.S = self.rr + 1j * self.ii

            # Make the acqus dir
            self.acqus = misc.makeacqus_2D(dic)
            self.acqus['BYTORDA'] = dic['acqus']['BYTORDA']
            self.acqus['DTYPA'] = dic['acqus']['DTYPA']
            self.ngdic = dic
            del dic

        # Look for group delay points
        try:
            self.acqus['GRPDLY'] = int(self.ngdic['acqus']['GRPDLY'])
        except Exception:
            self.acqus['GRPDLY'] = 0

        # initialize the procs dictionary with default values
        if (self.datadir / self.filename).with_suffix('.procs').exists():
            self.procs = self.read_procs()
        else:
            self.procs = self._default_procs(self.acqus)
            self.write_procs()

        # Calculates the frequency and ppm scales
        self.freq_f1 = processing.make_scale(self.rr.shape[0], dw=self.acqus['dw1'])
        if self.acqus['SFO1'] < 0:
            self.freq_f1 = self.freq_f1[::-1]
        self.ppm_f1 = misc.freq2ppm(self.freq_f1, B0=self.acqus['SFO1'], o1p=self.acqus['o1p'])

        self.freq_f2 = processing.make_scale(self.rr.shape[1], dw=self.acqus['dw2'])
        if self.acqus['SFO2'] < 0:
            self.freq_f2 = self.freq_f2[::-1]
        self.ppm_f2 = misc.freq2ppm(self.freq_f2, B0=self.acqus['SFO2'], o1p=self.acqus['o2p'])

        # Create empty dictionary where to save the projections
        self.trf1 = {}
        self.trf2 = {}
        self.Trf1 = {}
        self.Trf2 = {}


class Pseudo_2D(Spectrum_2D):
    """
    Subclass of :class:`klassez.Spectra.Spectrum_2D` to simulate and handle pseudo-2D experiments.
    Basically, they share more or less the same attributes, but some methods were adapted in order to suit well with a not-Fourier-transformed indirect dimension.

    Attributes
    ----------
    datadir: str
        Path to the input file/dataset directory
    filename: str
        Base of the name of the file, without extensions
    fid: 2darray
        FID. For simulated data, this must be explicitely set!
    acqus: dict
        Dictionary of acqusition parameters
    ngdic: dict
        Created only if it is an experimental spectrum. Generated by :func:`nmrglue.bruker.read`, contains all the information on the spectrometer and on the spectrum.
    procs: dict
        Dictionary of processing parameters
    S: 2darray
        Complex spectrum
    rr: 2darray
        Real part F2, real part F1
    ii: 2darray
        Imaginary part F2, imaginary part F1
    freq_f1: 1darray
        Indeces of the experiments, works as placeholder
    freq_f2: 1darray
        Frequency scale of the direct dimension, in Hz
    ppm_f1: 1darray
        Indeces of the experiments, works as placeholder
    ppm_f2: 1darray
        ppm scale of the direct dimension
    trf1: dict
        Projections of the indirect dimension, as ``1darrays`` . Keys: ``'ppm_f2'`` where they were taken
    trf2: dict
        Projections of the direct dimension, as ``1darrays`` . Keys: ``'ppm_f1'`` where they were taken
    Trf1: dict
        Projections of the indirect dimension, as :class:`pSpectrum_1D` objects. Keys: 'ppm_f2' where they were taken
    Trf2: dict
        Projections of the direct dimension, as :class:`pSpectrum_1D` objects. Keys: 'ppm_f1' where they were taken
    integrals: dict
        Dictionary where to save the regions and values of the integrals.
    F: fit.Voigt_Fit_P2D object
        Interface for lineshape deconvolution.

    """

    def __str__(self):
        doc = '-'*64
        doc += '\nPseudo_2D object.\n'
        if 'ngdic' in self.__dict__.keys():
            doc += f'Read from "{self.datadir}"\n'
        else:
            doc += f'Simulated from "{self.datadir}"\n'
        doc += f'It is a {self.acqus["nuc"]} spectrum recorded over a\nsweep width of {self.acqus["SWp"]} ppm, centered at {self.acqus["o1p"]} ppm.\n'
        if self.fid is None:
            doc += 'The FID is not present yet.'
        else:
            N = self.fid.shape
            doc += f'The FID consists of {N[0]} experiments, each one is {N[1]} points long.\n'
        doc += '-'*64
        return doc

    @property
    def S(self) -> np.ndarray:
        """ Normal getter for ``self.S`` attribute """
        return self._S

    @S.setter
    def S(self, data: np.ndarray):
        """ Every time ``self.S`` is instanced, ``self.r`` and ``self.i`` are updated automatically """
        self._S = data
        self.rr = self._S.real
        self.ii = self._S.imag
        if hasattr(self, 'F'):
            self.F.S = self._S

    def __init__(self, in_file, pv=False, isexp=True):
        """
        Initialize the class.

        Parameters
        ----------
        in_file : str
            path to file to read, or to the folder of the spectrum
        pv : bool
            True if you want to use pseudo-voigt lineshapes for simulation, False for Voigt
        isexp : bool
            True if this is an experimental dataset, False if it is simulated
        """
        # Set up the filenames
        self.datadir, self.filename = misc.get_datadir_filename(in_file, isexp)

        if isexp is False:      # It is simulated experiment
            if isinstance(in_file, dict):       # acqus dictionary provided
                self.acqus = deepcopy(in_file)  # Just read it
            else:   # acqus is in the file: read it
                self.acqus = sim.load_sim_1D(in_file)
            self.acqus['GRPDLY'] = 0
            self.fid = None     # FID must be loaded with mount
        else:       # Experimental data
            # Use the 1D version because it changes the shape of acqus
            self.fid, self.acqus, self.ngdic = misc.read_fid_acqus_1D(in_file)

        # Reshape the FID
        if isinstance(self.fid, np.ndarray):
            try:
                self.acqus['TD1'] = self.ngdic['acqu2s']['TD']
                self.fid = np.reshape(self.fid, (self.acqus['TD1'], -1))
            except Exception:
                self.acqus['TD1'] = 0
            self.acqus['TD1'] = self.fid.shape[0]
        else:
            self.acqus['TD1'] = 0

        # Try to find the group delay
        try:
            self.acqus['GRPDLY'] = int(self.ngdic['acqus']['GRPDLY'])
        except Exception:
            self.acqus['GRPDLY'] = 0

        # Initalize the procs dictionary
        if (self.datadir / self.filename).with_suffix('.procs').exists():
            self.procs = self.read_procs()
        else:
            self.procs = self._default_procs(self.acqus)
            self.write_procs()

    @staticmethod
    def _default_procs(acqus):
        procs = {}
        proc_init_1D = (deepcopy(wf0), None, 0.5, 0)
        for k, key in enumerate(proc_keys_1D):
            procs[key] = proc_init_1D[k]         # Processing parameters
        procs['wf']['sw'] = round(acqus['SW'], 4)
        #   Then, phases
        procs['p0'] = 0
        procs['p1'] = 0
        procs['pv'] = round(acqus['o1p'], 2)
        #   Then, baseline
        procs['basl_c'] = None
        procs['cal'] = 0
        procs['roll_ppm'] = None
        return procs

    def convdta(self, scaling=1):
        """ Calls processing.convdta """
        self.fid = processing.convdta(self.fid, self.acqus['GRPDLY'], scaling)

    def add_noise(self, s_n=1):
        """
        Adds noise to the FID, using the function :func:`klassez.sim.noisegen`.

        Parameters
        ----------
        s_n : float
            Standard deviation of the noise

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.sim.noisegen`
        """
        self.fid += sim.noisegen(self.fid.shape, self.acqus['o1'], self.acqus['t1'], s_n=s_n)

    def scan(self, ns=1, s_n=1):
        """
        Simulates the acquisition of ``ns`` scans, by adding a different realization of noise at each iteration.
        The function is supposed to start with the FID without noise at all. If not, the results will be biased.
        For reproducible results, fix the seed!

        Parameters
        ----------
        ns : int
            Number of scans to accumulate
        s_n : float
            Standard deviation of the noise for each scan

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.sim.noisegen`
        """
        clean_fid = np.copy(self.fid)   # Save a shallow copy of the FID without noise
        for k in range(ns):
            # Each scan is: clean FID + noise
            self.fid += clean_fid + sim.noisegen(self.fid.shape, self.acqus['o1'], self.acqus['t1'], s_n=s_n)

    def process(self):
        """
        Process only the direct dimension.
        Calls :func:`klassez.processing.fp` on each transient.
        The parameters are read from the ``procs`` dictionary.

        .. seealso::

            :func:`klassez.processing.fp`
        """
        # Make empty self.S whose dimensions are given by the zero-filling
        if self.procs['zf'] is None:
            self.S = np.zeros(self.fid.shape).astype(self.fid.dtype)
        else:
            self.S = np.zeros((self.fid.shape[0], self.procs['zf'])).astype(self.fid.dtype)

        # Do the processing
        for k in range(self.fid.shape[0]):
            self.S[k] = processing.fp(self.fid[k], wf=self.procs['wf'], zf=self.procs['zf'], fcor=self.procs['fcor'], tdeff=self.procs['tdeff'])

        # Make the frequency and ppm scales
        self.freq_f2 = processing.make_scale(self.S.shape[1], dw=self.acqus['dw'])
        if self.acqus['SFO1'] < 0:      # Correct for negative gamma nuclei
            self.freq_f2 = self.freq_f2[::-1]
        self.ppm_f2 = misc.freq2ppm(self.freq_f2, B0=self.acqus['SFO1'], o1p=self.acqus['o1p'])

        if self.acqus['SFO1'] < 0:      # Correct for negative gamma nuclei
            self.S = self.S[:, ::-1]

        self.F = fit.Voigt_Fit_P2D(self.ppm_f2, self.S, self.acqus['t1'], self.acqus['SFO1'], self.acqus['o1p'], self.acqus['nuc'], self.filename)

        # Adjust the phase
        self.adjph(p0=self.procs['p0'], p1=self.procs['p1'], pv=self.procs['pv'], update=False)

        # Use number of the experiment as fake scale in F1
        self.freq_f1 = np.arange(self.S.shape[0])           # python numbering
        self.ppm_f1 = np.arange(self.S.shape[0]) + 1        # human numbering

        # Align the spectrum
        if self.procs['roll_ppm'] is not None:
            for k, experiment in enumerate(self.S):
                roll_pt = int(self.procs['roll_ppm'][k] / misc.calcres(self.ppm_f2))    # Compute the circular shift in points
                self.S[k] = np.roll(experiment, roll_pt)                # Apply it to each experiment

        self.integrals = {}

        # Create empty dictionary where to save the projections
        self.trf1 = {}
        self.trf2 = {}
        self.Trf1 = {}
        self.Trf2 = {}

    def mount(self, fids=[], filename=None, newacqus=None):
        """
        Replaces the FID of the experiment with a custom one, made by stacking 1D experiments.
        If the default filename exists (i.e. '<self.filename>.npy'), the function loads it, otherwise calls :func:`klassez.processing.stack_fids` to create it.
        The ``fid`` attribute is overwritten. The key 'TD1' of the ``acqus`` dictionary is updated to match the first dimension of the new FID.

        Parameters
        ----------
        fids : sequence of 1darray or Spectrum_1D objects
            FIDs to be stacked. It can be empty if the .npy file already exists.
        filename : str or None
            Path to the filename, without the .npy extension. If it is None, the default filename is used.
        newacqus : dict
            New acqus dictionary that replaces the actual one. If it is not a dictionary, no actions are performed.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.stack_fids`
        """
        # Adjust the filename
        if filename is None:    # Default
            filename = self.datadir / f'{self.filename}.npy'
        else:                   # Add the .npy extension
            filename = Path(f'{filename}').with_suffix('.npy')

        # Check if the .npy file already exists
        if not len(fids):
            if filename.exists():    # then load it
                self.fid = np.load(filename)
            else:
                raise ValueError('You passed no FIDs!')
        else:   # make it
            self.fid = processing.stack_fids(*fids, filename=filename)

        # Replace acqus only if newacqus is a dictionary
        if isinstance(newacqus, dict):
            self.acqus = deepcopy(newacqus)

        # Update the TD1 key
        self.acqus['TD1'] = self.fid.shape[0]

    def cal(self, offset=None, isHz=False, update=True, from_procs=True, *, ref=None):
        """
        Calibration of the ppm and frequency scales according to a given value, or interactively. In this latter case, a reference peak must be chosen.


        Parameters
        ----------
        offset : float
            scale shift F2
        isHz : tuple of bool
            True if offset is in frequency units, False if offset is in ppm
        update : bool
            Choose if to update the procs dictionary or not
        from_procs : bool
            Read the parameters from the procs dictionary
        ref : list of 1darray
            Reference ppm scale and spectrum to be used as reference for calibration [ppm, spectrum]

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.calibrate`
        """
        def _calibrate(ppm, trace, SFO1, ref=None):
            """ Main function that calls the real calibration """
            offppm = processing.calibration(ppm, trace, ref=ref)
            offhz = misc.ppm2freq(offppm, SFO1)
            return offppm, offhz

        if offset is not None:
            from_procs = False

        if from_procs and 'cal' in self.procs.keys():
            offp2 = self.procs['cal']
            offh2 = misc.ppm2freq(offp2, self.acqus['SFO1'])

            # Apply the calibration
            self.freq_f2 += offh2
            self.ppm_f2 += offp2
            # Move the offsets to the center of the SW
            self.acqus['o1p'] += offp2
            self.acqus['o1'] += offh2
            print(f'Applied calibration of {offp2:-.3f} ppm ({offh2:-.2f} Hz) as read from the procs dictionary.', c='violet')

        else:
            # Get the missing entries
            if offset is None:   # Select the reference traces
                coord = anal.select_traces(self.ppm_f1, self.ppm_f2, self.rr, Neg=True)
                _, iy = coord[0][0], coord[0][1]   # Position of the first crosshair
                # F2 reference spectrum
                X = anal.get_trace(self.rr, self.ppm_f2, self.ppm_f1, iy, column=False)
                # F1 reference spectrum

                ppm_f2 = np.copy(self.ppm_f2)
                offp2, offh2 = _calibrate(ppm_f2, X, self.acqus['SFO1'], ref=ref)
            else:   # Calculate offh2 from offp2 or viceversa
                if isHz:    # offp2 is missing
                    offh2 = offset
                    offp2 = misc.freq2ppm(offh2, self.acqus['SFO1'])
                else:       # offh2 is missing
                    offp2 = offset
                    offh2 = misc.ppm2freq(offp2, self.acqus['SFO1'])

            # Apply the calibration
            self.freq_f2 += offh2
            self.ppm_f2 += offp2
            # Move the offsets to the center of the SW
            self.acqus['o1p'] += offp2
            self.acqus['o1'] += offh2
            # Update the procs dictionary
            if update:
                self.procs['cal'] += offp2
            # Update the .procs file
            self.write_procs()

        # Update F
        self.F.ppm_scale = self.ppm_f2
        self.F.o1p = self.acqus['o1p']

    def adjph(self, expno=0, p0=None, p1=None, pv=None, update=True):
        """
        Adjusts the phases of the spectrum according to the given parameters, or interactively if they are left as default.

        Parameters
        ----------
        expno: int
            Index of the experiment (python numbering) to use in the interactive panel
        p0: float or None
            0-th order phase correction /°
        p1: float or None
            1-st order phase correction /°
        pv: float or None
            1-st order pivot /ppm
        update: bool
            Choose if to upload the procs dictionary or not

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.ps`

            :func:`klassez.processing.interactive_phase_1D`
        """
        # Get the reference spectrum
        S = self.S[expno]
        # Adjust the phases
        _, values = processing.ps(S, self.ppm_f2, p0=p0, p1=p1, pivot=pv)
        self.S, _ = processing.ps(self.S, self.ppm_f2, *values)

        # Update the procs dictionary
        if update:
            self.procs['p0'] += round(values[0], 2)
            self.procs['p1'] += round(values[1], 2)
            if values[2] is not None:
                self.procs['pv'] = round(values[2], 5)
            # Update the .procs file
            self.write_procs()

        # Update the F attribute
        if hasattr(self, 'F'):
            self.F.ppm_scale = self.ppm_f2

        return values

    def pknl(self):
        """
        Reverses the effect of the digital filter by applying a first order phase correction.
        To be called after having processed the data by :func:`self.process()`.
        """
        if hasattr(self, 'S'):
            self.S = processing.pknl(self.S, grpdly=self.acqus['GRPDLY'], onfid=False)
        else:
            self.fid = processing.pknl(self.fid, grpdly=self.acqus['GRPDLY'], onfid=True)

    def projf1(self, a, b=None):
        """
        Calculates the sum trace of the indirect dimension, from ``a`` ppm to ``b`` ppm in F2.
        Store the trace in the dictionary ``self.trf1`` and as :class:`pSpectrum_1D` in ``self.Trf1``.
        The key is ``'a'`` or ``'a:b'`` with two decimal figures.

        Calls :func:`klassez.anal.get_trace` on ``self.rr`` with ``column=True``

        Parameters
        ----------
        a : float
            ppm F2 value where to extract the trace.
        b : float or None.
            If it is None, extract the trace in ``a``. Else, sum from ``a`` to ``b`` in F2.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.anal.get_trace`
        """
        # make dictionary label
        if b is None:
            label = f'{a:.2f}'
        else:
            label = f'{a:.2f}:{b:.2f}'
        # Compute the trace
        f1 = anal.get_trace(self.rr, self.ppm_f2, self.ppm_f1, a, b, column=True)
        # Store it
        self.trf1[label] = f1
        self.Trf1[label] = pSpectrum_1D(f1, acqus=self.acqus, procs=self.procs, istrace=True)
        # Overwrite f2 scale with f1 scale as this is f1 projection
        self.Trf1[label].freq = np.copy(self.freq_f1)
        self.Trf1[label].ppm = np.copy(self.ppm_f1)

    def projf2(self, a, b=None):
        """
        Calculates the sum trace of the direct dimension, from ``a`` ppm to ``b`` ppm in F1.
        Store the trace in the dictionary ``self.trf2`` and as :class:`pSpectrum_1D` in ``self.Trf2``.
        The key is ``'a'`` or ``'a:b'`` with two decimal figures.

        Calls :func:`klassez.anal.get_trace` on ``self.rr`` with ``column=False``

        Parameters
        ----------
        a : float
            ppm F1 value where to extract the trace.
        b : float or None.
            If it is None, extract the trace in ``a``. Else, sum from ``a`` to ``b`` in F1.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.anal.get_trace`
        """
        # make dictionary label
        if b is None:
            label = f'{a:.2f}'
        else:
            label = f'{a:.2f}:{b:.2f}'
        # Compute the trace
        f2 = anal.get_trace(self.rr, self.ppm_f2, self.ppm_f1, a, b, column=False)
        # Store it
        self.trf2[label] = f2
        self.Trf2[label] = pSpectrum_1D(f2, acqus=self.acqus, procs=self.procs, istrace=True)

    def plot(self, fqscale=False, Neg=True, lvl0=0.2, Y_label='', name=None):
        """
        Plots the real part of the spectrum (``self.rr``). Use the mouse scroll to adjust the contour starting level.

        Parameters
        ----------
        fqscale : bool
            Display using frequency scale instead of ppm
        Neg : bool
            Plot (True) or not (False) the negative contours.
        lvl0 : float
            Starting contour value with respect to the maximum of the spectrum
        Y_label : str
            Custom label for the y axis
        name : str
            Name of the figure. If ``None``, ``self.filename`` is used.
        """
        if name is None:
            name = self.filename
        if fqscale:
            x_f1 = self.freq_f1
            x_f2 = self.freq_f2
            X_label = r'$\nu\ $'+misc.nuc_format(self.acqus['nuc'])+' /Hz'
        else:
            x_f1 = self.ppm_f1
            x_f2 = self.ppm_f2
            X_label = r'$\delta\ $'+misc.nuc_format(self.acqus['nuc'])+' /ppm'

        figures.plot_2D(x_f1, x_f2, self.rr, 1, self.acqus['SFO1'],
                        name=name, Neg=Neg, lvl0=lvl0, X_label=X_label, Y_label=Y_label)

    def plot_md(self, which=None, lims=None):
        """
        Plot a number of experiments, superimposed.

        Parameters
        ----------
        which : str or None
            List of experiment indexes, so that ``eval(which)`` is meaningful. None plots all of them
        lims : tuple
            Region of the spectrum to show (ppm1, ppm2)
        """
        # Select which spectra to plot
        if which is None:   # All of them
            which_exp = np.arange(self.rr.shape[0])
        else:   # Just the ones in which
            which_exp = eval(which)
        # Make shallow copy of ppm scale
        ppm = np.copy(self.ppm_f2)
        # Organize the spectra to be plot
        S = [np.copy(self.rr[w]) for w in which_exp]

        # Cut the data according to lims
        if lims is not None:
            for k, s in enumerate(S):
                _, S[k] = misc.trim_data(ppm, s, *lims)
            ppm, _ = misc.trim_data(ppm, s, *lims)

        # Make the figure
        figures.dotmd(ppm, S, labels=[f'{self.ppm_f1[w]:.3g}' for w in which_exp])

    def plot_stacked(self, which=None, lims=None):
        """
        Plot a number of experiments, stacked.

        Parameters
        ----------
        which : str or None
            List of experiment indexes, so that ``eval(which)`` is meaningful. None plots all of them.
        lims : tuple
            Region of the spectrum to show (ppm1, ppm2)
        """
        # Select which spectra to plot
        if which is None:   # All of them
            which_exp = np.arange(self.rr.shape[0])
        else:   # Just the ones in which
            which_exp = eval(which)
        # Make shallow copy of ppm scale
        ppm = np.copy(self.ppm_f2)
        # Organize the spectra to be plot
        S = [np.copy(self.rr[w]) for w in which_exp]

        # Cut the data according to lims
        if lims is not None:
            for k, s in enumerate(S):
                _, S[k] = misc.trim_data(ppm, s, *lims)
            ppm, _ = misc.trim_data(ppm, s, *lims)

        X_label = r'$\delta\ $'+misc.nuc_format(self.acqus['nuc'])+' /ppm'

        # Make the figure
        figures.stacked_plot(
                ppm, S,
                X_label=X_label, Y_label='Normalized intensity /a.u.',
                labels=[f'{w}' for w in which_exp])

    def strip(self, xlim=None):
        """
        Perform the strip transform in F2 with ``xlim``.
        The frequency and ppm scales are updated as well.
        The pivot for phase correction is moved to the center of the new scale.
        The imaginary parts are reconstructed via Hilbert transform.

        Parameters
        ----------
        xlim : tuple or None
            Limits in F2 in ppm

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.misc.trim_data_2D`

            :func:`klassez.processing.hilbert`

            :func:`klassez.processing.hilbert2`
        """
        # Cut the spectrum on the rr part
        self.ppm_f2, self.ppm_f1, self.rr = misc.trim_data_2D(self.ppm_f2, self.ppm_f1, self.rr, xlim, ylim=None)

        # Reconstruct the imaginary components
        self.S = processing.hilbert(self.rr)

        # Cut the frequency scales as well
        self.freq_f2 = misc.ppm2freq(self.ppm_f2, self.acqus['SFO1'], self.acqus['o1p'])

        # Update the pivot points to fix them in the center of the stripped part
        self.procs['pv'] = round(np.mean(self.ppm_f2), 5)

    def integrate(self, ref=0, filename=None, lims=None, use_bas=False):
        """
        Integrate the spectrum with a dedicated GUI.
        Calls :func:`klassez.anal.integrate_p2D` and writes in ``self.integrals`` with keys ``[ppm1:ppm2]``
        The values are saved in ``<filename>.igrl``.

        Parameters
        ----------
        ref : int
            Index of the transient to be used as reference for the integration.
        filename : str
            The integral values will be saved in ``<filename>.igrl``.
            If ``None``, ``<self.filename>`` will be used instead.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.anal.integrate_p2D`

            :func:`klassez.anal.write_igrl`
        """
        if filename is None:
            filename = self.filename

        if lims is None:
            X_label = r'$\delta\,$' + misc.nuc_format(self.acqus['nuc']) + ' /ppm'
            self.integrals = anal.integrate_p2D(self.ppm_f2, self.rr, self.acqus['SFO1'],
                                                ref=ref, indirect_scale=self.ppm_f1, filename=filename,
                                                X_label=X_label, dx=2*self.acqus['dw'])
        else:
            if len(np.asarray(lims).shape) == 1:
                lims = [lims]
            keys = misc.limits_to_key(lims)
            if isinstance(keys, str):
                keys = [keys]

            self.integrals = {
                    key: 2 * self.acqus['dw'] * processing.integrate(self.rr, x=self.ppm_f2,
                                                                     dx=misc.calcres(self.freq_f2),
                                                                     lims=lim, use_bas=use_bas)
                    for key, lim in zip(keys, lims)
                    }
            anal.write_igrl(filename, self.integrals, indirect_scale=self.ppm_f1, header=True)

    def write_integrals(self, other_dir=None):
        """
        Write the integrals in a file named ``{self.filename}.igrl``.
        The default path is ``self.datadir``.

        Parameters
        ----------
        other_dir : str or None
            Different location for the integrals file to write into. If None, ``self.datadir`` is used instead.

        Returns
        -------
        None

        .. seealso ::

            :func:`klassez.anal.write_igrl`

        """
        # Detect the file position
        if other_dir:
            path = Path(other_dir) / f'{self.filename}'
        else:
            path = self.datadir / f'{self.filename}'

        anal.write_igrl(path, self.integrals, header=True)

    def read_integrals(self, filename, n=-1):
        """
        Reads the integrals in the `.igrl` file named ``filename``, and stores the result in ``self.integrals``

        Parameters
        ----------
        filename : str
            Path to the filename to be read
        n : int
            Number of performed integrating procedure 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 attempt is ``n=1``.

        Returns
        -------
        None

        .. seealso ::

            :func:`klassez.anal.read_igrl`

        """
        self.integrals, self.ppm_f1 = anal.read_igrl(filename, n)
        print(f'{filename} read.')

    def qfil(self, which=None, u=None, s=None, from_procs=True, fqscale=False):
        """
        Gaussian filter to suppress signals.
        Tries to read ``self.procs['qfil']``, which is ``{ 'u': u, 's': s }``
        Otherwise, these are set interactively by :func:`klassez.processing.interactive_qfil` and then added to ``self.procs``.

        Parameters
        ----------
        which : int or None
            Index of the F2 trace to be used for :func:`klassez.processing.interactive_qfil`. If None, a suitable trace can be selected using :func:`klassez.anal.select_traces`.
        u : float
            Position /ppm
        s : float
            Width (standard deviation) /Hz
        from_procs : bool
            Tries to read the values from ``self.procs``
        fqscale : bool
            True to use the frequency scale, False for the ppm scale.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.interactive_qfil`

            :func:`klassez.anal.select_traces`

        """
        super().qfil(which=which, u=u, s=s, from_procs=from_procs, SFO=self.acqus['SFO1'], O1P=self.acqus['o1p'])

    def align(self, lims=None, u_off=0.5, ref_idx=0):
        """
        Aligns the spectrum to a reference signal in the reference spectrum (default: first one).

        Parameters
        ----------
        lims: tuple or None
            Reference signal region, in ppm. If None, you can select it interactively.
        u_off: float
            Maximum displacement allowed, in ppm
        ref_idx: int
            Index of the spectrum to be used as a reference (python numbering)

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.fit.get_region`

            :func:`klassez.processing.align`
        """
        # Get the region of the reference peak
        if lims is None:
            lims = fit.get_region(self.ppm_f2, self.rr[ref_idx], rev=True)
        # Align
        self.S, roll_pt, roll_ppm = processing.align(self.ppm_f2, self.S, lims, u_off, ref_idx)
        # Update the procs dictionary
        if 'roll_ppm' not in self.procs.keys():
            self.procs['roll_ppm'] = np.zeros(self.S.shape[0])
        if self.procs['roll_ppm'] is None:
            self.procs['roll_ppm'] = np.zeros(self.S.shape[0])
        self.procs['roll_ppm'] += roll_ppm
        # Update the .procs file
        self.write_procs()

    def basl(self, from_procs=False, phase=True):
        """
        Apply baseline correction to the whole pseudo-2D by subtracting ``self.baseline`` from ``self.S``.
        Then, ``self.S`` is unpacked in ``self.rr`` and ``self.ii``.

        Parameters
        ----------
        from_procs : bool
            If True, computes the baseline using the polynomion model reading ``self.procs['basl_c']`` as coefficients
        phase : bool
            Choose if to apply the same phase correction of the spectrum to the baseline. This should be done if the baseline was computed before the phase adjustment!
        """
        # Get the baseline, if there is not already
        if from_procs:
            x_basl = np.linspace(-1, 1, self.S.shape[-1])
            self.baseline = misc.polyn(x_basl, self.procs['basl_c'])
        # Apply the phase correction
        if phase:
            self.baseline, *_ = processing.ps(self.baseline, self.ppm_f2, p0=self.procs['p0'], p1=self.procs['p1'], pivot=self.procs['pv'])
        # Make an array of baseline
        full_baseline = np.array([self.baseline for w in range(self.S.shape[0])])
        # Apply the baseline correction
        self.S -= full_baseline

    def rpbc(self, ref_exp=0, **rpbc_kws):
        """
        Computes the phase angles and the baseline using :func:`klassez.processing.rpbc` on ``self.S``.
        Then applies the phase correction and subtracts the baseline, automatically, to all experiments of the pseudo-2D.
        The ``procs`` dictionary is then updated and saved.
        The polynomial baseline is computed according to the given coefficients and stored in ``self.baseline``

        Parameters
        ----------
        ref_exp: int
            Index of the reference experiment on which to apply the algorithm
        rpbc_kws : keyworded arguments
            See :func:`klassez.processing.rpbc` for details.

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.processing.rpbc`
        """
        # Get the reference experiment
        experiment = np.copy(self.S[ref_exp])
        # Get phase angles and baseline coefficients with rpbc
        _, p0, p1, c = processing.rpbc(experiment, **rpbc_kws)
        # Apply phase to the whole self.S
        self.adjph(p0=p0, p1=p1)
        # Compute the baseline
        self.procs['basl_c'] = c
        self.baseline = misc.polyn(np.linspace(-1, 1, self.S.shape[-1]), c)
        # Apply it
        self.basl()

        # Update the .procs file
        self.write_procs()

    def to_wav(self, filename=None, cutoff=None, rate=44100):
        """
        Converts the FID in an audio file by using :func:`klasse.misc.data2wav`.

        Parameters
        ----------
        filename : str
            Path where to save the file. If None, ``self.filename`` is used
        cutoff : float
            Clipping limits for the FID
        rate : int
            Sampling rate in samples/sec

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.misc.data2wav`
        """
        if filename is None:
            filename = Path.cwd() / self.filename
        else:
            filename = Path(filename)
        # Make a shallow copy of the FID
        data = np.copy(self.fid)
        misc.data2wav(data, filename, cutoff, rate)

    def abs(self, n=5, lims=None, alpha=2.75, qfil=False, qfilp={'u': 4.7, 's': 10}):
        """
        Correct the baseline automatically on the real part of the spectrum, then retrieve
        the imaginary part through Hilbert transform.

        Parameters
        ----------
        n : int
            Number of coefficients of the polynomial baseline
        lims : tuple or None
            Limits for the region on which to compute the baseline, in ppm
        alpha : float
            The threshold will be set as ``thr = alpha * np.std(np.gradient(data))``
        qfil : bool
            Choose whether to apply a filter on the solvent region (True) or not (False)
        qfilp : dict
            Parameters to be used to compute the filter if ``qfil=True``. Keys:

            * 'u' = center of the filter in ppm
            * 's' = width of the filter in Hz

        Returns
        -------
        baseline : 1darray
            Employed baseline for correction

        .. seealso::

            :func:`klassez.processing.abs`

            :func:`klassez.processing.hilbert`
        """
        original = deepcopy(self.S)
        # Correct the baseline
        datap = np.empty_like(self.S)
        for k, data in enumerate(self.rr):
            datap[k] = processing.abs(self.ppm_f2, data, n=n, lims=lims, alpha=alpha, qfil=qfil, qfilp=qfilp)
        # Retrieve imaginary part
        self.S = deepcopy(datap)
        return original - datap

    def abs_v2(self, n=5, lims=None, alpha=5, winsize=2, qfil=False, qfilp={'u': 4.7, 's': 10}):
        """
        Correct the baseline automatically on the real part of the spectrum, then retrieve
        the imaginary part through Hilbert transform.

        Parameters
        ----------
        n : int
            Number of coefficients of the polynomial baseline
        lims : tuple or None
            Limits for the region on which to compute the baseline, in ppm
        alpha : float
            The threshold will be set as ``thr = alpha * np.std(np.gradient(data))``
        qfil : bool
            Choose whether to apply a filter on the solvent region (True) or not (False)
        qfilp : dict
            Parameters to be used to compute the filter if ``qfil=True``. Keys:
            * 'u' = center of the filter in ppm
            * 's' = width of the filter in Hz

        Returns
        -------
        baseline : 1darray
            Employed baseline for correction

        .. seealso::

            :func:`klassez.processing.abs_v2`

            :func:`klassez.processing.hilbert`
        """
        original = deepcopy(self.S)
        # Correct the baseline
        datap = processing.abs_v2(self.ppm, self.r, self.acqus['SFO1'], n=n, lims=lims, alpha=alpha, winsize=winsize, qfil=qfil, qfilp=qfilp)
        # Retrieve imaginary part
        self.S = processing.hilbert(datap)
        return original - datap


class DOSY(Pseudo_2D):
    """
    Class: DOSY spectrum

    It has the same structure of a :class:`klassez.Spectra.Pseudo_2D`,
    with added functionalities and dedicated fitting.

    Attributes
    ----------
    D : fit.DosyFit object
        Fitting object created when integrals are read or instanced.

    .. seealso::

        :class:`klassez.fit.DosyFit`

    """
    def __init__(self, *args, **kws):
        """
        Initialize the class as it is a normal :class:`klassez.Spectra.Pseudo_2D` object.
        Then, reads the `difflist` file and stores it in the ``self.difflist`` attribute.
        """
        super().__init__(*args, **kws)
        self.difflist = np.loadtxt(self.datadir / 'difflist')

    def process(self):
        """
        Process the data as in a normal :class:`klassez.Spectra.Pseudo_2D` object.
        Then, replaces ``self.ppm_f1`` with the ``self.difflist``.

        .. seealso::

            :func:`klassez.Spectra.Pseudo_2D.process`

        """
        super().process()
        self.ppm_f1 = self.difflist

    def integrate(self, *args, **kws):
        """
        Performs the same integration procedure of the parent class,
        which sets the ``self.integrals`` attribute.
        Then, uses it to either create or update the ``self.D`` ``DosyFit object``.

        .. seealso::

            :func:`klassez.Spectra.Pseudo_2D.integrate`

            :class:`klassez.fit.DosyFit`

        """
        super().integrate(*args, **kws)
        self._instance_D(self.integrals)

    def read_integrals(self, *args, **kws):
        """
        Reads a `.igrl` file as well as its father class, which sets the ``self.integrals`` attribute.
        Then, uses it to either create or update the ``self.D`` :class:`klassez.fit.DosyFit` object.

        .. seealso::

            :func:`klassez.Spectra.Pseudo_2D.read_integrals`

            :class:`klassez.fit.DosyFit`

        """
        super().read_integrals(*args, **kws)
        self._instance_D(self.integrals)

    def _instance_D(self, input_data, pprog=None):
        """
        If there is not a ``self.D`` attribute already existing, creates it
        with the parameters read from the spectrum itself.
        Otherwise, just updates the data with ``self.integrals``
        """
        if hasattr(self, 'D'):
            self.D.data = input_data
        else:
            if hasattr(self, 'ngdic'):
                pprog = self.ngdic['acqus']['PULPROG']
            elif pprog is None:
                pprog = 'stebp'
            self.D = fit.DosyFit(self, pprog=pprog, difflist=self.ppm_f1,
                                 input_data=input_data,
                                 filename=self.filename)

    def diffplot(self, what='result', xlims=None, filename=None, ext='png', dpi=300, dim=None):
        """
        Makes a plot of the diffusion coefficients, with error bars, as a function of the integrated regions.

        Parameters
        ----------
        what : str
            ``'iguess'`` or ``'result'``
        xlims : sequence of float or False or None
            Limits for the chemical shift axis.
            If ``False``, the whole spectrum is plotted.
            If ``None``, a restricted portion of the spectrum which includes all the
            fitted regions is shown instead
        filename : str or None
            Filename for the figure to save. If None, the plot is shown instead
        ext : str
            Format for the figure to save
        dpi : int
            Resolution of the figure in dots per inches
        dim : tuple of int
            Dimension of the figure in inches

        Returns
        -------
        None

        .. seealso::

            :func:`klassez.figures.diffplot`

        """

        # Choose what to plot
        if what == 'iguess':
            assert hasattr(self.D, 'i_guess'), '"i_guess" attribute not found in the DosyFit object'
            dic = self.D.i_guess
        elif what == 'result':
            assert hasattr(self.D, 'result'), '"result" attribute not found in the DosyFit object'
            dic = self.D.result
        else:
            raise ValueError('"what" must be either "iguess" or "result"')

        # Customize the x label
        X_label = r'$\delta\,$' + misc.nuc_format(self.acqus['nuc']) + r' /ppm'
        # Make the plot
        figures.diffplot(self.ppm_f2, self.rr, dic, xlims=xlims, X_label=X_label,
                         filename=filename, ext=ext, dpi=dpi, dim=dim)


class pDOSY(DOSY):
    """
    Plane of a :class:`klassez.Spectra.DOSY_T1`.

    Attributes
    ----------
    datadir: str
        Path to the input file/dataset directory
    filename: str
        Base of the name of the file, without extensions
    fid: 2darray
        FID.
    acqus: dict
        Dictionary of acqusition parameters
    ngdic: dict
        Created only if it is an experimental spectrum. Generated by :func:`nmrglue.bruker.read`, contains all the information on the spectrometer and on the spectrum.
    S: 2darray
        Complex spectrum
    rr: 2darray
        Real part F2, real part F1
    ii: 2darray
        Imaginary part F2, imaginary part F1
    freq_f1: 1darray
        Indeces of the experiments, works as placeholder
    freq_f2: 1darray
        Frequency scale of the direct dimension, in Hz
    ppm_f1: 1darray
        Indirect scale, as passed to the :func:`klassez.Spectra.pDOSY.__init__` method
    ppm_f2: 1darray
        ppm scale of the direct dimension
    trf1: dict
        Projections of the indirect dimension, as ``1darrays`` . Keys: ``'ppm_f2'`` where they were taken
    trf2: dict
        Projections of the direct dimension, as ``1darrays`` . Keys: ``'ppm_f1'`` where they were taken
    Trf1: dict
        Projections of the indirect dimension, as :class:`pSpectrum_1D` objects. Keys: 'ppm_f2' where they were taken
    Trf2: dict
        Projections of the direct dimension, as :class:`pSpectrum_1D` objects. Keys: 'ppm_f1' where they were taken
    integrals: dict
        Dictionary where to save the regions and values of the integrals.
    D : fit.DosyFit object
        Fitting object created when integrals are read or instanced.

    """
    def __init__(self, in_file, acqus, datadir, ppm_scale=None, indirect_scale=None, p=None, ngdic=None):
        """
        Initialize the class with the already processed spectrum and a few other parameters.

        Parameters
        ----------
        in_file : 2darray
            Processed spectrum
        acqus : dict
            Acquisition dictionary in the format of the one of :class:`klassez.Spectra.DOSY_T1`
        datadir : str
            Location of the original 3D spectrum
        ppm_scale : 1darray or None
            PPM scale of the direct dimension to be used. If ``None``, it is computed from ``acqus``
        indirect_scale: 1darray
            Scale for the indirect dimension. Important for correct display and fitting.
        p : int or str
            It will be added to ``self.filename`` in order to not overwite the original files
        """
        # The input file is the processed data
        # Real part
        self.rr = in_file.real
        # Imaginary part
        if np.iscomplexobj(in_file):        # use the original imaginary part
            self.ii = in_file.imag
        else:                               # compute the hilbert transform
            self.ii = processing.hilbert(self.rr).imag
        # Complex data
        self.S = self.rr + 1j * self.ii

        # Store the acqus dictionary
        self.acqus = acqus
        # Add the FnMODE to acqus, required for certain functions inherited by Spectrum_2D
        if 'FnMODE' not in self.acqus.keys():
            self.acqus['FnMODE'] = 'No'

        # Datadir and stuff
        self.datadir = deepcopy(datadir)
        self.filename = self.datadir.stem
        if p is not None:
            self.filename += '_'+f'{p}'

        # Compute the ppm and frequency scale for the direct dimension
        if ppm_scale is None:   # compute the frequency scale, then transform it into ppm
            self.freq_f2 = processing.make_scale(self.rr.shape[-1], self.acqus['dw'])
            self.ppm_f2 = misc.freq2ppm(self.freq_f2, self.acqus['SFO1'], self.acqus['o1p'])
        else:   # read the ppm scale and transform it to frequency
            self.ppm_f2 = ppm_scale
            self.freq_f2 = misc.ppm2freq(self.ppm_f2, self.acqus['SFO1'], self.acqus['o1p'])

        # Important for the plots and the fit
        #   the frequency scale is just the index of the transient in human numbering
        self.freq_f1 = np.arange(self.rr.shape[0]) + 1
        #   The ppm scale instead will be:
        if indirect_scale is None:      # the index of the transient in python numbering
            self.ppm_f1 = np.arange(self.rr.shape[0])
        else:                           # the passed one
            self.ppm_f1 = indirect_scale

        # We might need it later
        if ngdic is not None:
            self.ngdic = ngdic

    def write_procs(self):
        """ Overwrites the original function to NOT EVER save a procs file """
        pass


class DOSY_T1:
    """
    Class: 3D DOSY spectrum

    It should be acquired with the sequence described in
    `Novakovic M. et al. (2025), Nature Communications, 16(1), 4628`_,
    Supporting Information, Figure 12a.
    This sequence, adapted in the pulse program ``stebpgp1s193D.rav``,
    evolves the big delta (``d20``) in dimension F2 and the gradient strength according to
    the `difflist` in dimension F1.
    This sequence is actually equivalent to acquiring separate DOSY with different ``d20``.


    .. _Novakovic M. et al. (2025), Nature Communications, 16(1), 4628: https://www.nature.com/articles/s41467-025-59759-2

    Attributes
    ----------
    datadir : str
        Path to the input file/dataset directory
    filename : str
        Base of the name of the file, without extensions
    fid : 3darray
        FID
    acqus : dict
        Dictionary of acqusition parameters
    ngdic : dict
        Created only if it is an experimental spectrum. Generated by :func:`nmrglue.<spectrometer>.read`, contains all the information on the spectrometer and on the spectrum.
    procs : dict
        Dictionary of processing parameters
    S : 3darray
        Complex (or hypercomplex, depending on ``FnMODE``) spectrum
    x_f1 : 1darray
        gradient list
    x_f2 : 1darray
        big delta list
    freq_f2 : 1darray
        Frequency scale of the direct dimension, in Hz
    ppm_f2 : 1darray
        ppm scale of the direct dimension
    """

    def __init__(self, in_file):
        """
        Initialize the class.

        Parameters
        ----------
        in_file : str
            path to file to read, or to the folder of the spectrum
        """
        self.datadir, self.filename = misc.get_datadir_filename(in_file, isexp=True)

        with warnings.catch_warnings():   # Suppress errors due to CONVDTA in TopSpin
            warnings.simplefilter("ignore")
            # Read the data, acquisition parameters...
            dic, data = ng.bruker.read(in_file, cplex=True)
            self.ngdic = dic
            self.fid = data
            self.acqus = misc.makeacqus_pp3D(dic)
            self.fid = np.reshape(self.fid, (self.acqus['TD1'], self.acqus['TD2'], -1))
            self.acqus['BYTORDA'] = dic['acqus']['BYTORDA']
            self.acqus['DTYPA'] = dic['acqus']['DTYPA']

            del dic
            del data

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

        # Load the difflist
        self.x_f1 = np.loadtxt(self.datadir / 'difflist')
        self.label_f1 = r'Gradient /G cm$^{-1}$'

        # Load the d20 list
        #   It should be the only file in lists/vd/ but we do not know its name
        vdlistpath = next((self.datadir / 'lists' / 'vd').iterdir())
        #   Print a notification
        print(f'Found {vdlistpath} to be imported as VDLIST', c='tab:blue')
        #   Actual loading and storage in an attribute
        vdlist = np.loadtxt(vdlistpath)
        self.x_f2 = vdlist
        self.label_f2 = 'Delay /s'

        # Procs dictionary
        if (self.datadir / self.filename).with_suffix('.procs').exists():
            self.procs = self.read_procs()
        else:
            self.procs = self._default_procs(self.acqus)
            self.write_procs()

    @staticmethod
    def _default_procs(acqus):
        procs = {}
        proc_init_1D = (deepcopy(wf0), None, 0.5, 0)
        for k, key in enumerate(proc_keys_1D):
            procs[key] = proc_init_1D[k]         # Processing parameters
        procs['wf']['sw'] = round(acqus['SW'], 4)
        #   Then, phases
        procs['p0'] = 0
        procs['p1'] = 0
        procs['pv'] = round(acqus['o1p'], 2)
        #   Then, baseline
        procs['basl_c'] = None
        procs['cal'] = 0
        procs['roll_ppm'] = None
        return procs

    def process(self):
        """
        Process only the direct dimension.
        Calls :func:`klassez.processing.fp` on each transient.
        The parameters are read from the ``procs`` dictionary.

        .. seealso::

            :func:`klassez.processing.fp`
        """
        # Make empty self.S whose dimensions are given by the zero-filling
        if self.procs['zf'] is None:
            self.S = np.zeros(self.fid.shape).astype(self.fid.dtype)
        else:
            self.S = np.zeros((*self.fid.shape[:-1], self.procs['zf'])).astype(self.fid.dtype)

        # Do the processing
        # We suppress the UserWarnings because if the zerofilling is not enough it will show the warning 800 times and it is annoying
        with warnings.catch_warnings():
            warnings.simplefilter('ignore', UserWarning)
            # Process the transients one by one hence double for
            for j in range(self.fid.shape[0]):      # difflist
                for k in range(self.fid.shape[1]):  # vdlist
                    self.S[j, k] = processing.fp(self.fid[j, k], wf=self.procs['wf'], zf=self.procs['zf'], fcor=self.procs['fcor'], tdeff=self.procs['tdeff'])
        # Now if the zerofilling is incorrect give the warning just once
        if self.S.shape[-1] < 2 * self.fid.shape[-1]:
            warnings.warn('Not enough zerofilling for Hilbert Transform')

        # Make the frequency and ppm scales
        self.freq_f2 = processing.make_scale(self.S.shape[-1], dw=self.acqus['dw'])
        if self.acqus['SFO1'] < 0:      # Correct for negative gamma nuclei
            self.freq_f2 = self.freq_f2[::-1]
        self.ppm_f2 = misc.freq2ppm(self.freq_f2, B0=self.acqus['SFO1'], o1p=self.acqus['o1p'])

        if self.acqus['SFO1'] < 0:      # Correct for negative gamma nuclei
            self.S = self.S[..., ::-1]

        # Adjust the phase
        self.adjph(fromplane=None, p0=self.procs['p0'], p1=self.procs['p1'], pv=self.procs['pv'], update=False)

    def getplane(self, idx, dim='31'):
        """
        Extract from ``self.S`` the ``idx``-th plane (python numbering)
        on the direction ``dim``.
        Keeps a reference to it as the attribute ``self._active``.

        Parameters
        ----------
        idx : int
            Index of the plane to extract
        dim : str
            ``'31'`` = plane F3-F1; ``'32'`` = plane F3-F2

        Returns
        -------
        active : pDOSY object
            Extracted plane as :class:`klassez.Spectra.pDOSY` object
        """
        # We want to pass the ppm scale of the spectrum
        if hasattr(self, 'ppm_f2'):
            ppm_scale = self.ppm_f2
        else:   # we do not care, pDOSY will compute it by itself
            ppm_scale = None

        if dim == '31':
            # F3 - F1 plane
            #   Slice along the second dimension
            S = self.S[:, idx]
            #   The indirect scale is the difflist, hence F1
            indir_scale = self.x_f1
        else:
            # F3 - F1 plane
            #   Slice along the first dimension
            S = self.S[idx]
            #   The indirect scale is the vdlist, hence F2
            indir_scale = self.x_f2

        # Store the plane as active and return it
        self._active = pDOSY(S, self.acqus, self.datadir, ppm_scale, indir_scale, f'd{dim}_p{idx}', self.ngdic)
        return self._active

    def write_procs(self, other_dir=None):
        """
        Writes the actual ``procs`` dictionary in a file named ``"filename.procs"`` in the same directory of the input file.

        Parameters
        ----------
        other_dir: str or None
            Different location for the ``procs`` dictionary to write into. If None, ``self.datadir`` is used instead.

            .. warning::

                Do not put the trailing slash!
        """
        if other_dir:
            path = Path(other_dir) / f'{self.filename}'
        else:
            path = self.datadir / f'{self.filename}'
        path.with_suffix('.procs').write_text(f'{self.procs}')

    def read_procs(self, other_dir=None):
        """
        Reads the procs dictionary from a file named ``filename.procs`` in the same directory of the input file.

        Parameters
        ----------
        other_dir : str or None
            Different location for the procs dictionary to look into. If None, ``self.datadir`` is used instead.

            .. warning::

                Do not put the trailing slash!

        Returns
        -------
        procs: dict
            Dictionary of processing parameters
        """
        if other_dir:
            path = Path(other_dir) / f'{self.filename}.procs'
        else:
            path = self.datadir / f'{self.filename}.procs'
        procs = eval(path.read_text().replace('array', 'np.array'))
        # Check if it was read correctly
        if isinstance(procs, dict):
            return procs
        else:
            raise ValueError(f'{path} cannot be interpreted as a dictionary')

    def adjph(self, fromplane=0, expno=0, dim='31', p0=None, p1=None, pv=None, update=True):
        """
        Adjusts the phases of the spectrum according to the given parameters, or interactively if they are left as default.

        Parameters
        ----------
        fromplane : int
            Index of the plane to use in the ``dim`` direction
        expno: int
            Index of the experiment (python numbering) of the extracted plane to use in the interactive panel
        dim : str
            If ``'31'``, the directive ``fromplane`` is relative to the F3-F1 direction.
            If ``'32'``, the directive ``fromplane`` is relative to the F3-F2 direction.
        p0: float or None
            0-th order phase correction /°
        p1: float or None
            1-st order phase correction /°
        pv: float or None
            1-st order pivot /ppm
        update: bool
            Choose if to upload the procs dictionary or not

        .. seealso::

            :func:`klassez.processing.ps`

            :func:`klassez.processing.interactive_phase_1D`
        """
        if fromplane is None:
            values = p0, p1, pv
        else:
            # Get the reference spectrum
            self.getplane(fromplane, dim=dim)
            # Adjust the phases on the reference plane with its internal method
            values = self._active.adjph(expno=expno, update=False)

        # Apply to the whole dataset
        self.S, _ = processing.ps(self.S, self.ppm_f2, *values)

        # Update the procs dictionary
        if update:
            self.procs['p0'] += round(values[0], 2)
            self.procs['p1'] += round(values[1], 2)
            if values[2] is not None:
                self.procs['pv'] = round(values[2], 5)
        # Update the .procs file
        self.write_procs()

    def pknl(self):
        """
        Reverses the effect of the digital filter by applying a first order phase correction.
        To be called after having processed the data by :func:`self.process()`.
        """
        if hasattr(self, 'S'):
            self.S = processing.pknl(self.S, grpdly=self.acqus['GRPDLY'], onfid=False)
        else:
            self.fid = processing.pknl(self.fid, grpdly=self.acqus['GRPDLY'], onfid=True)

    def plot(self, dim='31', fqscale=False, Neg=True, lvl0=0.2, name=None):
        """
        Plots the real part of the spectrum as a 2D contour plot.
        You can scroll between the various planes inside the interactive panel.

        Parameters
        ----------
        dim : str
            ``'31'`` = plane F3-F1; ``'32'`` = plane F3-F2
        fqscale : bool
            Display using frequency scale instead of ppm
        Neg : bool
            Plot (True) or not (False) the negative contours.
        lvl0 : float
            Starting contour value.
        name : str
            Custom name for the figure. If ``None``, ``self.filename`` is used.
        """
        if name is None:
            name = self.filename
        # Plots data, set Neg=True to see negative contours
        if fqscale:
            x_f2 = self.freq_f2
            X_label = r'$\nu\ $' + misc.nuc_format(self.acqus['nuc']) + ' /Hz'
        else:
            x_f2 = self.ppm_f2
            X_label = r'$\delta\ $' + misc.nuc_format(self.acqus['nuc']) + ' /ppm'

        if dim == '31':
            x_f1 = self.x_f1
            idx_scale = self.x_f2
            Y_label = self.label_f1
        elif dim == '32':
            x_f1 = self.x_f2
            idx_scale = self.x_f1
            Y_label = self.label_f2

        figures.plot_p3D(x_f1, x_f2, idx_scale, self.S.real, dim=dim, Neg=Neg,
                         name=name, X_label=X_label, Y_label=Y_label, lvl0=lvl0)

    def integrate(self, filename=None):
        """
        Create the attribute ``self.D`` as ``klassez.fit.DosyFit_pp3D``, and integrate the planes along the `31` direction.

        Parameters
        ----------
        filename : str or None
            Custom filename for data and plots. If ``None``, ``self.filename`` is used.

        Returns
        -------
        None

        .. seealso::

            :class:`klassez.fit.DosyFit_pp3D`
            :func:`klassez.fit.DosyFit_pp3D.__init__`
        """
        if filename is None:
            filename = self.filename
        filename = Path(filename)
        self.D = fit.DosyFit_pp3D(self, datadir=filename, filename=filename)
