### GLAZER FITTING: Explicit refinement of Glazer modes in a PDF of CaTiO3

```python
# Import packages that we will need.
import os
import yaml
import collections

import matplotlib as mpl
import matplotlib.pyplot as plt
mpl.rcParams.update(mpl.rcParamsDefault)
plt.style.use(os.path.join("utils",
                            "billinge.mplstyle"))

from diffpy.srfit.fitbase import FitContribution, FitRecipe, FitResults, Profile, FitHook
from diffpy.srfit.fitbase.fitresults import initializeRecipe
from diffpy.srfit.structure import constrainAsSpaceGroup
from diffpy.srfit.pdf import PDFParser, PDFGenerator
from diffpy.structure import Structure
from scipy.optimize import least_squares

import numpy as np
from sympy import *
np.seterr(invalid='ignore')

%matplotlib inline
```

```python
def create_tilt_dict(glazer_system, glazer_tilts):
    """Constructs and initializes the Glazer tilt parameter dictionary
    Constructs the Glazer tilts amplitudes and correct number of
    variables by checking the degrees of freedom of the Glazer
    system and using amplitudes for the glazer tilts around each axis.

    Parameters
    ----------
    glazer_system : integer
       the number of the Glazer system.
    glazer_tilts :       dict
       contains the three glazer tilt variables alpha_i, beta_i and gamma_i
       and their start values

    Returns
    ----------
    tilt_values :   dict,
       the values of the three tilts, alpha_i, beta_i and gamma_i
    """

    a = 0
    b = 0
    c = 0

    alpha_i = list(glazer_tilts.values())[0]
    beta_i = list(glazer_tilts.values())[1]
    gamma_i = list(glazer_tilts.values())[2]

    # Checking for number of active tilt variables depending on the Glazer system:
    if glazer_system in [22, 21, 20, 16, 14, 11, 7, 3]:
        print('This Glazer system has one tilt degree of freedom. \nPlease make sure you have set alpha_i')
        a = alpha_i
    if glazer_system in [19, 18, 17, 15, 13, 10, 9, 6, 5, 2]:
        print('This Glazer system has two tilt degrees of freedom. \nPlease make sure you have set alpha_i and beta_i.')
        a = alpha_i
        b = beta_i
    if glazer_system in [12, 4, 1]:
        print('This Glazer system has three tilt degrees of freedom. \nPlease make sure you have set alpha_i, beta_i, GAMMA_I.')
        a = alpha_i
        b = beta_i
        c = gamma_i

    tilts_dict = {23: {'alpha': 0, 'beta': 0, 'gamma': 0}, 22: {'alpha': 0, 'beta': 0, 'gamma': -a},
               21: {'alpha': 0, 'beta': 0, 'gamma': a}, 20: {'alpha': 0, 'beta': -a, 'gamma': -a},
               19: {'alpha': 0, 'beta': -a, 'gamma': -b}, 18: {'alpha': 0, 'beta': a, 'gamma': -b},
               17: {'alpha': 0, 'beta': a, 'gamma': -b}, 16: {'alpha': 0, 'beta': a, 'gamma': a},
               15: {'alpha': 0, 'beta': a, 'gamma': b}, 14: {'alpha': -a, 'beta': -a, 'gamma': -a},
               13: {'alpha': -a, 'beta': -a, 'gamma': -b}, 12: {'alpha': -a, 'beta': -b, 'gamma': -c},
               11: {'alpha': a, 'beta': -a, 'gamma': -a}, 10: {'alpha': a, 'beta': -b, 'gamma': -b},
               9: {'alpha': a, 'beta': -a, 'gamma': -b}, 8: {'alpha': a, 'beta': -b, 'gamma': -c},
               7: {'alpha': a, 'beta': a, 'gamma': -a}, 6: {'alpha': a, 'beta': b, 'gamma': -b},
               5: {'alpha': a, 'beta': a, 'gamma': -b}, 4: {'alpha': a, 'beta': b, 'gamma': -c},
               3: {'alpha': a, 'beta': a, 'gamma': a}, 2: {'alpha': a, 'beta': b, 'gamma': b},
               1: {'alpha': a, 'beta': b, 'gamma': c}}

    try:
        tilt_values = tilts_dict[glazer_system]
        print('Refinement will start with values {}. \nPositive values signify in-phase tilt, negative values signify out-of-phase tilt.\n'.format(list(tilt_values.values())))
    except:
        print('Found no start values for the the Glazer tilt amplitudes (alpha_i, beta_i and/or GAMMA_I)')
    return tilt_values

```

```python
def create_Aconstraints(glazer_system, adisp):
	""" Constructs and initializes constraints, variables and  start values for the A sites.
	Constructs and initializes constraint equations, list of variables
	and their start values for the A sites based on the glazer system
	ID and displacements of the A cations from their high-symmetry
	positions.

    Parameters
    ----------
    glazer_system : integer
       the number of the Glazer system.
	adisp : dict
	   contains the variables for displacement along different axes and
	   their initial values.

    Returns
    ----------
    acons :   8x3 matrix of strings
	   gives the constraint equations for the atomic positions
	avars :   list of strings
	   variables used in the constraints equations
	adispvals_i :  list of floats
	   initial values for A cation displacements
    """

	print('Setting up constraints and variables for the A cations in Glazer system {}.'.format(glazer_system))

	if glazer_system == 10:
		# Variables for the A sites
		avars = list(adisp.keys())
		avars = [v.split('_')[0] for v in avars]
		# Constraints for the eight A sites.
		acons = [[None, '0.25+{}'.format(avars[0]), '0.25+{}'.format(avars[1])],
 				[None, '0.25+{}'.format(avars[0]), '0.75+{}'.format(avars[1])],
 				[None, '0.75+{}'.format(avars[0]), '0.25+{}'.format(avars[1])],
 				[None, '0.75+{}'.format(avars[0]), '0.75+{}'.format(avars[1])],
 				[None, '0.25-{}'.format(avars[0]), '0.25-{}'.format(avars[1])],
 				[None, '0.25-{}'.format(avars[0]), '0.75-{}'.format(avars[1])],
 				[None, '0.75-{}'.format(avars[0]), '0.25-{}'.format(avars[1])],
 				[None, '0.75-{}'.format(avars[0]), '0.75-{}'.format(avars[1])]]

		adispvals_i = list(adisp.values())
		print('Refinement will start with values {}.\n'.format(adispvals_i))

	else:
		print('You have chosen Glazer system {}. A cation constraints for this system are yet undefined.\n'.format(glazer_system))

	return acons, avars, adispvals_i
```

```python
"""
Here we define the rotation/tilt patterns of all eight octahedra in a 2x2x2
supercell of the perovskite structure. alpha, beta and gamma are the
tilts around x, y and z respectively. IP and OOP denotes whether adjacent
layers of octahedra in the tilt pattern tilt in-phase or out-of-phase.
A clockwise or anti-clockwise tilt is given for each of the 8 octahedra.
The octahedra are labeled according to the position of their center B site.
"""
rotation_pattern = {

'alpha': {'IP': {'clockwise': [[0, 0, 0], [0, 0.5, 0.5], [0.5, 0, 0], [0.5, 0.5, 0.5]],
                 'anti-clockwise': [[0, 0, 0.5], [0, 0.5, 0], [0.5, 0, 0.5], [0.5, 0.5, 0]]},
         'OOP': {'clockwise': [[0, 0, 0], [0, 0.5, 0.5], [0.5, 0, 0.5], [0.5, 0.5, 0]],
                 'anti-clockwise': [[0, 0, 0.5], [0, 0.5, 0], [0.5, 0, 0], [0.5, 0.5, 0.5]]}},

'beta': {'IP': {'clockwise': [[0, 0, 0], [0.5, 0, 0.5], [0, 0.5, 0], [0.5, 0.5, 0.5]],
                'anti-clockwise': [[0.5, 0, 0], [0.5, 0.5, 0], [0, 0, 0.5], [0, 0.5, 0.5]]},
        'OOP': {'clockwise': [[0, 0, 0], [0.5, 0, 0.5], [0.5, 0.5, 0], [0, 0.5, 0.5]],
                'anti-clockwise': [[0.5, 0, 0], [0, 0, 0.5], [0, 0.5, 0], [0.5, 0.5, 0.5]]}},

'gamma': {'IP': {'clockwise': [[0, 0, 0], [0.5, 0.5, 0], [0, 0, 0.5], [0.5, 0.5, 0.5]],
                 'anti-clockwise': [[0.5, 0, 0], [0, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5]]},
         'OOP': {'clockwise': [[0, 0, 0], [0.5, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5]],
                 'anti-clockwise': [[0.5, 0, 0], [0, 0.5, 0], [0, 0, 0.5], [0.5, 0.5, 0.5]]}}
}
```

```python
def rotate_octahedron(octahedron, tilt_values):
    """Rotates the octahedron around the three cartesian axes
    Takes in the atomic positions of the B atom and X atoms in an octahedron and a
    dict of tilt values and returns the displacement constraints
    for the X atoms of the octahedron as symbolic expressions and a list of
    Glazer variables to be refined.

    Parameters
    ----------
    octahedron :    4x3 matrix of atomic positions (floats)
    tilt_values :   dict of tilt labels and values.

    Returns
    ----------
    Xs_cons :        3x3 matrix, symbolic expressions, constraints for the x,y and z oxygen positions
    Glazer_vars :    list, sympy variables for alpha, beta and/or gamma, depinding on the Glazer system
    """
    # Make empty dictionaries for the amplitudes (amp), the indicators for
    # in-phase or out-of-phase tilt around the three axes (pattern_indicator)
    # sign for clockwise or anti-clockwise rotation for each octahedron (sign)
    # and the constraints equations for the X atoms as symbolic expressions.
    amp = {}
    pattern_indicator = {}
    sign = {}
    Xs_disp = {}

    # Define the cartesian axes
    axes = np.array([[1,0,0], [0,1,0], [0,0,1]])

    # Fetch initial position for pivot atom (B) and the three oxygens (X):
    B = octahedron[0]
    Xs = octahedron[1:4]
    # Shift the octahedron to the origin:
    Xs_rel = Xs - B

    # define active variables
    glazer_vars = [Symbol('alpha_var'), Symbol('beta_var'), Symbol('gamma_var')]
    amps = tilt_values['alpha'], tilt_values['beta'], tilt_values['gamma']
    for i in [1,2]:
        # if amplitudes are the same around two different axes
        if abs(amps[i]) == abs(amps[i-1]):
            # give the same tilt variable (alpha, beta or gamma) for those axes
            glazer_vars[i] = glazer_vars[i-1]

    for i in range(len(glazer_vars)):
        if amps[i] == 0:
            glazer_vars[i] = 0

    # Go through all the Glazer amplitudes, alpha, beta and gamma, and
    # fetch relevant rotation/tilt pattern.
    i = 0
    for key in tilt_values.keys():
        amp[key] = np.abs(float(tilt_values[key]))
        pattern_indicator[key] = np.sign(tilt_values[key])
        if amp[key] > 0:
            if pattern_indicator[key] > 0:
                typ = 'IP'
            elif pattern_indicator[key] < 0:
                typ = 'OOP'

            # set sign of rotation for the current octahedron
            if list(B) in rotation_pattern[key][typ]['clockwise']:
                sign[key] = +1
            elif list(B) in rotation_pattern[key][typ]['anti-clockwise']:
                sign[key] = -1

        elif amp[key] == 0:
            sign[key] = 0

        # Define displacement from cubic position around axis
        R = axes[i]
        Xs_disp[key] = symarray('', len(Xs))
        for j in range(len(Xs_rel)):
            x = Xs_rel[j]
            disp_dir = Matrix(np.nan_to_num(np.cross(R, x) / np.linalg.norm(np.cross(R, x))))
            x_scaled = Matrix(x).multiply(cos(glazer_vars[i]/180*pi)) # length of vector*cosine
            x_scaled_len = sqrt((x_scaled[0])**2 + (x_scaled[1])**2 + (x_scaled[2])**2)
            disp_amp = x_scaled_len * tan(sign[key]*glazer_vars[i]/180*pi)
            x_disp = disp_dir * disp_amp
            Xs_disp[key][j] = x_disp

        i = i+1

    # Combining all relative displacements around all three axes
    Xs_disp_all = Xs_disp['alpha'] + Xs_disp['beta'] + Xs_disp['gamma']

    # Create constraints equations for all oxygens by adding the old positions
    Xs_cons = symarray('', len(Xs))
    for i in range(len(Xs)):
        Xs_cons[i] = Xs_disp_all[i] + Matrix(Xs[i])

    return Xs_cons, glazer_vars
```

```python
def create_lattice_parameters_constraints(octahedron_disp, latpar_a, nx, ny, nz):
    """Creates constraints for the lattice parameters
    Creates a set of constraints for the for the lattice parameters using
    the constraints for the positions of the atoms in one octahedron,
    the cubic perovskite lattice parameter and the supercell dimensions
    nx, ny, nz.

    Parameters
    ----------
    octahedron_disp :  4x3 matrix of symbolic expressions
       atomic positions of any octahedron
    latpar_a :            sympy variable
       cubic lattice parameter
    nx, ny, nz :       int x 3
       dimensions of the supercell

    Returns
    ----------
    a_new :     symbolic expression
       constraint equation for the a lattice parameter
    b_new :     symbolic expression
       constraint equation for the b lattice parameter
    c_new :     symbolic expression
       constraint equation for the c lattice parameter
    """

    print('Setting up constraints for the lattice parameters using the Glazer tilt variables.\n')

    # Breaking up the octahedron in the pivot atom and three oxygens:
    B = octahedron_disp[0]
    X1 = octahedron_disp[1]
    X2 = octahedron_disp[2]
    X3 = octahedron_disp[3]

    # Shifting the octahedron to the origin:
    O1x, O1y, O1z = X1 - B
    O2x, O2y, O2z = X2 - B
    O3x, O3y, O3z = X3 - B

    # Using the solutions from evaluating the compression of the supercell,
    # define scaling factors for the lattice parameters. See paper for details
    # on derivation of these expressions.
    a_num = 1 - 256*O2x**2 *(O1y**2 -16*O3y**2 *O1z**2) - 256*(O3y**2 *O2z**2 + O3x**2 *(O1z**2 - 16*O1y**2 *O2z**2))
    a_den = 1 - 16*O1y**2 + 16*(-1+16*O3y**2)*O1z**2 + 256*(O1y**2-O3y**2)*O2z**2
    scale_a = sqrt(a_num/a_den)

    b_num = 1 - 256*O2x**2 *(O1y**2 -16*O3y**2 *O1z**2) - 256*(O3y**2 *O2z**2 + O3x**2 *(O1z**2 - 16*O1y**2 *O2z**2))
    b_den = 1 + 16*O2x**2 *(-1 + 16*O1z**2) - 16*O2z**2 + 256*O3x**2 *(-O1z**2 + O2z**2)
    scale_b = sqrt(b_num/b_den)

    c_num = -1 + 256*O2x**2 *(O1y**2 -16*O3y**2 *O1z**2) + 256*(O3y**2 *O2z**2 + O3x**2 *(O1z**2 - 16*O1y**2 *O2z**2))
    c_den = -1 + O3x**2 *(16-256*O1y**2) + 256*O2x**2 *(O1y**2 - O3y**2) + 16*O3y**2
    scale_c = sqrt(c_num/c_den)

    # Divide the cubic lattice parameters with the scaling factors for each axis:
    a = Symbol(latpar_a)
    a_new = a*nx/scale_a
    b_new = a*ny/scale_b
    c_new = a*nz/scale_c

    return a_new, b_new, c_new
```

```python
# Test for creating all the constraints
octa = np.array([[0.0, 0.5, 0.0],
                [0.25, 0.5, 0.0],
                [0.0, 0.75, 0.],
                [0.0, 0.5, 0.25]])
glazerID = 10
glazer_tilts = {'alpha_i': 9, 'beta_i': 10, 'gamma_i': None}
adisp = {'aydisp_i': 0.02, 'azdisp_i': 0.01}

a_cons, a_vars, a_dispvals_i = create_Aconstraints(glazerID, adisp)
print("Constraints for the A cation positions: \n {}\n".format(a_cons))
tilt_values = create_tilt_dict(glazerID, glazer_tilts)

Xs_new_test, variables_active = rotate_octahedron(octa, tilt_values)
print("Constraints for the new oxygen positions expressed as a function of Glazer tilts: \n {}\n".format(Xs_new_test))

octa_disp = Matrix(octa[0]), Xs_new_test[0], Xs_new_test[1], Xs_new_test[2]
a_new_test, b_new_test, c_new_test = create_lattice_parameters_constraints(octa_disp, 'a_cubic', 2, 2, 2)
print("Constraints for the new lattice parameters expressed as a function of Glazer tilts: \na: {},\nb: {},\nc: {}".format(a_new_test, b_new_test, c_new_test))
```

    Setting up constraints and variables for the A cations in Glazer system 10.
    Refinement will start with values [0.02, 0.01].

    Constraints for the A cation positions:
     [[None, '0.25+aydisp', '0.25+azdisp'], [None, '0.25+aydisp', '0.75+azdisp'], [None, '0.75+aydisp', '0.25+azdisp'], [None, '0.75+aydisp', '0.75+azdisp'], [None, '0.25-aydisp', '0.25-azdisp'], [None, '0.25-aydisp', '0.75-azdisp'], [None, '0.75-aydisp', '0.25-azdisp'], [None, '0.75-aydisp', '0.75-azdisp']]

    This Glazer system has two tilt degrees of freedom.
    Please make sure you have set alpha_i and beta_i.
    Refinement will start with values [9, -10, -10].
    Positive values signify in-phase tilt, negative values signify out-of-phase tilt.

    Constraints for the new oxygen positions expressed as a function of Glazer tilts:
     [Matrix([
     [                                                          0.25],
     [-0.25*sqrt(cos(pi*beta_var/180)**2)*tan(pi*beta_var/180) + 0.5],
     [       0.25*sqrt(cos(pi*beta_var/180)**2)*tan(pi*beta_var/180)]])
     Matrix([
     [   0.25*sqrt(cos(pi*beta_var/180)**2)*tan(pi*beta_var/180)],
     [                                                      0.75],
     [-0.25*sqrt(cos(pi*alpha_var/180)**2)*tan(pi*alpha_var/180)]])
     Matrix([
     [       -0.25*sqrt(cos(pi*beta_var/180)**2)*tan(pi*beta_var/180)],
     [0.25*sqrt(cos(pi*alpha_var/180)**2)*tan(pi*alpha_var/180) + 0.5],
     [                                                           0.25]])]

    Setting up constraints for the lattice parameters using the Glazer tilt variables.

    Constraints for the new lattice parameters expressed as a function of Glazer tilts:
    a: 2*a_cubic/sqrt((-32.0*(-0.0625*cos(pi*alpha_var/180)**2*cos(pi*beta_var/180)**2*tan(pi*alpha_var/180)**2*tan(pi*beta_var/180)**2 + 0.0625*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2)*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2 - cos(pi*alpha_var/180)**4*tan(pi*alpha_var/180)**4 + 1)/(0.0625*(-16.0*cos(pi*alpha_var/180)**2*tan(pi*alpha_var/180)**2 + 16.0*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2)*cos(pi*alpha_var/180)**2*tan(pi*alpha_var/180)**2 + 0.0625*(16.0*cos(pi*alpha_var/180)**2*tan(pi*alpha_var/180)**2 - 16)*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2 - 1.0*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2 + 1)),
    b: 2*a_cubic/sqrt((-32.0*(-0.0625*cos(pi*alpha_var/180)**2*cos(pi*beta_var/180)**2*tan(pi*alpha_var/180)**2*tan(pi*beta_var/180)**2 + 0.0625*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2)*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2 - cos(pi*alpha_var/180)**4*tan(pi*alpha_var/180)**4 + 1)/(16.0*(0.0625*cos(pi*alpha_var/180)**2*tan(pi*alpha_var/180)**2 - 0.0625*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2)*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2 + 1.0*(1.0*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2 - 1)*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2 - 1.0*cos(pi*alpha_var/180)**2*tan(pi*alpha_var/180)**2 + 1)),
    c: 2*a_cubic/sqrt((32.0*(-0.0625*cos(pi*alpha_var/180)**2*cos(pi*beta_var/180)**2*tan(pi*alpha_var/180)**2*tan(pi*beta_var/180)**2 + 0.0625*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2)*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2 + cos(pi*alpha_var/180)**4*tan(pi*alpha_var/180)**4 - 1)/(16.0*(-0.0625*cos(pi*alpha_var/180)**2*tan(pi*alpha_var/180)**2 + 0.0625*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2)*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2 + 0.0625*(-16.0*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2 + 16)*cos(pi*beta_var/180)**2*tan(pi*beta_var/180)**2 + 1.0*cos(pi*alpha_var/180)**2*tan(pi*alpha_var/180)**2 - 1))

```python
def plotresults(recipe, resfithook, figname):
    """Plots fit results.
    Creates plots of the fitted PDF and residual, displays them, and
    then writes them to disk as *.pdf files.

    Parameters
    ----------
    recipe :     Fit Recipe object
       contains the optimized calculated PDF data we wish to plot
    resfithook : Diffpy recipe object
       contains the optimized variables and goodness of fit for each iteration.
    figname :    string
       the location and name of the figure file to create

    Returns
    ----------
    None
    """
    r = recipe.crystal.profile.x

    g = recipe.crystal.profile.y
    gcalc = recipe.crystal.profile.ycalc
    diffzero = -0.65 * max(g) * np.ones_like(g)
    diff = g - gcalc + diffzero

    fig, ax = plt.subplots(2, 1)

    ax[0].plot(r, g, ls="None",
             marker="o", ms=3, mew=0.2,
             mfc="None", label="$G(r)$ Data")
    ax[0].plot(r, gcalc, lw=1.3, label="$G(r)$ Fit")
    ax[0].plot(r, diff, lw=1.2, label="$G(r)$ diff")
    ax[0].plot(r, diffzero, lw=1.0, ls="--", c="black")
    ax[0].set_xlabel("$r$ [$\mathrm{\AA}]$")
    ax[0].set_ylabel("$G$ [$\mathrm{\AA}$$^{-2}$]")
    ax[0].set_title("Observed PDF and calculated PDF from Glazer model")
    ax[0].tick_params(axis="both",
                    which="major",
                    top=True,
                    right=True)
    ax[0].set_xlim(rmin, rmax)
    ax[0].legend(loc=0, ncol=3)

    ax[1].plot(resfithook.rws, lw=1.0, marker='o', c="black")
    ax[1].set_xlabel("iteration")
    ax[1].set_ylabel(r"$R_w$")
    ax[1].set_title(r"Progression of $R_w$ through the refinement")
    ax[1].tick_params(axis="both",
                    which="major",
                    top=True,
                    right=True)

    plt.tight_layout()
    plt.show()
    fig.savefig(figname + ".pdf", format="pdf")

    # End of function
```

```python
def makerecipe(model, gr_path, rmin, rmax, rstep, qmin, qmax, element_a, element_b,
               element_x, scale_i, delta2_i, latpar_a_cub_i, uiso_a_i, uiso_b_i, uiso_x_i):
    """Creates and returns a Fit Recipe object.
    Makes the recipe that the fit will follow using the PDF data,
    the model structure, fit specifications and initial values of
    the variables to be refined. The constraints and variables
    necessary to run the fit are created using separate functions.

    Parameters
    ----------
    model :           diffpy structure object
       contains the structure model used to fit the PDF data
    gr_path :         string
       the path to the PDF data to be fit
    rmin :            float
       lower boundary of r range for fitting
    rmax :            float
       higher boundary of r range for fitting
    rstep :           float
       step size of increments in r
    qmin :            float
       lower boundary of Q space used in the Fourier transform to G(r)
    qmax :            float
       higher boundary of Q space used in the Fourier transform to G(r)
    element_a :       string
       A cation element in the perovskite
    element_b :       string
       B cation element in the perovskite
    element_x :       string
       X anion element in the perovskite
    scale_i :         float
       initial scaling factor of the calculated PDF
    delta2_i :        float
       initial nearest-neighbor correlation
    latpar_a_cub_i :  float
       initial cubic lattice parameter
    uiso_a :          float
       initial temperature parameter Uiso value for the A cation
    uiso_b :          float
       initial temperature parameter Uiso value for the B cation
    uiso_x :          float
       initial temperature parameter Uiso value for the X anion

    Returns
    ----------
    fitrecipe : Fit Recipe object
       Contains information about the data, model, variables to be refined,
       initial values and constraints for all relevant parameters.
    """

    ########################### Profile Section ###################################
    # Create a Profile object for the experimental dataset. This handles all details
    # about the dataset. We also tell this profile the range and mesh of points in
    # r-space.

    profile = Profile()
    parser = PDFParser()
    parser.parseFile(gr_path)
    profile.loadParsedData(parser)
    profile.setCalculationRange(xmin=rmin, xmax=rmax, dx=rstep)

    ########################## PDF Generator Section ##############################
    # Create a PDF Generator object for a periodic structure model. Here we name it
    # "G1" and we give it the structure object. This Generator will later compute
    # the model PDF for the structure object we provide it here.
    generator_crystal = PDFGenerator("G1")
    generator_crystal.setStructure(model, periodic=True)

    # Initialize the instrument parameters, Q_damp and Q_broad, and assign Q_max and
    # Q_min, all part of the PDF Generator object. It's possible that the PDFParse
    # function we used above already parsed out ths information, but in case it
    # didn't, we set it explicitly again here. All parameter objects can have their
    # value assigned using the below ".value = " syntax.
    generator_crystal.qdamp.value = qdamp
    generator_crystal.qbroad.value = qbroad
    generator_crystal.setQmax(qmax)
    generator_crystal.setQmin(qmin)

    # If you have a multi-core computer (you probably do), you can run your
    # refinement in parallel! This requires that you set "RUN_PARALLEL" to "True"
    # above. The psutil python package is also required for the bit of code below,
    # where we make sure not to overload your CPUs.
    if RUN_PARALLEL:
        import psutil
        import multiprocessing
        syst_cores = multiprocessing.cpu_count()
        cpu_percent = psutil.cpu_percent()
        avail_cores = np.floor((100 - cpu_percent) / (100.0 / syst_cores))
        ncpu = int(np.max([1, avail_cores]))
        generator_crystal.parallel(ncpu)

    ######################### Fit Contribution Section ############################
    # Create a Fit Contribution object, and name it "crystal." We then give the PDF
    # Generator object we created just above to this Fit Contribution object.
    # The Fit Contribution holds the equation used to fit the PDF.
    contribution = FitContribution("crystal")
    contribution.addProfileGenerator(generator_crystal)

    # Set an equation, within the Fit Contribution, based on your PDF Generators.
    # Here we simply have one Generator, G1, and a scale variable, s1.
    # Using this structure is a very flexible way of adding additional
    # Generators (ie. multiple structural phases), experimental Profiles,
    # PDF characteristic functions (ie. shape envelopes), and more.
    contribution.setEquation("s1*G1")

    # Set the experimental profile, within the Fit Contribution object, to the
    # Profile object we created earlier.
    contribution.setProfile(profile, xname="r")

    ######################### Recipe Section ######################################
    # Create the Fit Recipe object that holds all the details of the fit, defined in
    # the lines above. We give the Fit Recipe the Fit Contribution we created earlier.
    recipe = FitRecipe()
    recipe.addContribution(contribution)

    # Configure some additional fit variables pertaining to symmetry. We can use the
    # srfit function constrainAsSpaceGroup to constrain the lattice and ADP
    # parameters. We are not using symmetry constraints in this code, but we still
    # initialize a P1 symmetry so that we can use the parameters generated to create
    # our own constraints.
    # First we establish the relevant parameters, splitting the structure into its
    # elemental sublattices, A, B and X then we cycle through the parameters and
    # activate and tag them. This gives us the flexibility to use symmetry constraints
    # for the A cations will creating our Glazer tilt constraints manually. We must
    # explicitly set the ADP parameters, because in this case, the CIF has no ADP data.
    # Currently, the A atoms are also constrained manually, so we initialize P1 for all
    # atoms.

    #Get the atoms:
    atoms = generator_crystal.phase.getScatterers()
    # Sorting the atoms by element. Based on a 2x2x2 perovskite structure.
    A_atoms = atoms[0:8]
    X_atoms = atoms[16:]

    spacegroupparams_A = constrainAsSpaceGroup(generator_crystal.phase,
                                             'P1', scatterers=A_atoms,
                                              constrainlat=False)
    spacegroupparams_X = constrainAsSpaceGroup(generator_crystal.phase,
                                             'P1', scatterers=X_atoms)

    # Create dicts of position and lists of indices for all three atom types.
    A_dict, B_dict, X_dict = {}, {}, {}
    A_index, B_index, X_index  = [], [], []
    for i,atom in enumerate(atoms):
        if atom.element == element_x:
            X_index.append(i)
            X_dict[str(i)] = {atom.element: [atom.x.getValue(), atom.y.getValue(), atom.z.getValue()]}
        elif atom.element == element_b:
            B_index.append(i)
            B_dict[str(i)] = {atom.element: [atom.x.getValue(), atom.y.getValue(), atom.z.getValue()]}
        elif atom.element == element_a:
            A_index.append(i)
            A_dict[str(i)] = {atom.element: [atom.x.getValue(), atom.y.getValue(), atom.z.getValue()]}

    # Picking out the indices that describe the three types of anions. Keep in
    # mind that the numbers depend on the order of atoms in the loaded cif.
    x1_index = X_index[0*8:8*1]
    x2_index = X_index[1*8:8*2]
    x3_index = X_index[2*8:8*3]

    # Create dict for indices and xyzpars of oxygen spacegroup params
    index_xyzpars_X_dict = {}
    for par in spacegroupparams_X.xyzpars:
        atom_index = int(par.name.split("_")[-1]) +16
        if not atom_index in index_xyzpars_X_dict.keys():
            index_xyzpars_X_dict[atom_index] = [par]
        elif atom_index in index_xyzpars_X_dict.keys():
            index_xyzpars_X_dict[atom_index].append(par)

    index_xyzpars_A_dict = {}
    for par in spacegroupparams_A.xyzpars:
        atom_index = int(par.name.split("_")[-1])
        if not atom_index in index_xyzpars_A_dict.keys():
            index_xyzpars_A_dict[atom_index] = [par]
        elif atom_index in index_xyzpars_A_dict.keys():
            index_xyzpars_A_dict[atom_index].append(par)

    ######################## VARIABLES and CONSTRAINTS #################################
    ####################################################################################
    # Now that we have the parameters, we can set up the structure variables that are
    # to be refined, and the values they are to be initialized with.

    # Add a variable to the Fit Recipe object, initialize the variables with some value,
    # and tag it with a string. Here we add the scale parameter from the Fit Contribution,
    # delta and instrumental parameters. The ".addVar" method can be used generally to add
    # variables to the Fit Recipe.
    recipe.addVar(contribution.s1, scale_i, tag="scale")
    recipe.addVar(generator_crystal.delta2, name="Delta2", value=delta2_i, tag="d2")
    recipe.addVar(generator_crystal.qdamp, name="qdamp", value=qdamp, tag="qdamp")
    recipe.addVar(generator_crystal.qbroad, name="qbroad", value=qbroad, tag="qbroad")

    # For the atom positions, we have to create new variables, since we are not using
    # the parameters that come out of the ConstrainAsSpacegroup directly. Then we can
    # link these new variables to the parameters. Note that the variable names given
    # here are the same as those used in the A_con list, as initialized in cell 3.

    # Variables, start values and constraints for A cations
    A_cons, A_vars, A_dispvals_i = create_Aconstraints(glazer_system, adisp)
    for Avar, AvarI in zip(A_vars, A_dispvals_i):
        recipe.newVar(Avar, AvarI, tag=Avar)

    for i in range(len(A_cons)):
        for j in range(3):
            A_par = index_xyzpars_A_dict[i][j]
            A_con = A_cons[i][j]
            if A_con != None:
                recipe.constrain(A_par, "{}".format(A_con))

    # Initialize variables for the cubic lattice parameter
    recipe.newVar("latpar_a_cub_var", latpar_a_cub_i, tag="latpar_a_cub")

    # Go through all the octahedra and fetch variable expressions for the constraints
    # Use the first octahedron to initizalize the glazer variables

    tilt_values = create_tilt_dict(glazer_system, glazer_tilts)

    print('Setting up constraints for the oxygens using the Glazer tilt variables.\n')
    for n in range(len(B_index)):
        # Find the atoms in the unit and define vectors between the X's and
        # B cation, setting B as a reference point around which to rotate:
        B_pos = B_dict[str(B_index[n])][element_b]
        X1_pos = X_dict[str(x1_index[n])][element_x]
        X2_pos = X_dict[str(x2_index[n])][element_x]
        X3_pos = X_dict[str(x3_index[n])][element_x]
        octahedron = np.array([B_pos, X1_pos, X2_pos, X3_pos])
        Xs_con, glazer_vars = rotate_octahedron(octahedron, tilt_values)

        # Create variables for the active glazer tilt variables:
        if n == 0:
            glazer_vars = [var for var in glazer_vars if var != 0]
            i = 0
            l = []
            for v in glazer_vars:
                if v not in l:
                    recipe.newVar("{}".format(v), list(tilt_values.values())[i], fixed=False, tag="{}".format(v))
                    l.append(v)
                i = i + 1

        # Loop through all x, y and z components and add constraints
        for i in range(3):
            X1_par = index_xyzpars_X_dict[x1_index[n]][i]
            X2_par = index_xyzpars_X_dict[x2_index[n]][i]
            X3_par = index_xyzpars_X_dict[x3_index[n]][i]

            X1_con = Xs_con[0][i]
            X2_con = Xs_con[1][i]
            X3_con = Xs_con[2][i]

            recipe.constrain(X1_par, "{}".format(X1_con))
            recipe.constrain(X2_par, "{}".format(X2_con))
            recipe.constrain(X3_par, "{}".format(X3_con))

    # Set up constraints for the lattice parameters based on the refinable variables for
    # the cubic lattice parameter latpar_a_cub_var and the glazer tilt amplitudes relevant for the mode
    B = B_dict[str(B_index[-1])][element_b]
    octa_disp = Matrix(B), Xs_con[0], Xs_con[1], Xs_con[2]
    a_con, b_con, c_con = create_lattice_parameters_constraints(octa_disp, 'latpar_a_cub_var', 2, 2, 2)

    for par in spacegroupparams_X.latpars:
        if par.name == "a":
            recipe.constrain(par, "{}".format(a_con))
        elif par.name == "b":
            recipe.constrain(par, "{}".format(b_con))
        elif par.name == "c":
            recipe.constrain(par, "{}".format(c_con))

    # Add Uiso parameter for each element.
    elements = collections.Counter([atom.element for atom in atoms]).keys()
    dct = {}
    for element in elements:
        if element == element_a:
            var = recipe.newVar(f'Uiso_{element}', value=uiso_a_i, tag=f"Uiso_{element}")
        elif element == element_b:
            var = recipe.newVar(f'Uiso_{element}', value=uiso_b_i, tag=f"Uiso_{element}")
        elif element == element_x:
            var = recipe.newVar(f'Uiso_{element}', value=uiso_x_i, tag=f"Uiso_{element}")
        dct[element] = var
    for atom in atoms:
        recipe.constrain(atom.Uiso, dct.get(atom.element))

    # Return the Fit Recipe object to be optimized
    return recipe

    # End of function

```

```python
class ResidualFitHook(FitHook):

    def __init__(self):
        super(ResidualFitHook, self).__init__()
        self.rws = []

    def reset(self, recipe):
        self.rws = []

    def postcall(self, recipe, chiv):
        recipe: FitRecipe
        fc: FitContribution = next(iter(recipe._contributions.values()))
        rw = np.sqrt(np.sum(np.power(chiv, 2)) / np.sum(np.power(fc.profile.y, 2)))
        self.rws.append(rw)
```

```python
def main():
    """Main function for the refinement.
    Fetches data and model, sets up output directories, calls on the
    recipe function to make fit recipe and performs the refinement by
    gradually freeing variables. Saves the refinement results and plots
    the final fit and the goodness of fit (rw) as a function of number
    of refinement iterations.

    Returns
    ----------
    """

    # Create a name for the fit based on the compound, the chosen Glazer system
    # and the r ranges being fit over
    fit_name = "{}_GS_{}_r_{}-{}".format(compound, glazer_system, rmin, rmax)

    # Make some folders to store our output files.
    resdir = "output/res"  # Contains results (.res)
    vardir = 'output/var/' # Contains only refined variables from results (.yml)
    fitdir = "output/fit"  # Contains fit (.fit)
    figdir = "output/fig"  # Contains plot of fit (.pdf)
    strudir = "output/stru" # Contains refined structures (.cif)

    folders = [resdir, fitdir, figdir, strudir, vardir]

    # Loop over all folders
    for folder in folders:
        # If the folder does not exist...
        if not os.path.exists(folder):
            # ...then we create it.
            os.makedirs(folder)

    # Establish the location of the cif file with the structure model
    # of interest and load it into a diffpy structure object.
    moddir = os.path.abspath(mpath)
    modelstru = Structure(filename=os.path.join(moddir, cif_name))

    # Initialize the Fit Recipe by giving it this diffpy structure
    # as well as the path to the data file.
    recipe = makerecipe(modelstru, dpath+gr_name,  rmin, rmax, rstep, qmin, qmax,
                        element_a, element_b, element_x, scale_i, delta2_i,
                        latpar_a_cub_i, uiso_a_i, uiso_b_i, uiso_x_i)

    # Parse in the gr file and add it to the recipe
    parser = PDFParser()
    parser.parseFile(dpath+gr_name)
    recipe.crystal.profile.loadParsedData(parser)

    # Set fithooks to get optimized values at each iteration
    recipe.fithooks.clear()
    resfithook = ResidualFitHook()
    recipe.fithooks.append(resfithook)

    # Fetch list of glazer variables and A cation variables to fit
    # checking the variables created by the recipe against the input
    # dictionaries adisp and glazer_tilts
    all_variables = recipe.getNames()
    avarnames = [av.split('_')[0] for av in list(adisp.keys())]
    A_variables = []
    for v in all_variables:
        if v in avarnames:
            A_variables.append(v)

    glazervarnames = [gv.split('_')[0]+str('_var') for gv in list(glazer_tilts.keys())]
    glazer_variables = []
    for v in all_variables:
        if v in glazervarnames:
            glazer_variables.append(v)

    ######################### Refinement steps ######################################
    # During the optimization, we fix and free parameters sequentially as you would
    # in PDFgui. This leads to more stability in the refinement. We first fix all
    # variables. "all" is a tag which incorporates every parameter.

    # We then run a fit using the SciPy function "least_squares" which takes as its
    # arguments the function to be optimized, here recipe.residual, as well as initial
    # values for the fitted parameters, provided by recipe.values. The x_scale="jac"
    # argument is an optional argument that provides for a bit more stability in the
    # refinement. "least_squares" is a bit more robust than "leastsq," which is another
    # optimization function provided by SciPy. "least_squares" supports bounds on
    # refined parameters, while "leastsq" does not.
     #sys.exit()
    print('Starting refinement {}...'.format(fit_name))
    recipe.fix("all")
    recipe.free("scale")
    least_squares(recipe.residual, recipe.values, x_scale="jac")

    recipe.free("latpar_a_cub_var")
    for i in range(len(glazer_variables)):
        recipe.free(glazer_variables[i])
    least_squares(recipe.residual, recipe.values, x_scale="jac")

    for i in range(len(glazer_variables)):
        recipe.fix(glazer_variables[i])

    for i in range(len(A_variables)):
        recipe.free(A_variables[i])
    least_squares(recipe.residual, recipe.values, x_scale="jac")
    for i in range(len(glazer_variables)):
        recipe.free(glazer_variables[i])
    least_squares(recipe.residual, recipe.values, x_scale="jac")

    for i in range(len(A_variables)):
        recipe.fix(A_variables[i])
    for i in range(len(glazer_variables)):
        recipe.fix(glazer_variables[i])

    recipe.free("Uiso_Ca", "Uiso_Ti", "Uiso_O")
    least_squares(recipe.residual, recipe.values, x_scale="jac")
    recipe.fix("Uiso_Ca", "Uiso_Ti", "Uiso_O")

    recipe.fix("all")
    recipe.free("scale")
    recipe.free("latpar_a_cub_var")
    for i in range(len(A_variables)):
        recipe.free(A_variables[i])
    for i in range(len(glazer_variables)):
        recipe.free(glazer_variables[i])
    least_squares(recipe.residual, recipe.values, x_scale="jac")

    recipe.free("Uiso_Ca", "Uiso_Ti", "Uiso_O")
    least_squares(recipe.residual, recipe.values, x_scale="jac")

    recipe.fix("all")
    recipe.free("scale")
    recipe.free("latpar_a_cub_var")
    for i in range(len(glazer_variables)):
        recipe.free(glazer_variables[i])
    least_squares(recipe.residual, recipe.values, x_scale="jac")

    recipe.fix("all")
    recipe.free("d2")
    least_squares(recipe.residual, recipe.values, x_scale="jac")

    recipe.free("all")
    recipe.fix("qdamp")
    recipe.fix("qbroad")
    least_squares(recipe.residual, recipe.values, x_scale="jac")

    print('... done!\n')

    # We use the FitResults function to parse out the results from the optimized
    # Fit Recipe.
    res = FitResults(recipe)

    # We use the savetxt method of the profile to write a text file containing the
    # measured and fitted PDF to disk. The file is named based on the fit_name we
    # created earlier, and written to the fitdir directory.
    profile = recipe.crystal.profile
    profile.savetxt(os.path.join(fitdir, fit_name + ".fit"))
    print('Dumped {}'.format(os.path.join(fitdir, fit_name + ".fit")))

    # We use the saveResults method of FitResults to write a text file containing the
    # refined parameters and fit quality indices to disk. The file is named based on
    # the fit_name we created earlier, and written to the resdir directory.
    header = "{}_{}.\n".format(compound, fit_name)
    res.saveResults(os.path.join(resdir, fit_name + ".res"), header=header)
    print('Dumped {}'.format(os.path.join(resdir, fit_name + ".res")))

    # Write out updated model structure
    pg = recipe.crystal.G1
    modelstru_opt: diffpy.structure.structure.Structure = pg.stru
    modelstru_opt.write("{}/{}.cif".format(strudir, fit_name), "cif")
    print('Dumped {}'.format("{}/{}.cif".format(strudir, fit_name)))

    # We create a new nested dictionary based on each variable name
    refined_dict = dict()
    for name, val in zip(res.varnames, res.varvals):
        # We store the refined value for this variable using the "value" key.
        # We use the ".item()" method because "res.varvals" exist as
        # numpy.float64 objects, and we want them as regular python floats.
        refined_dict[name] = val.item()

    # We also store the fit rw, for posterity.
    rw = res.rw
    refined_dict['rw'] = rw.item()

    # We now write this dictionary to a file for later use.
    outfilename = vardir + fit_name + "_r_" + str(rmin) + "-" + str(rmax) + "_refined_params.yml"
    with open(outfilename, 'w') as outfile:
        yaml.safe_dump(refined_dict, outfile)
    print('Dumped {}'.format(outfilename))

    # We print the results as output.
    print('\nResults from the Glazer fit:')
    res.printResults()

    # We use the plotresults function we created earlier to make a plot of the measured
    # calculated, and difference curves then write a pdf file to disk.
    # The file is named based on the fit_name we created earlier, and written
    # to the figdir directory.
    plotresults(recipe, resfithook, os.path.join(figdir, fit_name))

    return
```

```python
### PARAMETERS AND VARIABLES NOT TO BE REFINED

# Give the elements in the perovskite
element_a = "Ca"
element_b = "Ti"
element_x = "O"
compound = "{}{}{}3".format(element_a, element_b, element_x)

qmax = 23.6
qmin = 0.6

qdamp = 0.0291601718936
qbroad = 0.0103215402787

rmin = 1.6  # Can be changed. Min: 1.6 Å
rmax = 15  # Can be changed. Max: 50 Å
rstep = 0.01

RUN_PARALLEL = True
```

### Glazer tilt system IDs

#### Group A: High symmetry tilt systems

a0a0a0 ID: 23

a-a-a- ID: 14

a0a0c- ID :22

a0a0c+ ID :21

a0b-b- ID :20

a+b-b- ID :10

#### Group B: Multiple A-site tilt systems

a+a+a+ ID :3

a0b-c+ ID :17

a0b+b+ ID :16

a+a+c- ID :5

#### Group C: transitional/low symmetry tilt systems

a-a-c- ID :13

a0b-c- ID :19

a-b-c- ID :12

a+b-c- ID :8

a+b+c+ ID :1

```python
######## GLAZER SYSTEM ID ################################
glazer_system = 10

######## START VALUES TO BE REFINED ######################
# initial cubic lattice parameter
latpar_a_cub_i =  3.90
# initial tilt amplitudes (absolute values) around the three crystallagraphic axes
glazer_tilts = {'alpha_i': 8, 'beta_i': 9, 'gamma_i': 9}
# initial A cation displacements along the relevant crystallographic axes
adisp = {'aydisp_i': 0.02, 'azdisp_i': 0.01}

uiso_a_i = 0.003  # initial value for isotropic Uiso(A)
uiso_b_i = 0.004  # initial value for isotropic Uiso(B)
uiso_x_i = 0.01  # initial value for isotropic Uiso(X)

delta2_i = 1.95202664  # initial value for nearest neighbor correlation
scale_i = 0.2  # initial value for scale
```

```python
############### LOADING Data and models ##############################
dpath = 'input/data/'
gr_name = 'CaTiO3_x-ray_experimental_200K.gr'

mpath = 'input/models'
cif_name = "CaTiO3_2x2x2-supercell_0_0_0_tilt.cif"   ## This is not optional, it has to be a 2x2x2 cubic supercell
```

```python
if __name__ == "__main__":
    main()
```

```python

```


#### _Speech Processing Labs 2020: Module 1_

```python
## Run this first!
%matplotlib inline
import matplotlib.pyplot as plt
import numpy as np

import cmath
from math import floor
from matplotlib.animation import FuncAnimation
from IPython.display import HTML
plt.style.use('ggplot')

from dspMisc import *
```

# 5 Interpreting the  Discrete Fourier Transform

### Learning Outcomes
* Understand how sampling rate effects the DFT output
* Understand what the DFT leakage is.

### Need to know
* Topic Videos: Fourier Analysis, Frequency Domain
* [Digital Signals: Sampling sinusoids](./sp-m1-3-sampling-sinusoids.ipynb)
* [The Discrete Fourier Transform](./sp-m1-4-discrete-fourier-transform.ipynb) _(desirable)_

Equation alert: If you're viewing this on github, please note that the equation rendering is not always perfect. You should view the notebooks through a jupyter notebook server for an accurate view.

## 5.1 A Very Quick DFT recap
The previous worksheet went through the mechanics of the Discrete Fourier Transform (DFT).  The DFT input and output are broadly:

* **Input:**  $N$ amplitude samples over time
    * $x[n]$, for $n=1..N$ (i.e. a time series of $N$ samples)

* **Output:** the dot product (i.e., the similiarity) between the input and $N$ sinusoids with different frequencies
    * DFT[k] $= Me^{j\phi}$, i.e. a complex number with **magnitude** $M$ and **phase** angle $\phi$

The outputs are calculated using the following formula for $k=0,...N-1$.

$$
\begin{align}
DFT[k] &= \sum_{n=0}^{N-1} x[n] e^{-j \frac{2\pi nk}{N}} \\
&= \sum_{n=0}^{N-1} x[n]\big[\cos\big(\frac{2\pi nk}{N} \big) - j \sin\big(\frac{2\pi nk}{N} \big) \big]
\end{align}
$$

We saw how the complex numbers in this equation $e^{-j2\pi nk/N}$ for $k=1...N-1$ represent sinusoids with specific frequencies as projections of phasors. You can just think of a phasor as an analogue clock with one hand (vector) ticking (rotating) around a clockface (i.e. a circle), where the length of the hand is the amplitude of that wave, and how fast it goes around the clock is it's frequency.

Each DFT[k] output essentially tells us how strongly the input is repeats itself (i.e. is periodic) at the frequency of the $k$th DFT phasor. So, we talk about the DFT outputs as providing a **frequency response**.

Since the the DFT outputs are complex numbers, we can talk about them in terms of magnitude and phase angle: The magnitude of DFT[k] tells us how much we'd weight the $k$-th phasor if we were to try to reconstruct the original input by adding all the DFT phasors together.  The phase of DFT[k] tells use whether we need to shift that wave along the time axis.

## 5.2 The DFT Frequency Response:  Which Frequencies?

You will have noticed in the previous worksheet that we only talked about relative frequencies above, e.g. the $e^{-j2\pi nk/N}$ phasor has $k$ times the frequency of the $e^{-j2\pi n/N}$ phasor.

Since the frequencies of the DFT phasors are all multiples of the $k=1$ phasor, we just have to figure out the frequency of that phasor and we'll know all the rest. Now, the $k=1$ phasor takes $N$ steps to complete a full cycle of the unit circle.  So, it's frequency depends on how long each of these steps takes.  That is, the **sampling rate**.

Assume we are sampling at a rate of $f_s$ samples per second. Then, we can calculate:

* How long between each sample (i.e. the **sampling time**):
    * $t_s = 1/f_s$  (seconds)

* How long does it take to take $N$ samples?
    * $t_s \times N$  (seconds x samples = seconds)

* How long will it take the $k=1$ phasor to make 1 complete cycle?
    * $T_1 = t_s \times N$  (seconds)
    * This is the **period** or **wavelength** of the phasor

* What is the **frequency** of the $k=1$ phasor?
    * $f_{min} = 1/T_1 = 1/(t_s N) $ (cycles/second)

* What is the **frequency** of the $k$th phasor?
    * $kf_{min}$ (cycles/second)

So, the important thing to remember is that the DFT outputs depend on:

* The **number of samples** in the input sequence, $N$
* The **sampling rate**, $f_s$ samples/second

### Example
Assume we have a sampling rate of $f_s = 32$ samples/second, and an input length of $N=16$.

We can then calculate the $k=1$ DFT output frequency by noting the following:

* The sampling time is: $t_s = 1/f_s = 1/32$ seconds
* So, the time it takes to complete one cycle is: $T_1 = t_sN = 1/32 \times 16 = 1/2$ seconds
* So, the frequency of DFT[1] is $f_{min} = 1/1/2 = 2$ cycles/second

### Exercise

* What's the frequency of the $k=5$ phasor in the previous example?

### Notes

```python

```

### Exercise

Try running this code which sets the input to be a sine wave with the same frequency as the the 1st DFT component. That is, `f_in = 1/wavlen_in`.

```python
N=64

#sampling rate:
f_s = 128

## sample time
t_s = 1/f_s

## Notice that the DFT component frequencies as completely determined by the number of samples N
## and the sampling frequency f_s.
## So if we wanted the input frequency to exactly match the frequency of the 1st DFT component we
## could just do this:

wavlen_in = t_s*N
f_in = 1/wavlen_in

print("f_s: %f\nt_s: %f\nwavlen: %f\nf_in %f" % (f_s, t_s, wavlen_in, f_in))

## indices of input sequence of size N
nsteps = np.array(range(N))

samples_per_cycle = wavlen_in/t_s
print("samples per cycle %f" % samples_per_cycle)

## Generate a sine wave with f_in matching the frequency of the k=1 DFT phasor
## You can look at the code for this function in the file XXX.py

x, time_steps = gen_sinusoid(frequency=f_in, phase=0, amplitude=1, sample_rate=f_s, seq_length=N, gen_function=np.sin)

## Plot the input
fig, timedom = plt.subplots(figsize=(16, 4))
timedom.scatter(time_steps, x, color='magenta')
timedom.plot(time_steps, x, color='magenta')
timedom.set_xlabel("time (s)")
timedom.set_ylabel("Amplitude")
timedom.set_title("Input: $x[n]$ in the time domain")
```

```python
## Calculate the frequencies associated with each DFT output
def get_dft_freqs_all(sample_rate, seq_len):
    ## Get the minimum frequency
    f_min = sample_rate/seq_len

    ## All the other frequencies are just integer multiples of the minimum frequency
    ## We have 0,...,seq_len-1 outputs
    dft_outs = np.arange(seq_len)

    return f_min * dft_outs
```

```python
## Find the DFT output frequencies for our current sample rate and input length
dft_freqs = get_dft_freqs_all(sample_rate=f_s, seq_len=N)
print(dft_freqs)

```

    [  0.   2.   4.   6.   8.  10.  12.  14.  16.  18.  20.  22.  24.  26.
      28.  30.  32.  34.  36.  38.  40.  42.  44.  46.  48.  50.  52.  54.
      56.  58.  60.  62.  64.  66.  68.  70.  72.  74.  76.  78.  80.  82.
      84.  86.  88.  90.  92.  94.  96.  98. 100. 102. 104. 106. 108. 110.
     112. 114. 116. 118. 120. 122. 124. 126.]

```python
## Apply the DFT to the input and return the magnitudes and phases for each DFT output
mags, phases = get_dft_mag_phase(x, seq_len=N)

## Plot the magnitudes
fig, fdom = plt.subplots(figsize=(16, 4))
fdom.scatter(dft_freqs, mags)
fdom.set_xlabel("Frequency (Hz)")
fdom.set_ylabel("Magnitude")
fdom.set_title("Magnitude response")

#print(mags)

## Plot the phases
fig, fdom = plt.subplots(figsize=(16, 4))
fdom.scatter(dft_freqs, phases)
fdom.set_xlabel("Frequency (Hz)")
fdom.set_ylabel("Phase angle (radians)")
fdom.set_title("Phase response")

#print(phases)

```

### Exercise

* Is the output what you'd expect given the input?
* Why is the phase non-zero for the DFT output's 1 and 63?
* What happens if you change the `amplitude` of the input sine wave? What about the `phase`?
* What happens to the range of DFT output frequencies if you increase the sampling rate `f_s` but keep the input sequence length `N` fixed?
* What happens to the range of DFT output frequencies for $k > N/2$? Can the DFT actually model those frequencies?

### Notes

```python

```

## 5.3 DFT on a compound waveform

Now, let's see what happens when we use a compound waverform as input to the DFT.  First we need to set the length of the sequence and the sample rate.

```python
## Set the number of samples N, sampling rate f_s
N=64

#sampling rate:
f_s = 64

## sample time
t_s = 1/f_s

print("N: %d\nf_s: %f\nt_s: %f" % (N, f_s, t_s))
```

    N: 64
    f_s: 64.000000
    t_s: 0.015625

Let's create a compound waveform as input with frequency components of 4Hz and 12Hz:

```python
## Now let's create a compound waveform as input
## We're setting gen_function=np.cos, meaning that each of our sinusoids is a cosine wave
x1, time_steps = gen_sinusoid(frequency=4, phase=0, amplitude=1, sample_rate=f_s, seq_length=N, gen_function=np.cos)

## Let's shift the phase of this one by 90 degrees (pi/2 rads)
x2, time_steps = gen_sinusoid(frequency=12, phase=np.pi/2, amplitude=1, sample_rate=f_s, seq_length=N, gen_function=np.cos)

x_compound = x1 + x2

## Plot the input
fig, timedom = plt.subplots(figsize=(16, 4))
timedom.scatter(time_steps, x_compound, color='magenta')
timedom.plot(time_steps, x_compound, color='magenta')
timedom.set_xlabel("time (s)")
timedom.set_ylabel("Amplitude")
timedom.set_title("A compound waveform: $x_compound$ in the time domain")
```

You should see a somewhat complicated waveform whose overall pattern repeats 4 times in a second.  Now we do the DFT and look at the magnitude and phase values for the different DFT outputs.

```python
## Do the DFT on the compound waveform as above:
mags, phases = get_dft_mag_phase(x_compound, seq_len=N)
dft_freqs = get_dft_freqs_all(sample_rate=f_s, seq_len=N)

## Plot the magnitudes
fig, fdom = plt.subplots(figsize=(16, 4))
#timedom.scatter(dft_freqs[:round(N)], mags[:round(N)])
fdom.scatter(dft_freqs[:round(N/2)], mags[:round(N/2)])
fdom.set_xlabel("Frequency (Hz)")
fdom.set_ylabel("Magnitude")
fdom.set_title("Magnitude response")
#print(mags)

## Plot the phases - let's just skip this for the moment
fig, fdom = plt.subplots(figsize=(16, 4))
fdom.scatter(dft_freqs[:round(N/2)], phases[:round(N/2)])
fdom.set_xlabel("Frequency (Hz)")
fdom.set_ylabel("Phase (rad)")
fdom.set_title("Phase response")
#print(phases)

```

### Exercise

* Do you see what you would expect given the input?
* Why does the number of non-zero phases differ from the number of non-zero magnitudes?

### Notes

```python

```

### Exercise

Explore the outputs of the DFT when you change the following parameters
* sampling rate `f_s`
* sequence length `N`
* frequency of the input sequence(s) `frequency` (in the function `gen_sinusoid`)

**Question**
* What happens if you set the frequency of one of the input components to be greater than the Nyquist frequency?
* What happens when the frequency of the input components doesn't match any of the DFT component frequencies?
(e.g. for the example above what if one of the input frequencies is not a whole number?)

### Notes

```python

```

## 5.4  Leakage

One of the main things to remember about the DFT is that you're calculating the correlation between the input and phasors with specific frequencies.  If your input exactly matches one of those phasor frequencies the magnitude response will show a positive magnitude for that phasor and zero for everything else.  However, if the input frequency falls between phasor frequencies (i.e. doesn't correspond to a specific DFT output), then you'll see spectral **leakage**.  The DFT outputs close to the input frequency will also get positive magnitudes, with the DFT output closest to the input frequency getting the highest magnitude.

The following code gives an example

```python
N=64
f_s = 64
f_in1 = 4.5 ## In between DFT output frequencies
f_in2 = 12  ## One of the DFT outputs

x1, time_steps = gen_sinusoid(frequency=f_in1, phase=0, amplitude=1, sample_rate=f_s, seq_length=N, gen_function=np.cos)
x2, time_steps = gen_sinusoid(frequency=f_in2, phase=np.pi/2, amplitude=1, sample_rate=f_s, seq_length=N, gen_function=np.cos)
x_compound = x1  + x2

## Plot the input
fig, timedom = plt.subplots(figsize=(16, 4))
timedom.scatter(time_steps, x_compound, color='magenta')
timedom.plot(time_steps, x_compound, color='magenta')
timedom.set_xlabel("time (s)")
timedom.set_ylabel("Amplitude")
timedom.set_title("Leakage example (time domain)")

```

```python
## Do the DFT on the compound waveform as above:
mags, phases = get_dft_mag_phase(x_compound, seq_len=N)
dft_freqs = get_dft_freqs_all(sample_rate=f_s, seq_len=N)

## Plot the magnitudes
fig, fdom = plt.subplots(figsize=(16, 4))

## Just plot the first N/2 frequencies since we know that they are the mirrored for k>N/2
fdom.scatter(dft_freqs[:round(N/2)], mags[:round(N/2)])
fdom.set_xlabel("Frequency (Hz)")
fdom.set_ylabel("Magnitude")
fdom.set_title("Leakage example: Magnitude response")
#print(mags)

```

### Leakage as the normalized sinc function

Leakage makes the DFT harder to interpret. However, we can derive the shape that leakage will have from the the DFT equation and some algebra about rectangular functions.  It turns out that leakage for a particular frequency follows the normalized **sinc** function:

$$
\begin{align}
X(m) &= \Big|\frac{AN}{2} \cdot \mathop{sinc}(c-m)\Big|\\
&= \Big|\frac{AN}{2} \cdot \frac{\sin(\pi(c-m))}{2\pi(c-m))}\Big|\\
\end{align}
$$

Where $A$ is the peak amplitude of the input, $N$ is the input sequence length, $c$ is the number of cycles completed in the input sequence time.  If $c$ is a whole number we just get the usual DFT response, but if $c$ is not a whole number, we get a spread across output frequency bins.

Let's check whether the sinc function matches what we get in the DFT. First we write a function to evaluate the leakage function in between our DFT outputs.

```python
## Calculate the approximated leakage as the sinc function
def calc_leakage(freq,  sample_rate, seqlen, amplitude=1):
    sequence_time = (1/sample_rate)*seqlen

    ## number of cycles in input for component
    c = freq * sequence_time
    print("c=", c)

    ## Interpolate between DFT ouput indices
    ms = np.arange(0, seqlen, 0.05)

    ## Approximated response - we could actually just return a function here, but
    ## let's just keep things concrete for now.
    leakage = np.abs(amplitude * seqlen * 0.5 * np.sinc((c-ms)))

    return leakage, ms * (sample_rate/seqlen)

```

Now let's plot the leakage predicted for our two input components separately (top) and added together (bottom)

```python
## Calculate the leakage function for our know input wave components
leakage1, ms = calc_leakage(f_in1, f_s, N)
leakage2, ms = calc_leakage(f_in2, f_s, N)

## Plot the magnitude response and the leakage function for each of our input components
fig, fdom = plt.subplots(figsize=(16, 4))
fdom.set(xlim=(-1, N/2), ylim=(-1, N))
fdom.scatter(dft_freqs, mags)

fdom.plot(ms, leakage1)
fdom.plot(ms, leakage2)
fdom.set_xlabel("Frequency (Hz)")
fdom.set_ylabel("Magnitude")
fdom.set_title("Leakage function for each input component frequency")

## Plot the magnitude response and the sum of the leakage functions
fig, fdom = plt.subplots(figsize=(16, 4))
fdom.set(xlim=(-1, N/2), ylim=(-1, N))
fdom.scatter(dft_freqs, mags)
fdom.plot(ms, leakage1 + leakage2, color='C5')
fdom.set_xlabel("Frequency (Hz)")
fdom.set_ylabel("Magnitude")
fdom.set_title("Sum of leakage functions for input components")

## It fits, though not perfectly!
```

In the top figure, you should see that peaks (**main lobes**) of each leakage function are aligned with our input component frequencies. The peaks are at the same points as the DFT outputs when the sinusoidal component has a frequency matching the DFT output frequency (i.e. 12 above Hz).  Otherwise we see the spread of leakage around the input component frequency (i.e. around 4.5 Hz).

You'll also notice that our DFT magnitudes points don't always line up perfectly with our sinc curves.  Part of this is because the leakage function is an _approximation_.  Nevertheless, it's a very good approximation!

### Exercise

* What happens if the frequencies of the two components making the compound waveform are very close together?
    * e.g. make `f_in2=6`
* What if one of the components has a relatively small amplitude?
    * e.g. change `amplitude` of the second input to 0.4

### Notes

```python

```

### Shaping the lobes
The leakage sinc function has a peak around a specific frequency. If we want our DFT to be better able to distinguish between close frequencies, we need that peak, the **main lobe** to be narrower.  We also want the other peaks, the **side lobes** to be flatter.  We can achieve this using different **windowing methods** on our input.  This is why you see 'Hanning' as the default option for window method in the advanced spectrogram settings in praat.

We'll see some more examples of this when we look at different types of filters later.  But for now the main thing to observe is that leakage might give you the impression that specific frequency components are present in your waveform, when what's actually happening is that your waveform has frequencies that don't match the DFT phasors.  Another thing that can happen is that the peak for a low amplitude component gets subsumed into the main lobe of a nearby frequency component.  This might make you miss frequency components in your input!

```python

```

## 5.6 Extra:

Here's the composition and decomposition for the compound waveform one you saw in Notebook 2!

```python
N=1028
f_s = 1028
f_in1 = 8 ## In between DFT output frequencies
f_in2 = 20  ## One of the DFT outputs
f_in3 = 36  ## One of the DFT outputs

x1, time_steps = gen_sinusoid(frequency=f_in1, phase=0, amplitude=0.5, sample_rate=f_s, seq_length=N, gen_function=np.cos)
x2, time_steps = gen_sinusoid(frequency=f_in2, phase=np.pi/2, amplitude=1, sample_rate=f_s, seq_length=N, gen_function=np.cos)
x3, time_steps = gen_sinusoid(frequency=f_in3, phase=0, amplitude=0.3, sample_rate=f_s, seq_length=N, gen_function=np.cos)

x_compound = x1  + x2 + x3

## Plot the input
fig, timedom = plt.subplots(figsize=(16, 4))
#timedom.scatter(time_steps, x_compound, color='magenta')
#timedom.set(xlim=(0, 0.2))
timedom.plot(time_steps, x_compound, color='magenta')
```

```python
## Plot the input

fig = plt.figure(figsize=(15,15))
gs = fig.add_gridspec(3,2)

ymax=2
timedom = fig.add_subplot(gs[0, 0])
timedom.set(xlim=(-0.1, 1), ylim=(-ymax,ymax))
timedom.plot(time_steps, x_compound, color='magenta')
timedom.set_title("waveform made from adding 3 sine waves")

s1 = fig.add_subplot(gs[0, 1])
s1.set(xlim=(-0.1, 1), ylim=(-ymax,ymax))
s1.plot(time_steps, x1)
s1.set_title("component 1: 8 Hz")

s2 = fig.add_subplot(gs[1, 1])
s2.set(xlim=(-0.1, 1), ylim=(-ymax,ymax))
s2.plot(time_steps, x2)
s2.set_title("component 2: 20 Hz")

s3 = fig.add_subplot(gs[2, 1])
s3.set(xlim=(-0.1, 1), ylim=(-ymax,ymax))
s3.plot(time_steps, x3)
s3.set_title("component 3: 36 Hz")

fig.savefig("./fig/compound_waveform.png")
```

```python
## Do the DFT on the compound waveform as above:
mags, phases = get_dft_mag_phase(x_compound, seq_len=N)
dft_freqs = get_dft_freqs_all(sample_rate=f_s, seq_len=N)

## Plot the magnitudes
fig,fdom = plt.subplots(figsize=(16, 4))
fdom.set(xlim=(-1, N/2))
## Just plot the first N/2 frequencies since we know that they are the mirrored for k>N/2
fdom.scatter(dft_freqs[:round(N/2)], mags[:round(N/2)])
fdom.set_xlabel("Frequency (Hz)")
fdom.set_ylabel("Magnitude")
fdom.set_title("Magnitude Response")
#print(mags)

```

```python

```

```python

```


# Introduction to Fourier Series and Transforms

**Student Version**

### This notebook contains both class and exercice materials. Classes material will be written in black, examples in blue, and problems to solve in red.

* * *

By L. Bugnet (Flatiron Institute)

```python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from ipywidgets import interact, fixed
from scipy.fftpack import fft, ifft, rfft
import time
from scipy import signal
from lightkurve import search_targetpixelfile
import lightkurve as lk
import astropy.units as u

%matplotlib notebook
plt.ion()
```

# Part 1: Fourier Series and signal decomposition

```python
from IPython.display import Audio,Image, YouTubeVideo
id='r18Gi8lSkfM'
YouTubeVideo(id=id,width=1000,height=600)
```

## Total Fourier Series formula:

Periodic signal f(x) with period $T=2\pi/\omega$

\begin{equation}
\boxed{F(x) = A_0 + \sum_{n=1}^\infty A_n \cos(n\omega x -\Phi_n)\,}
\end{equation}

That can be rewritten as:
\begin{equation}
\boxed{F(x) = a_0 + \sum_{n=1}^\infty a_n \cos(n\omega x) + \sum_{n=1}^\infty b_n \sin(n\omega x)\,}
\end{equation}

with
\begin{equation}
a_0 = \frac{1}{T} \int_0^T f(x)
\end{equation}
\begin{equation}
a_n = \frac{2}{T} \int_0^T f(x)\cos(n \omega x)
\end{equation}
\begin{equation}
b_n = \frac{2}{T} \int_0^T f(x) \sin(n \omega x)
\end{equation}

### Example 1.1: Approximate f with a Fourier Series
\begin{equation}
f(x) =
    \begin{cases}
      1 & \text{0<x<$\pi$ $[2\pi]$}\\
      0 & \text{$\pi$<x<2$\pi$ $[2\pi]$}
    \end{cases}
\end{equation}
f is periodic of Period $T=2\pi$, $\omega=1$ so:

\begin{equation}
    a_0=\frac{1}{T}\int_0^\pi 1 {\rm d}x \left(+\frac{1}{T}\int_\pi^{2\pi} 0 {\rm d}x\right) = \frac{1}{2}
    \end{equation}

\begin{equation}
    a_n=\frac{1}{T}\int_0^\pi \cos{n x} {\rm d}x = \frac{\sin{n\pi}}{n\pi}
    \end{equation}

\begin{equation}
    b_n=\frac{1}{T}\int_0^\pi \sin{n x} {\rm d}x = \frac{1-\cos{n\pi}}{n\pi}
    \end{equation}

Let's try to approximate $f$ by:

\begin{equation}
    F(x)=\frac{1}{2}+\sum_{n=1}^\infty\left(\frac{\sin{n\pi}}{n\pi}\cos(nx) +\frac{\sin{n\pi}}{n\pi}\sin(nx)\right)
    \end{equation}

```python
#Plot f over 3T-wide intervals:
x1=np.linspace(0, np.pi, 100)
x2=np.linspace(np.pi, 2*np.pi, 100)

plt.figure()
for ii in range(0,4):
    plt.plot(x1+2*np.pi*ii, np.ones(len(x1)), color='k', lw=5)
    plt.plot(x2+2*np.pi*ii, np.zeros(len(x2)), color='k', lw=5)

#Overplot Fourier Serie F:
x = np.linspace(0, 8*np.pi, 800)
a0=1./2.
Na = [ 1, 3, 5, 11, 101] #number of sine function to consider in the Fourier Serie

#Fucntion that calculates F from a0, an, bn:
def f_N(x,N, a0):
    na = range(1,N+1,2)
    f_N = np.zeros(x.shape)+a0
    for n in na:
        an = np.sin(n*np.pi)/(n*np.pi)
        bn = (1-np.cos(n*np.pi))/(n*np.pi)
        f_N += an*np.cos(n*x) + bn*np.sin(n*x)
    return f_N

for N in Na:
    plt.plot(x,f_N(x,N, a0),label='N={}'.format(N), alpha=0.5)
plt.xlabel('x')
plt.legend(loc='best')

```

### Example 1.2: Approximate continuous function with a Fourier Series
\begin{equation}
f(x) =x(1-x)\,\text{with } 0<x<1
\end{equation}

```python
x = np.linspace(0,1,1000)

# small python function to define the partial sum for the truncated Fourier Series f_N
def f_N(x,N):
    """
    small python function to define the partial sum for the truncated Fourier Series f_N
    """
    na = range(1,N+1,2)
    f_N = np.zeros(x.shape)
    for n in na:
        f_N += 8/((n*np.pi)**3)*np.sin(n*np.pi*x)
    return f_N

# And make a figure showing f_N for increasing values of N
Na = [ 1, 3, 5, 11, 101]

plt.figure()
for N in Na:
    plt.plot(x,f_N(x,N),label='N={}'.format(N))
plt.plot(x,x*(1-x),'k',label='$f(x)=x(1-x)$', lw=5)
plt.xlabel('x')
plt.legend(loc='best')
plt.grid()
plt.show()
```

### Example 1.3: Approximate 2D function with a Fourier Series

```python
def fourier2D_xy(m_terms=5, n_terms=5):
    """Plot the 2D Fourier approximation for a hyperbolic paraboloid

    m_terms, and n_terms are the number of terms in x and y.

    The values are padded to be between [-0.9 pi, 0.9 pi] to avoid the
    discontinuities in the border of the domain.
    """
    Y, X = 0.9*np.pi * np.mgrid[-1:1:21j, -1:1:21j]
    XY = np.zeros_like(X)
    for cont_x in range(1, m_terms + 1):
        for cont_y in range(1, n_terms + 1):
            XY = XY + (-1)**(cont_x + cont_y) * \
                np.sin(cont_x*X) * np.sin(cont_y*Y)/(cont_x*cont_y)

    XY = 4*XY

    fig = plt.figure(figsize=(6, 4))
    ax = fig.add_subplot(111, projection='3d')
    ax.plot_surface(X, Y, XY, cmap="Purples_r", cstride=1, rstride=1, alpha=0.8,
                   lw=0.2)
    ax.plot_wireframe(X, Y, X*Y, cstride=1, rstride=1, color="#e41a1c",
                     linewidth=1)

interact(fourier2D_xy,
     m_terms=(1, 500),
     n_terms=(1, 500));

interact(fourier2D_xy(m_terms=500, n_terms=500),
     m_terms=(1, 500),
     n_terms=(1, 500));
```

Visit link for visual interpretation of signal from sin/cos: https://www.geogebra.org/m/ztn2du2a

### What if the signal f is not periodic?

* #### Fourier series

Periodic function => converts into a discrete exponential or sine and cosine function.

Non-periodic function => not applicable
* #### Fourier transform

Periodic function => converts its Fourier series in the frequency domain.

Non-Periodic function => converts it into continuous frequency domain.

```python
from IPython.display import Audio,Image, YouTubeVideo
id='r18Gi8lSkfM'
YouTubeVideo(id=id,width=1000,height=600)
# start at 9:11
```

# Part 2: Fourier Transforms

"The Fourier transform is a way of splitting something up into a bunch of sine waves"

#### Fourier transform of a function f definition:
\begin{equation}
\boxed{F(\nu) = \int_{-\infty}^{\infty} f(x)e^{-2\pi i\nu x} {\rm d}x}
\end{equation}

### Example 2.1: Get the fourier transform of f
 \begin{equation}
f(x) = \cos(2\pi 3x)
\end{equation}

```python
#plot function f:
x = np.linspace(-3,3, 1000)
plt.figure()
plt.plot(x,np.cos(2*np.pi*x*3), alpha=1 , color='dodgerblue')
plt.xlabel('x')
plt.ylabel('f(x)')
```

f is periodic of known period $T=\frac{1}{3}$. Let's calculate recover this periodicity by using the Fourier transform of f:

\begin{equation}
    F(\nu) = \int_{-\infty}^{\infty}\cos(2\pi 3x)e^{-2\pi i \nu x}{\rm d}x
    \end{equation}

\begin{equation}
    F(\nu) = \int_{-\infty}^{\infty}\left(\frac{e^{2\pi i 3 x}+e^{-2 \pi i 3 x}}{2}\right)e^{-2\pi i \nu x}{\rm d}x
    \end{equation}

\begin{equation}
    F(\nu) = \int_{-\infty}^{\infty}\left(\frac{e^{2\pi i (3 x-\nu x)}+e^{-2 \pi i (3 x + \nu x)}}{2}\right){\rm d}x
    \end{equation}

\begin{equation}
\boxed{F(\nu) = \frac{1}{2}\left(\delta\left(\nu-3)\right) +\delta\left(\nu+3\right)\right)}
\end{equation}

The Fourier transform of $f(x) = \cos(2\pi 3x)$ is zero except at the frequency $\nu=3$ (and $\nu =-3$ not acceptable for most physical problems), corresponding to a periodicity of $T=\frac{1}{\nu}=\frac{1}{3}$

### As shown by this example, if the function is periodic the Fourier transform is a sum of Dirac positionned at the different frequencies.

### Problem 2.2: Get the fourier transform of f
 \begin{equation}
f(x) = \sin(2\pi 3x)
\end{equation}

Enter details of calculation here!

Solution:

\begin{equation}
F(\nu) = \frac{1}{2i}\left(\delta\left(\nu-3)\right) -\delta\left(\nu+3\right)\right)
\end{equation}

### Conclusion:
* ### Even functions have Real Fourier Series
* ### Odd functions have Imaginary Fourier Series

* ### In general, we use the absolute value of the FT to take into account both odd and even components in the function: |FT|

### What if the function is not periodic?

### Problem 2.3: Fourier transform of Periodic VS non-periodic function

Represent the following functions:

\begin{equation}
f_1(x) = \sin(6\pi x)
\end{equation}

\begin{equation}
f_2(x) = \sin(6\pi x)e^{-2\pi x}
\end{equation}

```python
x = np.linspace(0,3, 10000)
fig, ax = plt.subplots(1,2, figsize=(9,4))

f1 = np.sin(6*np.pi*x)
f2 = np.sin(6*np.pi*x)*np.exp(-2*np.pi*x)

#plot function f1:
ax[0].plot(x, f1, alpha=1 , color='crimson')

ax[0].set_xlabel('x')
ax[0].set_ylabel(r'$f_1(x)$')

#plot function f2:
ax[1].plot(x, f2, alpha=1 , color='crimson')

ax[1].set_xlabel('x')
ax[1].set_ylabel(r'$f_1(x)$')

plt.tight_layout();

```

and represent their fourier transforms:

\begin{equation}
F_1(\nu) = \frac{1}{2i}\left(\delta\left(\nu-3)\right) +\delta\left(\nu+3\right)\right)
\end{equation}

\begin{equation}
F_2(\nu) = \frac{6 \pi}{(2\pi+2\pi\nu i)^2+(6\pi)^2}
\end{equation}

```python
nu = np.linspace(0,50, 10000)

#we approximate FT1 with:
FT1 = np.zeros(10000)
FT1[np.where(np.abs(nu-(3))==np.min(np.abs(nu-3)))]=1

#plot FT1:
fig, ax = plt.subplots(1, 2, figsize=(9,4))
ax[0].plot(nu, np.abs(FT1), color='crimson')
wh1 = np.argmax(np.abs(FT1))
ax[0].plot(nu[wh1], np.abs(FT1)[wh1], '+', color='crimson', markersize=10)
ax[0].set_xlim([0,10])
ax[0].set_xlabel(r'$\nu$')
ax[0].set_ylabel(r'F($\nu$)')

#plot FT2
FT2 = np.zeros(10000)
denom = (2*np.pi + 2j*np.pi*nu)*(2*np.pi + 2j*np.pi*nu) + 36*np.pi*np.pi
FT2 = np.abs(6*np.pi/denom) # [np.where(np.abs(nu-(3))==np.min(np.abs(nu-3)))]=1
#FT22 = 6*np.pi/denom

ax[1].plot(nu, FT2, color='crimson')
ax[1].set_xlim([0,10])
ax[1].set_xlabel(r'$\nu$')
ax[1].set_ylabel(r'F($\nu$)')

#ax[2].plot(nu, FT22, color='crimson')
#ax[2].set_xlim([0,10])
#ax[2].set_xlabel(r'$\nu$')
#ax[2].set_ylabel(r'F($\nu$)')

#plot maximum of FT2, extract Period of f2
wh2 = np.argmax(FT2)
ax[1].plot(nu[wh2], FT2[wh2], '+', color='crimson', markersize=10)
print('T2='+str(nu[wh2]))
```

### Conclusion: If the function is not periodic in the time domain, the FT converts it to a continuous function in the frequency domain:

### What if the signal is discrete?

# Part 3: Discrete Fourier Transforms (DFT)

Discrete Fourier tranform are used on discrete signals.

\begin{equation}
\boxed{X_k = \sum_{n=0}^{N-1}x_n \displaystyle  e^{\displaystyle -2 i \pi  \frac{kn}{N}}}
\end{equation}

with
* N = number of samples
* n = current sample
* k = current frequency, where k∈[0,𝑁−1]
* $x_n$ = the sine value at sample n
* $X_k$ = The DFT which include information of both amplitude and phase:

\begin{equation}
A = \frac{|X_k|}{N}
\end{equation}

\begin{equation}
\Phi = \arctan{\frac{\mathcal{Im}(X_k)}{\mathcal{Re}(X_k)}}
\end{equation}

Visit link for visual interpretation of Discrete Fourier transform: https://www.geogebra.org/m/ztn2du2a

### Problem 3.1: Write a function DFT(x) which takes in one argument, x - input 1 dimensional real-valued signal. The function will calculate the DFT of the signal and return the DFT values.

```python
### Solution:
def DFT(x):
    """
    Function to calculate the
    discrete Fourier Transform
    of a 1D real-valued signal x
    """

    N = len(x)
    n = np.arange(N)
    k = n.reshape((N, 1))
    e = np.exp(-2j * np.pi * k * n / N)

    X = np.dot(e, x)

    return X
```

Apply this DFT function to the discrete signal generated by $f(x) = \sin(6\pi x)e^{-2\pi x}$ (see above) with a sampling rate of 1000 (x=np.linspace(0,10,1000)) and plot the result.

```python
sr =1000 #sampling rate
x = np.linspace(0,10, sr)
y = np.sin(6*np.pi*x)*np.exp(-2*np.pi* x)
X = DFT(y)
# calculate the frequencies associated with x:
freq = np.arange(0, 1/(1/100), 1/10)

fig, ax=plt.subplots(1,2, figsize=(10,4))
ax[0].plot(x, y, 'o',color='crimson', markersize=3)
ax[0].set_xlabel('x')
ax[0].set_ylabel('y')
ax[0].set_xlim([0,2])

ax[1].plot(freq, abs(X), 'o',color='crimson', markersize=3)

ax[1].set_xlabel(r'$\nu$')
ax[1].set_ylabel(r'|DFT($\nu$)|')
#ax[1].set_xlim([0,3])

#calculate Periodicity
imax_X = np.argmax(np.abs(X))
print('T='+str(1/freq[imax_X]))
```

### Do you recover $T\approx 0.35$?

### Why is the DFT mirrored?

# Part 4: Fast Fourier Transform

####  Fast Fourier Transforms are a family of algorithm that computes the DFT efficeintly. They are used to reduce the computational time from $\mathcal{O}(N^2)$ to $\mathcal{O}(N\log{N})$
 See https://www.ece.ucdavis.edu/~bbaas/281/papers/CooleyLewisWelch.1967.HistNotesFFT.pdf for details about the different FFT algorithms.

Of course, many python packages like scipy and numpy already contain implemented FFT functions, so that you don't have to.

Let's compare the FFT with our DFT function:

```python
#Calculate the FFT of our data
x = np.linspace(0,10, sr)
FFT = np.fft.fft(np.sin(6*np.pi*x)*np.exp(-2*np.pi*x))

#Calculate the associated frequencies
freqs = np.fft.fftfreq(len(x), d=x[1]-x[0])

#Plot FFT
plt.figure()
plt.plot(freqs,np.abs(FFT), 'o', color='dodgerblue', markersize=3)
plt.xlabel(r'$\nu$')
plt.ylabel(r'FFT($\nu$)')
plt.xlim(0,50)
wh = np.argmax(np.abs(FFT))
plt.plot(freqs[wh], np.abs(FFT)[wh], '+', color='crimson', markersize=10)

#Extract dominant frequency
print('T='+str(1/freqs[wh]))

```

### Is FFT really more efficient than our home made DFT function ?

## Problem 4.1: Computational time: DFT VS FFT

Compare the computational time of the DFT function and the FFT algorithm on the discrete function $f(x) = \sin(6\pi x)e^{-2\pi x}$ with two different observation cadence:

x = np.linspace(0,10, 1000)

x = np.linspace(0,10, 10000)

```python
#sampling rate:
sr = 1000
x = np.linspace(0,10, sr)
y = np.sin(6*np.pi*x)*np.exp(-2*np.pi* x)

# computation time with our DFT function:
start = time.time()
X = DFT(y)
end = time.time()
timing = end - start
print('DFT 1000 samples computational time='+ str(timing) +' sec')

# computation time with FFT
start = time.time()
FFT = np.fft.fft(np.sin(6*np.pi*x)*np.exp(-2*np.pi*x))
end = time.time()
timing = end - start
print('FFT 1000 samples computational time='+ str(timing) +' sec')

#increase number of point (*10):
sr *= 10
x = np.linspace(0,10, sr)
y = np.sin(6*np.pi*x)*np.exp(-2*np.pi* x)
start = time.time()
X = DFT(y)
end = time.time()
timing = end - start
print('DFT 10000 samples computational time='+ str(timing) +' sec')

start = time.time()
FFT = np.fft.fft(np.sin(6*np.pi*x)*np.exp(-2*np.pi*x))
end = time.time()
timing = end - start
print('FFT 10000 samples computational time='+ str(timing) +' sec')
```

    DFT 1000 samples computational time=0.1269989013671875 sec
    FFT 1000 samples computational time=0.0009982585906982422 sec
    DFT 10000 samples computational time=11.274016380310059 sec
    FFT 10000 samples computational time=0.0009980201721191406 sec

### Conclusion: Use FFT algorithms!

# Part 5: Reverse fourier transform:

\begin{equation}
\boxed{f(x)=\int_{-\infty}^{\infty} F(\nu)e^{\displaystyle 2\pi i \nu x}{\rm d}\nu}
\end{equation}

#### Discrete version:

\begin{equation}
\boxed{x_n=\frac{1}{N}\sum_{k=0}^{N-1} X_k e^{\displaystyle i\frac{2\pi}{N}kn}}
\end{equation}

```python
# Reverse the discrete Fourier transform:
ifft_2 = ifft(FFT)
fig, ax = plt.subplots(1, 2, figsize=(9,4))
ax[0].plot(x, np.sin(6*np.pi*x)*np.exp(-2*np.pi* x), label='f(x)', color='dodgerblue')
ax[0].plot(x, ifft_2, '--', label='iFT(FT(f(x)))', color='crimson')
ax[0].set_xlabel('x')
ax[0].legend()

ax[1].plot(x, np.sin(6*np.pi*x)*np.exp(-2*np.pi* x)-ifft_2, color='dodgerblue')
ax[1].set_xlabel('x')
ax[1].set_ylabel('f(x)-iFT(FT(x))')
plt.tight_layout()

```

# Part 6: Observation effects

### Example 6.1: Calculate the FFT of the sin function defined on the following intervals:

```python
fig, ax =plt.subplots(1,3, figsize=(10,4))
fig2, ax2 =plt.subplots(1,3, figsize=(10,4))

#1st interval
x1 = np.linspace(-2*np.pi,3.5*np.pi,1000)

y1 = np.sin(x1)
ax[0].plot(x1,y1, color='dodgerblue')
FFT1 = np.fft.fft(y1)
nu1 = np.fft.fftfreq(len(x1), d=x1[1]-x1[0])
ax2[0].plot(nu1, np.abs(FFT1), 'o', color='dodgerblue', markersize=5)
wh1 = np.argmax(np.abs(FFT1))
ax2[0].plot(nu1[wh1], np.abs(FFT1)[wh1], '+', color='crimson', markersize=10)
print('Period from FFT 1 = '+str(1/nu1[wh1]))

#2nd interval
x2 = np.linspace(-2*np.pi,35*np.pi,1000)

y2 = np.sin(x2)
ax[1].plot(x2,y2, color='dodgerblue')
FFT2 = np.fft.fft(y2)
nu2 = np.fft.fftfreq(len(x2), d=x2[1]-x2[0])
ax2[1].plot(nu2, np.abs(FFT2), 'o', color='dodgerblue', markersize=5)
ax2[1].set_xlim(0,1)
wh2 = np.argmax(np.abs(FFT2))
ax2[1].plot(nu2[wh2], np.abs(FFT2)[wh2], '+', color='crimson', markersize=10)
print('Period from FFT 2 = '+str(1/nu2[wh2]))

#3rd interval
x3 = np.linspace(-2*np.pi,64*np.pi,1000)

y3 = np.sin(x3)
ax[2].plot(x3,y3, color='dodgerblue')
FFT3 = np.fft.fft(y3)
nu3 = np.fft.fftfreq(len(x3), d=x3[1]-x3[0])
ax2[2].plot(nu3, np.abs(FFT3), 'o', color='dodgerblue', markersize=5)
wh3 = np.argmax(np.abs(FFT3))
ax2[2].plot(nu3[wh3], np.abs(FFT3)[wh3], '+', color='crimson', markersize=10)
print('Period from FFT 3 = '+str(1/nu3[wh3]))
print('True Period= '+str(2*np.pi))

ax2[0].set_xlim(0,1)
ax2[1].set_xlim(0,1)
ax2[2].set_xlim(0,1)

ax[0].set_xlabel('x')
ax[0].set_ylabel('sin(x)')
ax[1].set_xlabel('x')
ax[2].set_xlabel('x')

ax2[0].set_xlabel(r'$\nu$')
ax2[0].set_ylabel('FFT 1(sin(x))')
ax2[1].set_xlabel(r'$\nu$')
ax2[1].set_ylabel('FFT 2(sin(x))')
ax2[2].set_xlabel(r'$\nu$')
ax2[2].set_ylabel('FFT 3(sin(x))')

fig.tight_layout()
fig2.tight_layout()

```

### Conclusion:
* The duration of the time serie matters: the longest the time serie, the better the frequency resolution in the FFT (see panel 2 VS panel 3).
* When the number of periods in the acquisition is not an integer, the endpoints are discontinuous. These artificial discontinuities show up in the FFT as high-frequency components not present in the original signal (see panel 1 VS panel 2).

## PSD and amplitudes

Check out how the longer the time series is (and the finer the frequency bin width/step is), the higher the amplitude of the FFT becomes! This means you can't really use FFTs to compare and quantify vibration environments.

Power spectrum densities (PSDs) are normalized to the frequency bin width preventing the duration of the data set (and corresponding frequency step) from changing the amplitude of the result. The definition the power spectral density of a continuous function is the Fourier transform of its auto-correlation function

\begin{equation}
{\rm PSD}(\nu)=\lim_{T\rightarrow \infty}\frac{1}{T}|{\rm FT}(\nu)|^2
\end{equation}

```python
#rng = np.random.default_rng()
fs = 10e3
N = 1e3
amp = 2*np.sqrt(2)
freq = 1234.0
noise_power = 0.001 * fs / 2
time = np.arange(N) / fs
x = amp*np.sin(2*np.pi*freq*time)
f, Pxx_den = signal.welch(x, fs, nperseg=1024)
fig, ax = plt.subplots(2,3, figsize=(9,6))
ax[0,1].semilogy(f, Pxx_den, color='dodgerblue')
ax[0,1].set_ylim([0.5e-3, 1])
ax[0,1].set_xlabel('frequency [Hz]')
ax[0,1].set_ylabel('PSD')
ax[0,2].set_ylabel('FFT')
ax[0,2].plot(np.fft.fftfreq(len(x), d=time[1]-time[0]), np.abs(np.fft.fft(x)), color='dodgerblue')
ax[0,2].set_xlim(0, np.max(np.fft.fftfreq(len(x), d=time[1]-time[0])))
ax[0,0].plot(x, color='dodgerblue', lw=0.1)

N = 1e5
time = np.arange(N) / fs
x = amp*np.sin(2*np.pi*freq*time)
f, Pxx_den = signal.welch(x, fs, nperseg=1024)
ax[1,1].semilogy(f, Pxx_den, color='dodgerblue')
ax[1,1].set_ylim([0.5e-3, 1])
ax[1,1].set_xlabel('frequency [Hz]')
ax[1,1].set_ylabel('PSD')
ax[1,2].set_ylabel('FFT')
ax[1,2].plot(np.fft.fftfreq(len(x), d=time[1]-time[0]), np.abs(np.fft.fft(x)), color='dodgerblue')
ax[1,2].set_yscale('log')
ax[1,2].set_xlim(0, np.max(np.fft.fftfreq(len(x), d=time[1]-time[0])))
ax[1,0].plot(x, color='dodgerblue', lw=0.01)
plt.tight_layout()
```

### Problem 6.2: Effect of gaps in data

 Compare the FFT of the following data set and the FFT obtained when removing some data points.
 What is the effect of gaps in the FFT?

```python
fig, ax =plt.subplots(1,2, figsize=(10,4))
fig2, ax2 =plt.subplots(1,2, figsize=(10,4))

#1st interval
x = np.linspace(-2*np.pi,64*np.pi,1000)
y = np.sin(x)
ax[0].plot(x,y, color='crimson')
FFT = #------TO FILL------
nu = #------TO FILL------
ax2[0].plot(nu, np.abs(FFT), 'o', color='crimson', markersize=5)

#calculate period
#------TO FILL------
print('Period from FFT 1 = '+str(#------TO FILL------))

#Create artificial gaps in the data
y[np.where((x>0) & (x<8*np.pi))]=0
y[np.where((x>15*np.pi) & (x<20*np.pi))]=0
y[np.where((x>45*np.pi) & (x<46*np.pi))]=0

ax[1].plot(x,y, color='crimson')
FFT = #------TO FILL------
nu = #------TO FILL------
ax2[1].plot(nu, np.abs(FFT), 'o', color='crimson', markersize=5)
ax2[1].plot(nu, np.abs(FFT),  color='crimson', markersize=5)

#calculate period
#------TO FILL------
print('Period from FFT 2 = '+str(#------TO FILL------))

ax2[0].set_xlim(0,1)
ax2[1].set_xlim(0,1)

ax[0].set_xlabel('x')
ax[0].set_ylabel('y')
ax[1].set_xlabel('x')

ax2[0].set_xlabel(r'$\nu$')
ax2[0].set_ylabel('FFT(y)')
ax2[1].set_xlabel(r'$\nu$')

fig.tight_layout()
fig2.tight_layout()
```

# Part 7: Let's play with real data!

### We are gonna try to extract the rotation period of a solar-type star observed by Kepler.

Its identifier is KIC 3541346, and has been studied by Vida+ 2014: https://arxiv.org/abs/1404.4359

Kepler observed a region in the sky during about 4 years, divided into quarters.

We are using the Lightkurve suite (Lightkurve Collaboration 2018). Documentation: https://docs.lightkurve.org

```python
# ---excecute this cell---

#Download KIC 3541346 lightcurve (flux VS time):
search_result = lk.search_lightcurve('KIC 3541346', author='Kepler', cadence='long')
print(search_result)

```

The star has been observed during 17 quarters. Let's represent its lightkurve.

```python
# ---Excecute this cell---
#light curve representation:
lc_collection = search_result.download_all()
#we stitch all the quarters together:
lc_stitched = lc_collection.stitch()
lc_stitched.plot();

```

#### Can we apply a FFT directly on this data?

---> No because time array is not evenly spaced.

---> Fill time gaps with 1.00 values to create regular time array

```python
# ---Fill and excecute this cell---

#extract time and flux from lightcurve:
time = lc_stitched.time.to_value(format='jd')
flux = lc_stitched.flux.to_value()

# create new evenly-spaced time/flux arrays (time_even, flux_even):
# Tip: plot np.diff(time):
plt.figure()
plt.plot(np.diff(time), color='dodgerblue')

#chose threshold:
wh = np.where(np.diff(time)>0.03)

dt=time[1]-time[0]

time_even=[]
flux_even=[]

for ii in range(len(time[1:])):
    if time[ii]-time[ii-1]<0.03:
        time_even=np.append(time_even, [time[ii]], axis=0)
        flux_even=np.append(flux_even, [flux[ii]], axis=0)

    else:
        ind = 0
        while time[ii]-(time[ii-1]+ind* dt)>0.03:
            ind+=1
            time_even=np.append(time_even, [time[ii-1]+ind* dt], axis=0)
            flux_even=np.append(flux_even, [1], axis=0)
        if time[ii]-(time[ii-1]+ind* dt)<=0.03:
            time_even=np.append(time_even, [time[ii]], axis=0)
            flux_even=np.append(flux_even, [flux[ii]], axis=0)

#check that new time_even is ~ regularly spaced
plt.plot(np.diff(time_even), color='orange')
plt.ylabel('Time diff [days]')

#we artificially make the time array perfectly regular for this project. See Lomb-Scargle course for dealing with non-regular data.
time =time_even#[0:1000]
time_res=(time[-1]-time[0])/len(time)
time = [time[0]+ii*time_res for ii in range(len(time))]
flux =flux_even#[0:1000]
flux = np.nan_to_num(flux, copy=True, nan=1, posinf=None, neginf=None)

#plot new lightcurve
plt.figure()
plt.plot(time, flux, color='dodgerblue')
plt.ylabel('Time [days]')
plt.ylabel('Flux')

```

### Then compute FFT of the time serie:

```python
# ---Fill and excecute this cell---
time_res=(time[1]-time[0])#/len(time)
# Number of sample points
N = len(flux)
# sample spacing
T = time_res

yf = np.fft.fft(flux)
xf = np.fft.fftfreq(N, T)[1:N//2]

fig, ax = plt.subplots(1,2, figsize=(9,4))
ax[0].set_yscale('log')
ax[0].plot(xf, np.abs(yf[1:N//2]), color='dodgerblue')

wh = np.argwhere((np.abs(yf[10:N//2])==np.max(np.abs(yf[10:N//2]))) )
frequency_max = xf[9:][wh]
Period = 1/frequency_max[0][0]
ax[0].plot(frequency_max[0][0],np.abs(yf[10:N//2])[wh], '+', color='dodgerblue', markersize=5 )
ax[0].set_xlabel(r'Frequency [day$^{-1}$]')
ax[0].set_ylabel(r'FFT Amplitude')

print('Period from FFT1 = '+str(Period)+' days')

#compare with shorter time serie:
time2 = time[:130]
flux2 = flux[:130]
# Number of sample points
N = len(flux2)
# sample spacing
T = time_res

yf = np.fft.fft(flux2)
xf = np.fft.fftfreq(N, T)[1:N//2]

ax[1].set_yscale('log')
ax[1].plot(xf, np.abs(yf[1:N//2]), color='dodgerblue')

wh = np.argwhere((np.abs(yf[1:N//2])==np.max(np.abs(yf[1:N//2]))) )
frequency_max2 = xf[:][wh]
Period2 = 1/frequency_max2[0][0]
ax[1].plot(frequency_max2[0][0],np.abs(yf[1:N//2])[wh], '+', color='dodgerblue', markersize=5 )
ax[1].set_xlabel(r'Frequency [day$^{-1}$]')
ax[1].set_ylabel(r'FFT Amplitude')

print('Period from FFT2 = '+str(Period2)+' days')
```

### Let's check the FFT periodicity on the data:

```python
# ---excecute this cell, and check that your periodicity is correct:---

plt.figure()
plt.plot(time[:1300], flux[:1300], color='dodgerblue')
plt.vlines(np.array([time[0]+Period*ii for ii in range(30)]), 0.95, 1.1, linestyles ="dotted", colors ="k")
plt.xlabel('Time [days]')
plt.ylabel(r'Flux e$^-$/s')

```

#### What do other peaks represent?

## Your turn! Find the rotation period of KIC 10515986

```python

```

# Part 8: Challenge Problem! Determine the Mass and Radius of KIC 4682420

Credits IAC.

Ref: García & Ballot 2019

### 8.1) Represent the FFT of KIC 4682420 and identify the components.
### 8.2) Measure the frequency of maximum power. (numax)
### 8.3) Measure the large frequency separation. (deltanu)

### 8.4) Measure the Mass and Radius of the star by using the Asteroseismic scaling relations:

#### Asteroseismic scaling relations    (Bedding+ 2011):

\begin{equation}
\boxed{\frac{M}{{\rm M}_\odot}\approx\left(\frac{\nu_{\rm max}}{\nu_{{\rm max}_\odot}}\right)^3
\left(\frac{\Delta\nu}{\Delta\nu_\odot}\right)^{-4} \left(\frac{T_{\rm eff}}{T_{{\rm eff}_\odot}}\right)^{3/2} }
\end{equation}

\begin{equation}
\boxed{\frac{R}{{\rm R}_\odot}\approx\left(\frac{\nu_{\rm max}}{\nu_{{\rm max}_\odot}}\right)
\left(\frac{\Delta\nu}{\Delta\nu_\odot}\right)^{-2} \left(\frac{T_{\rm eff}}{T_{{\rm eff}_\odot}}\right)^{1/2} }
\end{equation}

with $$\nu_{{\rm max}_\odot} \approx 3090 {\rm μHz}$$ and $$\Delta\nu_{\odot} \approx 135.1 {\rm μHz}$$

```python

#Download KIC 4682420 lightcurve (flux VS time):
search_result = lk.search_lightcurve('#------TO FILL------', author='Kepler', cadence='long')
print(search_result)

#represente light curve:
lc_collection = search_result.download_all()
#we stitch all the quarters together:
lc_stitched = #------TO FILL------
lc_stitched.plot();

#extract time and flux from lightcurve:
time = #------TO FILL------
flux = #------TO FILL------

# create new evenly-spaced time/flux arrays (time_even, flux_even):
# Tip: first, plot np.diff(time):
#------TO FILL------

#check that new time_even doesn't have any gaps
#------TO FILL------

#we artificially make the time array perfectly regular for this project. See Lomb-Scargle course for dealing with non-regular data.
time =time_even
time_res=(time[-1]-time[0])/len(time)
time = [time[0]+ii*time_res for ii in range(len(time))]
flux =flux_even
flux = np.nan_to_num(flux, copy=True, nan=1, posinf=None, neginf=None)

#plot new lightcurve
plt.figure()
plt.plot(time, flux, color='crimson')
plt.ylabel('Time [days]')
plt.ylabel('Flux')

# Number of sample points
N = #------TO FILL------
# sample spacing
T = #------TO FILL------

#compute FFT (or PSD...)
yf = #------TO FILL------
xf = #------TO FILL------

fig, ax = plt.subplots(1,1, figsize=(9,4))
ax.set_yscale('log')
ax.plot(xf, np.abs(yf[1:N//2]), color='crimson')

ax.set_xlabel(r'Frequency [day$^{-1}$]')
ax.set_ylabel(r'FFT Amplitude')

#Then measure numax and delta nu
#Then compiute Mass and Radius estimates
```