from pyepr.classes import Parameter
from pyepr.utils import build_table, sop, autoEPRDecoder
from memoization import cached
import numpy as np
from scipy.integrate import cumulative_trapezoid
import numbers
import uuid
import json
import base64
import matplotlib.pyplot as plt
import scipy.fft as fft
from pyepr import __version__
import copy
from functools import reduce
from itertools import accumulate
from numba import njit

#@njit
def compute_upulses_not_trajectory(dUs):
    n_offsets, n_steps, _, _ = dUs.shape
    Upulses = np.empty((n_offsets, 2, 2), dtype=np.complex128)
    for i in range(n_offsets):
        U = np.eye(2, dtype=np.complex128)
        for j in range(n_steps):
            U = dUs[i, j] @ U
        Upulses[i] = U
    return Upulses

#@njit
def compute_upulses_trajectory(dUs):
    n_offsets, n_steps, _, _ = dUs.shape
    Upulses = np.empty((n_offsets, n_steps + 1, 2, 2), dtype=np.complex128)
    eye = np.eye(2, dtype=np.complex128)
    for i in range(n_offsets):
        U = eye.copy()
        Upulses[i, 0] = U
        for j in range(n_steps):
            U = dUs[i, j] @ U
            Upulses[i, j + 1] = U
    return Upulses

#@njit
def compute_magnetization_not_trajectory(Upulses, density0, Mmag):
    n_offsets = Upulses.shape[0]
    density = np.empty((n_offsets, 2, 2), dtype=np.complex128)
    Mag = np.empty((n_offsets, 3))

    for i in range(n_offsets):
        U = Upulses[i]
        density_i = density0[i]
       
        # Ensure density_i is contiguous
        if not density_i.flags.c_contiguous:
            density_i = np.ascontiguousarray(density_i)
       
        # Compute U_conj_T and ensure it's contiguous
        U_conj_T = U.conj().T
        if not U_conj_T.flags.c_contiguous:
            U_conj_T = np.ascontiguousarray(U_conj_T)
        
        # Ensure U is contiguous
        if not U.flags.c_contiguous:
            U = np.ascontiguousarray(U)
       
        D = U @ density_i @ U_conj_T
        density[i] = D

        Mag[i, 0] =  2 * D[0, 1].real
        Mag[i, 1] = -2 * D[1, 0].imag
        Mag[i, 2] =  D[0, 0].real - D[1, 1].real

    return Mag * Mmag[:, None]

#@njit
def compute_magnetization_trajectory(Upulses, density0):
    n_offsets, n_steps = Upulses.shape[:2]
    density = np.empty((n_offsets, n_steps, 2, 2), dtype=np.complex128)
    Mag = np.empty((n_offsets, n_steps, 3))

    for i in range(n_offsets):
        for j in range(n_steps):
            U = Upulses[i, j]
            D = U @ density0[i] @ U.conj().T
            density[i, j] = D

            Mag[i, j, 0] =  2 * D[0, 1].real
            Mag[i, j, 1] = -2 * D[1, 0].imag
            Mag[i, j, 2] =  D[0, 0].real - D[1, 1].real

    return Mag

class Pulse:
    """
    Represents a general experimental pulse.
    """

    def __init__(self, *, tp, t=None, scale=None, flipangle=None, pcyc=[0],
                 name=None, **kwargs) -> None:
        """The class for a general pulse.

        Parameters
        ----------
        tp : float
            The pulse length in ns.
        scale : float
            The arbitary experimental pulse amplitude, 0-1.
        t : float, optional
            The pulse start time in ns.

        """
        if t is not None:
            if isinstance(t,numbers.Number):
                self.t = Parameter("t", t, "ns", "Start time of pulse")
            elif isinstance(t,Parameter):
                self.t = t
        else:
            self.t = None

        if isinstance(tp,numbers.Number):
            self.tp = Parameter("tp", tp, "ns", "Length of the pulse")
        elif isinstance(tp,Parameter):
            self.tp = tp

        if isinstance(scale,numbers.Number):
            self.scale = Parameter("scale", scale, None, "Amplitude of pulse")
        elif isinstance(scale,Parameter):
            self.scale = scale
        elif scale is None:
            self.scale = Parameter("scale", None, None, "Amplitude of pulse")


        self.name = name
        self.Progression = False

        if flipangle is not None:
            self.flipangle = Parameter(
                "flipangle", flipangle, None,
                "The target flip angle of the spins")
        if pcyc is None:
            self.pcyc = None
        elif type(pcyc) is dict:
            self._addPhaseCycle(pcyc["phases"], detections=pcyc["dets"])
        else:
            self._addPhaseCycle(pcyc, detections=None)
        pass

    @property
    def bandwidth(self):
        BW = Parameter("Bandwidth", 0, "GHz", "Bandwidth of pulse")
        if hasattr(self, "freq"):
            BW.value =0
        elif hasattr(self, "init_freq") & hasattr(self, "final_freq"):
            BW.value = np.abs(self.final_freq.value - self.init_freq.value)
        else:
            raise ValueError()
        return BW
    
    def _addPhaseCycle(self, phases, detections=None):
            """
            Adds a phase cycle to the pulse sequence.

            Args:
                phases (list): List of phases to add to the phase cycle.
                detections (list, optional): List of detection signs. Defaults to None. If None then all cycles are summed.

            Returns:
                None
            """
            if detections is None:
                detections = np.ones(len(phases))
            self.pcyc = {"Phases": list(phases), "DetSigns": list(detections)}
            pass

    def _buildFMAM(self, func, ax=None):
        """
        Builds the amplitude modulation (AM) and frequency modulation (FM) of a given function.

        Args:
            func: A function that takes in an array of values and returns two arrays, representing the AM and FM of the function.

        Returns:
            Two arrays representing the AM and FM of the function.
        """
        self.ax = np.linspace(-self.tp.value/2, self.tp.value/2, 1001)
        dt = self.ax[1]-self.ax[0]
        self.AM, self.FM = func(self.ax)
        FM_arg = 2*np.pi*cumulative_trapezoid(self.FM, initial=0) * dt
        self.complex = self.AM * (np.cos(FM_arg) +1j* np.sin(FM_arg))
        self.FM
        self.AM
        return self.AM, self.FM
    
    
    def build_shape(self,ax=None, full_output=False):
        """
        Creates the time domain representation of the pulse shape, using the method `func`.

        Parameters
        ----------
        ax : np.ndarray, optional
            The axis to build the shape on. If None, uses the internal `ax` attribute.
        
        """
        if ax is None:
            ax = self.ax

        dt = ax[1]-ax[0]
        pulse_points = int(np.around(self.tp.value/dt))
        total_points = ax.shape[0]
        pad_points = total_points - pulse_points

        # pulse_axis = np.zeros(pulse_points)
        pulse_axis = np.linspace(self.ax.min(),self.ax.max(),pulse_points)
        AM, FM = self.func(pulse_axis)

        if pad_points > 0:
            pre_pad_points = int(np.floor(pad_points/2))
            post_pad_points = int(np.ceil(pad_points/2))
            AM = np.pad(AM,(pre_pad_points,post_pad_points),'constant',constant_values=0)
            FM = np.pad(FM,(pre_pad_points,post_pad_points),'constant',constant_values=0)
            full_axis = np.linspace(ax[0], ax[-1], total_points)
        else:
            full_axis = pulse_axis
        cum_phase = 2*np.pi*cumulative_trapezoid(FM, initial=0) * dt
        shape = AM * (np.cos(cum_phase) +1j* np.sin(cum_phase))
        
        
        if full_output:
            return full_axis, shape
        else:
            return shape

    def build_table(self):
            """
            Builds a table of variables, axes, and UUIDs for all non-static Parameters in the object.

            Returns:
                dict: A dictionary containing the following keys: "Variable", "axis", and "uuid".
                The values for each key are lists of the corresponding values for each non-static Parameter.
            """
            progTable = {"Variable": [], "axis": [],
                    "uuid": []}
            for arg in vars(self):
                attr = getattr(self,arg)
                if type(attr) is Parameter and not attr.is_static():
                    for i, axes in enumerate(attr.axis):
                        progTable["Variable"].append(arg)
                        progTable["axis"].append(axes["axis"])
                        progTable["uuid"].append(axes["uuid"])

            return progTable

    
    def is_static(self):
            """
            Check if all parameters in the pulse object are static.

            Returns:
                bool: True if all parameters are static, False otherwise.
            """
            for arg in vars(self):
                attr = getattr(self,arg)
                if type(attr) is Parameter and not attr.is_static():
                    return False
            return True

    def isDelayFocused(self):
        """
        Does the pulse contain a specified time, `t`?

        If so then it is not delay focused.
        """
        if self.t is None:
            return True
        else:
            return False
    
    def isPulseFocused(self):
        """
        Does the pulse contain a specified time, `t`?

        If so then it is delay focused.
        """
        if self.t is not None:
            return True
        else:
            return False

    def plot(self, pad=1000):
        """Plots the time domain representation of this pulse.

        Parameters
        ----------
        pad : int, optional
            The number of zeros to pad the data with, by default 1000
        """
        dt = self.ax[1] - self.ax[0]
        if self.isPulseFocused():
            tax = self.t.value + self.ax
        else:
            tax = self.ax
        fwd_ex = np.linspace(tax[0] - dt * pad, tax[0], pad)
        rev_ex = np.linspace(
            tax[-1] + dt, tax[-1] + dt * pad, pad, endpoint=True)
        tax = np.concatenate((fwd_ex, tax, rev_ex))
        data = np.pad(self.complex, pad)

        plt.plot(tax, np.real(data), label="Re")
        plt.plot(tax, np.imag(data), label="Im")
        plt.legend()
        plt.xlabel("Time (ns)")
        plt.ylabel("Amplitude (A.U.)")
        pass
    
    def _calc_fft(self, pad=10000):
        self._buildFMAM(self.func)
        data = np.pad(self.complex, int(pad))
        pulse_fft = fft.fftshift(fft.fft(data))
        n = data.shape[0]
        dt = self.ax[1] -self.ax[0]
        axis_fft = fft.fftshift(fft.fftfreq(n, dt))

        return axis_fft, pulse_fft
    
    def flip(self):
        """Flips the sweep direction of the pulse. Has no meaning for monochromatic pulses."""
        return self
    
    @property
    def amp_factor(self):
        """ The B1 amplitude factor (nutation frequency) for the pulse in GHz"""
        amp_factor_value=  self.flipangle.value / (2 * np.pi * np.trapz(self.AM,self.ax))
        return Parameter("amp_factor", amp_factor_value, "GHz", "Amplitude factor for the pulse")
    

    # @cached(thread_safe=False)
    def exciteprofile_old(self, freqs=None, resonator = None):
        """Excitation profile

        Generates the exciatation profiles for this pulse.

        This function is ported from EasySpin 
        (https://easyspin.org/easyspin/documentation/sop.html) [1-2],
        and based upon the method from Gunnar Jeschke, Stefan Pribitzer and
        Andrin Doll[3].

        References:
        +++++++++++
        [1] Stefan Stoll, Arthur Schweiger
        EasySpin, a comprehensive software package for spectral simulation and analysis in EPR
        J. Magn. Reson. 178(1), 42-55 (2006)
        
        [2] Stefan Stoll, R. David Britt
        General and efficient simulation of pulse EPR spectra
        Phys. Chem. Chem. Phys. 11, 6614-6625 (2009)

        [3] Jeschke, G., Pribitzer, S. & DollA. 
        Coherence Transfer by Passage Pulses in Electron Paramagnetic 
        Resonance Spectroscopy. 
        J. Phys. Chem. B 119, 13570-13582 (2015)


        Parameters
        ----------
        
        freqs: np.ndarray, optional
            The frequency axis. Caution: A larger number of points will linearly
            increase computation time.
        resonator: ad.ResonatorProfile, optional

        
        Returns
        -------

        Mx: np.ndarray
            The magentisation in the X direction.
        My: np.ndarray
            The magentisation in the Y direction.
        Mz: np.ndarray
            The magentisation in the Z direction.
        """
        eye = np.identity(2)
        # offsets = np.arange(-2*BW,2*BW,0.002)

        self._buildFMAM(self.func)

        if freqs is None:
            fxs, fs = self._calc_fft()
            fs = np.abs(fs)
            points = np.argwhere(fs>(fs.max()*0.5))[:,0]
            BW = fxs[points[-1]] - fxs[points[0]]
            c_freq = np.mean([fxs[points[-1]], fxs[points[0]]])
            offsets = np.linspace(-2*BW, 2*BW, 100) + c_freq
        else:
            offsets = freqs

        offsets 
        t = self.ax 
        nOffsets = offsets.shape[0]

        ISignal = np.real(self.complex) * self.amp_factor.value
        QSignal = np.imag(self.complex) * self.amp_factor.value

        if resonator is not None:
            FM = self.FM
            amp_factor = np.interp(FM, resonator.freqs-resonator.freq_c, resonator.profile)
            amp_factor = np.min([amp_factor,np.ones_like(amp_factor)*self.amp_factor.value],axis=0)
            ISignal = np.real(self.complex) * amp_factor
            QSignal = np.imag(self.complex) * amp_factor

        npoints = ISignal.shape[0]
        Sx = sop([1/2],"x")
        Sy = sop([1/2],"y")
        Sz = sop([1/2],"z")
        Density0 = -Sz
        Mag = np.zeros((3,nOffsets), dtype=np.complex128)
        for iOffset in range(0,nOffsets):
            UPulse = eye
            Ham0 = offsets[iOffset]*Sz

            for it in range(0,npoints):
                Ham = ISignal[it] * Sx + QSignal[it] * Sy + Ham0
                M = -2j * np.pi * (t[1]-t[0]) * Ham
                q = np.sqrt(M[0,0] ** 2 - np.abs(M[0,1]) ** 2)
                if np.abs(q) < 1e-10:
                    dU = eye + M
                else:
                    dU = np.cosh(q)*eye + (np.sinh(q)/q)*M
                UPulse = dU @ UPulse

            Density = UPulse @ Density0 @ UPulse.conjugate().T
            Mag[0, iOffset] = -2 * (Sx @ Density.T).sum().real
            Mag[1, iOffset] = -2 * (Sy @ Density.T).sum().real
            Mag[2, iOffset] = -2 * (Sz * Density.T).sum().real
        
        return Mag[0,:], Mag[1,:], Mag[2,:]
    
    # @cached(thread_safe=False)
    def exciteprofile(self, freqs=None, resonator=None, trajectory=False):
        """Excitation profile

        Generates the exciatation profiles for this pulse, using the two-level system model developed by Jeschke et al. [1].
        And then optimised in the PulseShape software package by Maxx Tessmer [2], reproduced under the GNU GPL v3 license.

        References:
        +++++++++++
        [1] Jeschke, G., Pribitzer, S. & Doll, A. 
            Coherence Transfer by Passage Pulses in Electron Paramagnetic 
            Resonance Spectroscopy. 
            J. Phys. Chem. B 119, 13570-13582 (2015)
        [2] https://github.com/mtessmer/PulseShape

        Parameters
        ----------
        
        freqs: np.ndarray, optional
            The frequency axis. Caution: A larger number of points will linearly
            increase computation time.
        resonator: ad.ResonatorProfile, optional
            The resonator profile to apply resonator compensation.
        trajectory: bool, optional
            If True, the function will return the magnetisation at each time point. Default is False.
        
        Returns
        -------
        Mag: np.ndarray
            The magnetisation in the X, Y, and Z directions.
        
        """
        self._buildFMAM(self.func)

        if freqs is None:
            fxs, fs = self._calc_fft()
            fs = np.abs(fs)
            points = np.argwhere(fs>(fs.max()*0.5))[:,0]
            BW = fxs[points[-1]] - fxs[points[0]]
            c_freq = np.mean([fxs[points[-1]], fxs[points[0]]])
            offsets = np.linspace(-2*BW, 2*BW, 100) + c_freq
        else:
            offsets = freqs
        
        ISignal = np.real(self.complex) * self.amp_factor.value
        QSignal = np.imag(self.complex) * self.amp_factor.value
        
        if resonator is not None:
            FM = self.FM
            if self.scale is None or self.scale.value is None:
                amp_factor = np.interp(FM, resonator.freqs-resonator.freq_c, resonator.profile)
                amp_factor = np.min([amp_factor,np.ones_like(amp_factor)*self.amp_factor.value],axis=0)
            else:
                if hasattr(self,'init_freq'):
                    amp_factor = np.interp(FM, resonator.freqs-resonator.freq_c, resonator.profile)
                    amp_factor = amp_factor * self.scale.value
                else:
                    amp_factor = self.amp_factor.value

            ISignal = np.real(self.complex) * amp_factor
            QSignal = np.imag(self.complex) * amp_factor

        t= self.ax
        M0=[0, 0, 1]
        M0 = np.asarray(M0, dtype=float)
        if len(M0.shape) == 1:
            Mmag = np.linalg.norm(M0)
            M0 /= Mmag
            M0 = np.tile(M0, (len(offsets), 1))
            Mmag = np.array([Mmag for i in range(len(offsets))])
        else:
            Mmag = np.linalg.norm(M0, axis=1)
            M0 /= Mmag[:, None]


        Sx = sop([1/2],"x")
        Sy = sop([1/2],"y")
        Sz = sop([1/2],"z")

        density0 = 0.5 * np.array(([[1 + M0[:, 2], M0[:, 0] - 1j * M0[:, 1]],
                                [M0[:, 0] + 1j * M0[:, 1], 1 - M0[:, 2]]]))
        density0 = np.moveaxis(density0, 2, 0)

        dt = t[1] - t[0]

        H = offsets[:, None, None] * Sz
        H = H[:, None, :, :] + ISignal[:, None, None] * Sx + QSignal[:, None, None] * Sy
        M = -2j * np.pi * dt * H
        q = np.sqrt(M[:, :, 0, 0]**2 - np.abs(M[:, :, 0, 1])**2)

        dUs = np.cosh(q)[:, :, None, None] * np.eye(2, dtype=complex) + (np.sinh(q) / q)[:, :, None, None] * M
        mask = np.abs(q) < 1e-10
        dUs[mask] = np.eye(2, dtype=complex) + M[mask]

        if not trajectory:
            # Upulses = np.empty((len(dUs), 2, 2), dtype=complex)
            # for i in range(len(dUs)):
            #     Upulses[i] = reduce(lambda x, y: y@x, dUs[i, :-1])
            # density = np.einsum('ijk,ikl,ilm->ijm', Upulses, density0, Upulses.conj().transpose((0, 2, 1)))
            # density = density.transpose((0, 2, 1))

            # Mag = np.zeros((len(offsets), 3))
            # Mag[..., 0] =  2 * density[..., 0, 1].real             # 2 * (Sx[None, :, :] * density).sum(axis=(1, 2)).real
            # Mag[..., 1] = -2 * density[..., 1, 0].imag             # 2 * (Sy[None, :, :] * density).sum(axis=(1, 2)).real
            # Mag[..., 2] =  density[..., 0, 0] - density[..., 1, 1] # 2 * (Sz[None, :, :] * density).sum(axis=(1, 2)).real
            # return np.squeeze(Mag * Mmag[:, None])
            Upulses = compute_upulses_not_trajectory(dUs)
            Mag = compute_magnetization_not_trajectory(Upulses, density0, Mmag)
            return np.squeeze(Mag)
        else:
            # Upulses = np.empty((len(dUs), len(t), 2, 2), dtype=complex)
            # for i in range(len(dUs)):
            #     Upulses[i] = [np.eye(2)] +  list((accumulate(dUs[i, :-1], lambda x, y: y @ x)))

            # density = np.einsum('hijk,hkl,hilm->hijm', Upulses, density0, Upulses.conj().transpose((0, 1, 3, 2)))
            # density = density.transpose((0, 1, 3, 2))

            # Mag = np.zeros((len(offsets), len(t), 3))
            # Mag[..., 0] = 2 * density[..., 0, 1].real # 2 * (Sx[None, None, :, :] * density).sum(axis=(2, 3)).real
            # Mag[..., 1] = -2 * density[..., 1, 0].imag # 2 * (Sy[None, None, :, :] * density).sum(axis=(2, 3)).real
            # Mag[..., 2] = density[..., 0, 0] - density[..., 1, 1] # 2 * (Sz[None, None, :, :] * density).sum(axis=(2, 3)).real
            Upulses = compute_upulses_trajectory(dUs)
            Mag = compute_magnetization_trajectory(Upulses, density0)
            return Mag



    def plot_fft(self):
                
        ax, ft = self._calc_fft(1e4)
        plt.plot(ax, np.real(ft), label="Re")
        plt.plot(ax, np.imag(ft), label="Im")
        plt.plot(ax, np.abs(ft), label="Im")
        plt.legend()
        plt.xlabel("Frequency (GHz)")
        plt.ylabel("Amplitude (A.U.)")
        pass

    def _pcyc_str(self):
    
        if self.pcyc is not None:
            pcyc_table = "["
            dets = self.pcyc["DetSigns"]
            phases = self.pcyc["Phases"]
            for i in range(0, len(phases)):
                if dets[i] == 1:
                    pcyc_table += "+"
                elif dets[i] == -1:
                    pcyc_table += "-"
                elif dets[i] == 1j:
                    pcyc_table += "+i"
                elif dets[i] == -1j:
                    pcyc_table += "-i"

                if phases[i] == 0:
                    pcyc_table += "(+x)"
                elif phases[i] == np.pi:
                    pcyc_table += "(-x)"
                elif phases[i] == np.pi/2:
                    pcyc_table += "(+y)"
                elif phases[i] == -np.pi/2:
                    pcyc_table += "(-y)"
                elif phases[i] == 3*np.pi/2:
                    pcyc_table += "(-y)"
                pcyc_table += " "
            
            pcyc_table = pcyc_table[:-1]
            pcyc_table += "]"
        else:
            pcyc_table = ""

        return pcyc_table

    def __str__(self):
        # Build header line
        header = "#" * 79 + "\n" + "autoDEER Pulse Definition" + \
                 "\n" + "#" * 79 + "\n"
        
        # Build Overviews
        if self.isPulseFocused():
            overview_params = "Time Pos (ns) \tLength (ns) \tScale (0-1)" +\
                "\t Static \n"
            
            if type(self) is Detection:
                overview_params += f"{self.t.value}" + "\t\t" + \
                    f"{self.tp.value}" + "\t\t" + "\t\t" +\
                    f"{self.is_static()}" + "\n" + "#" * 79 + "\n"        
            else:
                overview_params += f"{self.t.value}" + "\t\t" + \
                    f"{self.tp.value}" + "\t\t" + f"{self.scale.value}" + \
                    "\t\t" + f"{self.is_static()}" + "\n" + "#" * 79 + "\n"
        elif self.isDelayFocused():
            overview_params = "Length (ns) \tScale (0-1)" +\
                "\t Static \n"
            
            if type(self) is Detection:
                overview_params += f"{self.tp.value}" + "\t\t" + "\t\t" +\
                    f"{self.is_static()}" + "\n" + "#" * 79 + "\n"        
            else:
                overview_params += f"{self.tp.value}" + "\t\t" +\
                    f"{self.scale.value}" + "\t\t" + f"{self.is_static()}" +\
                    "\n" + "#" * 79 + "\n"

        # Build Pulse Parameter Table
        param_table =  f"{'Name':^12} \t {'Value':^12} \t {'Unit':^7} \t Description\n"
        param_table += f"{'----':^12} \t {'-----':^12} \t {'----':^7} \t -----------\n"
        for attr_name in dir(self):
            attr = getattr(self, attr_name)
            if type(attr) is Parameter:
                if attr.name == "flipangle":
                    if attr.value == np.pi:
                        value = f"{'π':>12}"
                    elif attr.value == np.pi/2:
                        value = f"{'π/2':>12}"
                    elif attr.value == 'Hard':
                        value = f"{'Hard':>12}"
                    else:
                        value = f"{attr.value:>5.5g}"
                elif attr.value is None:
                    value = f"{'N/A':>12}"
                else:
                    value = f"{attr.value:>5.5g}"
                if attr.unit is None:
                    attr.unit = "None"
                if attr.description is None:
                    attr.description = ""

                param_table += f"{attr.name:>12} \t {value:>12} \t" +\
                    f"{attr.unit:>7} \t {attr.description} \n"

        # Build Pulse Progression Table
        if self.Progression is True:
            prog_table = "#" * 79 + "\n" + "Progressive Variables:" + "\n"
            prog_table += "Variable \t Axis \t Length \n"
            prog_table += "-------- \t ---- \t ------ \n"
            prog_table += f"{self.Prog_var} \t\t {self.Prog_axis} \t" +\
                f"{self.Prog_n} \n"
        else:
            prog_table = ""

        # Build Pulse Phase Cycle Table
        if self.pcyc is not None:
            pcyc_table = "#" * 79 + "\n" + "Phase Cycle:" + "\t"
            pcyc_table += self._pcyc_str()
            pcyc_table += "\n"
        else:
            pcyc_table = ""

        # Build Footer
        footer = "#" * 79 + "\n" +\
            f"Built by autoDEER Version: {__version__}" + "\n" + "#" * 79

        # Combine All
        string = header + overview_params + param_table + prog_table +\
            pcyc_table + footer

        return string

    def copy(self, clear=False, **kwargs):
        """Creates a deep-copy of the pulse. I.e. Every parameter object is
        re-created at another memory space.

        Parameter can be chaged at this stage by adding them as keyword-
        arguments (kwargs).

        Returns
        -------
        Pulse
            A deep copy of the pulse
        """

        new_pulse = copy.deepcopy(self)

        if clear:
            for arg in vars(new_pulse):
                attr = getattr(new_pulse,arg)
                if type(attr) is Parameter and not getattr(new_pulse,arg).is_static():
                    getattr(new_pulse,arg).remove_dynamic()


        for arg in kwargs:
            if (hasattr(new_pulse,arg)) and (getattr(new_pulse,arg) is not None):
                attr = getattr(new_pulse,arg)
                if type(attr) is Parameter:
                    if isinstance(kwargs[arg], numbers.Number):
                        attr.value = kwargs[arg]     
                    elif isinstance(kwargs[arg], Parameter):
                        attr.value = kwargs[arg].value
                        attr.axis = kwargs[arg].axis

                elif arg == "pcyc":
                    new_pcyc = kwargs[arg]
                    if attr is None:
                        new_pulse.pcyc = None
                    elif type(new_pcyc) is dict:
                        new_pulse._addPhaseCycle(new_pcyc["phases"], detections=new_pcyc["dets"])
                    else:
                        new_pulse._addPhaseCycle(new_pcyc, detections=None)               
                else:
                    attr = kwargs[arg]
            elif arg == "t":
                if type(kwargs[arg]) is Parameter:
                    kwargs[arg].name = "t"
                    setattr(new_pulse, arg, kwargs[arg])
                elif isinstance(kwargs[arg], numbers.Number):
                    setattr(new_pulse, arg,
                            Parameter("t", kwargs[arg], "ns",
                                    "Start time of pulse"))
                else:
                    raise ValueError("new parameters must be either numeric or Parameters")
            elif arg == "scale":
                setattr(new_pulse, arg,
                        Parameter("scale", kwargs[arg], None,
                                  "Amplitude of pulse"))
            elif arg == "flipangle":
                setattr(new_pulse,arg,
                        Parameter("flipangle", kwargs[arg], None,
                                  "The target flip angle of the spins"))
            
            else:
                setattr(new_pulse,arg,kwargs[arg])
                
        return new_pulse
    

    def _to_dict(self):
        to_return = {"version": __version__, "type": "Pulse", "subclass": str(type(self))}

        for key, var in vars(self).items():
            if isinstance(var, Parameter):
                to_return[key] = var._to_dict()
            else:
                to_return[key] = var

        return to_return
    
    def _to_json(self):
        class autoEPREncoder(json.JSONEncoder):
            def default(self, obj):
                if isinstance(obj, np.ndarray):
                    if (len(obj) > 0 ) and isinstance(obj[0], str):
                        return list(obj)
                    data = np.ascontiguousarray(obj.data)
                    data_b64 = base64.b64encode(data)
                    return dict(__ndarray__=str(data_b64),
                                dtype=str(obj.dtype),
                                shape=obj.shape)
                if isinstance(obj, complex):
                    return str(obj)
                if isinstance(obj, numbers.Number):
                    return str(obj)
                if isinstance(obj, uuid.UUID):
                    return_dict = {"__uuid__": str(obj)}
                    return return_dict
                if isinstance(obj, Parameter):
                    return obj._to_dict()
                if isinstance(obj, Pulse):
                    return obj._to_dict()
                else:
                    return json.JSONEncoder.default(self, obj)
        
        return json.dumps(self._to_dict(), cls=autoEPREncoder, indent=4)

    def save(self, filename):
        """Save the Pulse to a JSON file.

        Parameters
        ----------
        filename : str
            Path to the JSON file.

        Returns
        -------
        None

        Raises
        ------
        TypeError
            If the object cannot be serialized to JSON.

        Example
        -------
        >>> obj = Pulse()
        >>> obj.save("my_pulse.json")
        """
                
        with open(filename, "w") as f:
           f.write(self._to_json())

    @classmethod
    def _from_dict(cls, dct):
        new_pulse = cls(tp=Parameter._from_dict(dct["tp"]), name=dct["name"])
        for key, var in dct.items(): 
            if isinstance(var, dict) and ("type" in var):
                setattr(new_pulse, key, Parameter._from_dict(var))
            elif key == "type":
                continue
            elif key == "version":
                continue
            else:
                setattr(new_pulse, key, var)
            
        return new_pulse

    @classmethod
    def _from_json(cls, JSONstring):
        dct = json.loads(JSONstring, object_hook=autoEPRDecoder)
        return cls._from_dict(dct)
    
    @classmethod
    def load(cls, filename):
        """Load a Pulse object from a JSON file.

        Parameters
        ----------
        filename : str
            Path to the JSON file.

        Returns
        -------
        obj : Pulse
            The Pulse loaded from the JSON file.

        Raises
        ------
        FileNotFoundError
            If the file does not exist.

        Example
        -------
        >>> obj = Pulse.load("my_pulse.json")
        """
        with open(filename, "r") as f:
           file_buffer = f.read()
        return cls._from_json(file_buffer)

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

#                                Subclasses

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

class Detection(Pulse):
    """
    Represents a detection pulse.
    """

    def __init__(self, *, tp, t=None, freq=0,**kwargs) -> None:
        """A general detection pulse.

        Parameters
        ----------
        tp : float
            The **total** time of the detection event. The detection event will
            be symetrical about the centre time. 
        t : float, optional
            The **centre** time of the detection event
        freq: float, optional
            The detection frequency, not all spectrometer support this 
            functionality, by default 0 MHz

        """
        Pulse.__init__(self, t=t, tp=tp, **kwargs)
        self.scale = None
        self.freq = Parameter("freq", freq, "GHz", "The detection frequency, if supported")
        self.pcyc = None
        pass


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


class Delay(Pulse):
    """
    DEPRECATION WARNING: THIS WILL BE REMOVED SOON.
    
    Represents a inter-pulse delay pulse.
    """

    def __init__(self, *, tp, t=None) -> None:
        
        if t is not None:
            self.t = Parameter("Time", t, "ns", "Start time of pulse")
        else:
            self.t = None
        self.tp = Parameter("Length", tp, "ns", "Length of the pulse")
        self.pcyc = None
        self.scale = None

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


class RectPulse(Pulse):
    """
    Represents a rectangular monochromatic pulse.
    """

    def __init__(
            self, tp=16, freq=0, t=None, flipangle=None, **kwargs) -> None:
        """

        Parameters
        ----------
        tp : flaot, optional
            Pulse Length in ns, by default 16
        freq : float,optional
            Frequency in MHz, by default 0
        t : float, optional
            Time position in ns, by default None
        flipangle : _type_, optional
            The flip angle in radians, by default None
        """
        Pulse.__init__(
            self, tp=tp, t=t, flipangle=flipangle,name='RectPulse', **kwargs)
        self.freq = Parameter("freq", freq, "GHz", "Frequency of the Pulse")
        self._buildFMAM(self.func)
        pass

    def func(self, ax):
        nx = ax.shape[0]
        AM = np.ones(nx)
        FM = np.zeros(nx) + self.freq.value
        return AM, FM

class GaussianPulse(Pulse):
    """
    Represents a Gaussian monochromatic pulse.
    """

    def __init__(self, *, tp=32,FWHM=16, freq=0, **kwargs) -> None:
        """    Represents a Gaussian monochromatic pulse.

        Parameters
        ----------
        tp : float
            Pulse length in ns, by default 128
        FWHM : float,
            The full width at half maximum of the pulse
        freq : float, optional
            The frequency of the pulse, by default 0
        """
        Pulse.__init__(self, tp=tp,name='GaussianPulse', **kwargs)
        self.freq = Parameter("Freq", freq, "GHz", "Frequency of the Pulse")
        self.FWHM = Parameter("FWHM", FWHM, "ns", "Full Width at Half Maximum of the Pulse")
        self._buildFMAM(self.func)
        pass

    def func(self, ax):
        nx = ax.shape[0]
        sigma = self.FWHM.value / (2 * np.sqrt(2 * np.log(2)))
        AM = np.exp(-ax**2 / (2 * sigma**2))
        FM = np.zeros(nx) + self.freq.value
        return AM, FM
# =============================================================================
class FrequencySweptPulse(Pulse):
    """
    A general parent class for Frequency Swept Pulses.
    """
    
    def __init__(self, *, tp, t=None, scale=None, flipangle=None, pcyc=[0], name=None, **kwargs) -> None:
        
        super().__init__(tp=tp, t=t, scale=scale, flipangle=flipangle, pcyc=pcyc, name=name, **kwargs)

        # Extract frequency infomation, can be either specified with an initial and final frequency or a bandwidth combined with one of the two or the central frequency

        if "BW" in kwargs:
            BW = kwargs["BW"]
            if "init_freq" in kwargs:
                self.init_freq = Parameter("init_freq", kwargs["init_freq"], "GHz", "Initial frequency of pulse")
                self.final_freq = Parameter("final_freq", self.init_freq.value + BW, "GHz", "Final frequency of pulse")
            elif "final_freq" in kwargs:
                self.final_freq = Parameter("final_freq", kwargs["final_freq"], "GHz", "Final frequency of pulse")
                self.init_freq = Parameter("init_freq", self.final_freq.value - BW, "GHz", "Initial frequency of pulse")
            else:
                raise ValueError("Bandwidth must be combined with either an initial or final frequency")
        elif ("init_freq" in kwargs) & ("final_freq" in kwargs):
            self.init_freq = Parameter("init_freq", kwargs["init_freq"], "GHz", "Initial frequency of pulse")
            self.final_freq = Parameter("final_freq", kwargs["final_freq"], "GHz", "Final frequency of pulse")
        else:
            raise ValueError("Frequency information must be provided")
        
        

    @property
    def Qcrit(self):
        """ The critical Q factor for the pulse."""
        Qcrit = (2/np.pi)*np.log(2/(1+np.cos(self.flipangle.value)))
        Qcrit = np.min([Qcrit,5])
        return Parameter("Qcrit", Qcrit, None, "Critical Q factor for the pulse")
    
    @property
    def sweeprate(self):
        """ The sweep rate of the pulse in GHz/ns"""
        raise NotImplementedError("This property must be implemented in the subclass")

    @property
    def amp_factor(self):
        """ The B1 amplitude factor (nutation frequency) for the pulse in GHz"""
        amp_factor_value=  np.sqrt(2*np.pi*self.Qcrit.value*self.sweeprate.value)/(2*np.pi)
        return Parameter("amp_factor", amp_factor_value, "GHz", "Amplitude factor for the pulse")
    
    def flip(self):
        """Flips the sweep direction of the pulse"""
        temp = self.init_freq.value
        self.init_freq.value = self.final_freq.value
        self.final_freq.value = temp
        return self
        

class HSPulse(FrequencySweptPulse):
    """
    Represents a hyperboilc secant frequency-swept pulse.
    """
    def __init__(self, *, tp=128, order1=1, order2=6, beta=20, **kwargs) -> None:
        FrequencySweptPulse.__init__(self, tp=tp, name='HSPulse', **kwargs)
        self.order1 = Parameter(
            "order1", order1, None, "Order 1 of the HS Pulse")
        self.order2 = Parameter(
            "order2", order2, None, "Order 2 of the HS Pulse")
        self.beta = Parameter("beta", beta, None, "Beta of the HS Pulse")

        self._buildFMAM(self.func)
        pass


    def func(self, ax):
        beta = self.beta.value
        order1 = self.order1.value
        order2 = self.order2.value
        tp = ax.max() - ax.min()
        tcent = tp / 2
        ti = ax
        nx = ax.shape[0]
        # beta_exp1 = np.log(beta*0.5**(1-order1)) / np.log(beta)
        # beta_exp2 = np.log(beta*0.5**(1-order2)) / np.log(beta)
        # cut = round(nx/2)
        # AM = np.zeros(nx)
        # AM[0:cut] = 1/np.cosh(
        #     beta**beta_exp1 * (ax[0:cut]/tp)**order1)
        # AM[cut:-1] = 1/np.cosh(
        #     beta**beta_exp2 * (ax[cut:-1]/tp)**order2)

        # FM = BW * cumulative_trapezoid(AM**2,ax,initial=0) /\
        #      np.trapz(AM**2,ax) + self.init_freq.value
        sech = lambda x: 1/np.cosh(x) 
        cut = round(nx/2)
        AM = np.zeros_like(ti)
        AM[:cut] = sech(beta*0.5*(2*ti[:cut]/tp)**order1)
        AM[cut:] = sech(beta*0.5*(2*ti[cut:]/tp)**order2)


        BWinf = (self.bandwidth.value) / np.tanh(beta/2)

        freq = (BWinf/2) * np.tanh((beta/tp*ti)) + np.mean([self.init_freq.value,self.final_freq.value])
        # phase = 2*np.pi*(BWinf/2) * (tp/beta) * np.log(np.cosh((beta/tp)*ti))

        # total_phase = phase * 2* np.pi * np.mean([self.init_freq.value,self.final_freq.value])
        
        return AM, freq
    
    @property
    def sweeprate(self):
        """ The sweep rate of the pulse in GHz/ns"""
        sweeprate_value = self.beta.value * self.bandwidth.value / (2*self.tp.value)
        return Parameter("sweeprate", sweeprate_value, "GHz/ns", "Sweep rate of the pulse")

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

class ChirpPulse(FrequencySweptPulse):
    """
    Represents a linear frequency-swept pulse.
    """

    def __init__(self, *, tp=128, **kwargs) -> None:
        FrequencySweptPulse.__init__(self, tp=tp,name='ChirpPulse', **kwargs)

        self._buildFMAM(self.func)
        pass

    def func(self, ax):
        nx = ax.shape[0]
        AM = np.ones(nx)

        FM = np.linspace(
            self.init_freq.value, self.final_freq.value, nx)

        return AM, FM
    
    @property
    def sweeprate(self):
        """ The sweep rate of the pulse in GHz/ns"""
        sweeprate_value = self.bandwidth.value / self.tp.value
        return Parameter("sweeprate", sweeprate_value, "GHz/ns", "Sweep rate of the pulse")
# =============================================================================

class WURSTPulse(FrequencySweptPulse):
    """
    Represents a WURST frequency-swept pulse.

    Equations:

    The amplitude :math:`AM` and frequency modulation :math:`FM` of the WURST pulse are defined as follows:
    .. math::
        AM(t) = 1 - \left|\sin\left(\frac{\pi}{tp} \cdot t\right)\right|^{order}
    
    .. math::
        FM(t) = \frac{BW t}{tp} + init\_freq
    where :math:`t` is the time axis, :math:`tp` is the pulse length, :math:`BW` is the bandwidth, :math:`init_freq` is the initial frequency of the pulse, and :math:`order` is the order of the WURST pulse.

    Parameters
    ----------
    tp : int, optional
        Pulse length in ns, by default 128
    order : int, optional
        The order of the WURST pulse, by default 32
    **kwargs : dict, optional
        Additional keyword arguments to pass to the default pulse class.

    """

    def __init__(self, *, tp=128, order=32, **kwargs) -> None:
        FrequencySweptPulse.__init__(self, tp=tp,name='ChirpPulse', **kwargs)
        self.order = Parameter("Order", order, None, "The order of the WURST pulse")

        self._buildFMAM(self.func)
        pass

    def func(self, ax):
        nx = ax.shape[0]
        
        AM = np.ones(nx) - np.abs(np.sin((np.pi*ax/self.tp.value)))** self.order.value

        FM = np.linspace(
            self.init_freq.value, self.final_freq.value, nx)

        return AM, FM
    
    @property
    def sweeprate(self):
        """ The sweep rate of the pulse in GHz/ns"""
        sweeprate_value = self.bandwidth.value / self.tp.value
        return Parameter("sweeprate", sweeprate_value, "GHz/ns", "Sweep rate of the pulse")
# =============================================================================

class SincPulse(Pulse):


    def __init__(self, *, tp=128, freq=0, order=6, window=None, **kwargs) -> None:
        """    Represents a sinc shaped monochromatic pulse.


        Parameters
        ----------
        tp : int
            Pulse length in ns, by default 128
        freq : int, optional
            The frequency of the pulse, by default 0
        order : int, optional
            The order of this sinc function, by default 6
        window : _type_, optional
            The window function, by default None
        """

        Pulse.__init__(self, tp=tp,name='SincPulse', **kwargs)
        self.freq = Parameter("Freq", freq, "GHz", "Frequency of the Pulse")

        self.order = Parameter("Order", order, None, "The sinc pulse order")
        self.window = Parameter(
            "Window", window, None, "The type of window function")

        self._buildFMAM(self.func)
        pass


    def func(self, ax):
        nx = ax.shape[0]
        FM = np.zeros(nx) + self.freq.value
        AM = np.sinc(self.order.value * ax)

        return AM, FM
    
# class GaussianPulse(Pulse):
#     """
#     Represents a Gaussian monochromatic pulse.
#     """

#     def __init__(self, *, tp=128, freq=0, **kwargs) -> None:
#         """    Represents a Gaussian monochromatic pulse.


#         Parameters
#         ----------
#         tp : int
#             Pulse length in ns, by default 128
#         freq : int, optional
#             The frequency of the pulse, by default 0
#         """

#         Pulse.__init__(self, tp=tp,name='GaussianPulse', **kwargs)
#         self.freq = Parameter("Freq", freq, "GHz", "Frequency of the Pulse")

#         self._buildFMAM(self.func)
#         pass


#     def func(self, ax):
#         nx = ax.shape[0]
#         FM = np.zeros(nx) + self.freq.value
#         AM = np.exp(-ax**2/(((self.tp.value/2)**2)/(-1*np.log(0.001))))

#         return AM, FM

class CPMGDetection(Detection):
    """
    CPMGDetection Implements a Carr-Purcell-Meiboom-Gill (CPMG) detection sequence.

    """

    def __init__(self, *, tp, t=None, freq=0, n_echoes=1, echo_spacing=100, **kwargs) -> None:
        """A CPMG detection pulse.

        Parameters
        ----------
        tp : float
            The **total** time of each detection event. The detection event will
            be symetrical about the centre time. 
        t : float, optional
            The **centre** time of the first detection event
        freq: float, optional
            The detection frequency, not all spectrometer support this 
            functionality, by default 0 MHz
        n_echoes : int, optional
            Number of echoes in the CPMG sequence, by default 1
        echo_spacing : float, optional
            Spacing between echoes in ns, by default 100 ns
        """
        super().__init__(tp=tp, t=t, freq=freq, **kwargs)
        self.n_echoes = Parameter("n_echoes", n_echoes, None, "Number of echoes in the CPMG sequence")
        self.echo_spacing = Parameter("echo_spacing", echo_spacing, "ns", "Spacing between echoes in the CPMG sequence")

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

def build_default_pulses(AWG=True,SPFU=False,tp=12):
    if SPFU:
        tp_pi = tp*2
    else:
        tp_pi = tp

    exc_pulse = RectPulse(  
                    tp=tp, freq=0, flipangle=np.pi/2, scale=0
                )
    
    ref_pulse = RectPulse(  
                    tp=tp_pi, freq=0, flipangle=np.pi, scale=0
                        )
    
    if AWG:
        pump_pulse = HSPulse(  
                    tp=120, init_freq=-0.25, final_freq=-0.03, flipangle=np.pi, scale=0,
                        order1=6, order2=1, beta=10
                    )
        det_event = Detection(tp=512, freq=0)
    else:
        pump_pulse = RectPulse(
                    tp=tp_pi, freq=-0.07, flipangle=np.pi, scale=0)
        
        # det_event = Detection(tp=exc_pulse.tp * 2, freq=0)
        det_event = Detection(tp=128, freq=0)
        
    pulses = {'exc_pulse':exc_pulse, 'ref_pulse':ref_pulse, 'pump_pulse':pump_pulse, 'det_event':det_event}

    return pulses