By Almog Yalinewich

```
import sympy
sympy.init_printing()
a=1|
print(a)
```

# Introduction

One of the greatest scientific successes in the 20th century is explaining how stars work. The major breakthrough came in 1920, when Arthur Eddington proposed that stars are powered by nuclear fusion. This insight lead to long series of discoveries, and new scientific disciplines, namely stellar structure and evolution. In this notebook we will focus on the relation between the masses, radii and luminosities of stars. These relations give rise to a pattern known as the [main sequence](https://en.wikipedia.org/wiki/Main_sequence), as can be seen on the [Herzsprung-Russell diagram](https://en.wikipedia.org/wiki/Hertzsprung%E2%80%93Russell_diagram). We start this lecture with an overview of some relevant radiative processes. We begin the lecture by considering the journey radiant energy makes on its way from the core where it is generated to the stellar surface. The phenomena that determine the properties of this radiative energy  are called radiative processes.

# General Instruction

In the following notebook you will find an interactive tutorial on radiative processes and stellar evolution. Each exercise aims to teach one concept, and will guide you through the derivation. Each exercise is made up of several blocks that look like this:

```python
print("a) what is...?")
answer_a = sympy.Integer(0)
display(answer_a)
print('unit check')
display(answer_a.subs(unit_check))
print(validate.exercise_1_a(answer_a))
```

You should enter the solution in the second line. The fifth line performs a unit check, and the sixth line checks if the the answer is true. We will be using the sympy package for symbolic calculations.

## Sympy Cheat Sheet

Arithmetic operations, i.e. $+,-,*,/$ work as you would expect. Raising to a power is denoted by two asterisks **

You can substitute an expression into a variable using the subs method

```
def subs_demo():

    x, y = sympy.symbols('x, y')

    expr = sympy.sin(y).subs(y, x**2)
    return expr
subs_demo()
a=1
print(a)
```

You can solve equations using the solve method

```
def solve_demo():

    x = sympy.Symbol('x')

    eqn = sympy.Eq(3*x,6) # Creates an equation
    return sympy.solve(eqn,x)[0] # Solve returns a list since an equation can have multiple solutions
solve_demo()
```

Note that you don't have to use an equation object. If you apply solve to some expression than it will assume you want to find the roots of that expression, e.g.

```
def solve_demo2():

    x = sympy.Symbol('x')

    eqn = 3*x-6 # Creates an equation
    return sympy.solve(eqn,x)[0] # Solve returns a list since an equation can have multiple solutions
solve_demo2()
```

If you want to raise an expression to a fractional power, then this

```python
x**(1/3)
```

returns a result different from

```python
x**sympy.Rational(1,3)
```

and you should always use the latter

Good luck!

# Radiative Processes

Stars are the result of a balance between two forces: one the one hand there's gravity, which tends to squeeze the the star, and on the other hand there's thermal pressure sustain by thermonuclear burning which tends to expand the star. The same thermal energy that supports the star against gravity is constantly leaking out of the star in the form of radiation, which is why we can see them.

## Larmor Forumula

Let us consider a charge $q$ that moves to the left with velocity $v$, and at some point comes to a halt within a short period of time $\Delta t$. The information about the sudden stop travels at the speed of light $c$. The field outside the light front is still radial relative to the previous trajectory of the particle, and interior to the light front the field is radial relative to the new trajectory. Inside a thin shell of thickness $c \Delta t$ the field transitions from one configuration to the other.

Let us focus on what happens directly above the terminal position of the particle (along the y axis)

Guided derivation

```
def exercise_1():

    import validate
    import importlib
    importlib.reload(validate)

    R = sympy.Symbol('R', positive=True) # Distance from the terminal position
    c = sympy.Symbol('c', positive=True) # speed of light
    v = sympy.Symbol('v', positive=True) # Particle velocity
    q = sympy.Symbol('q', positive=True) # Electric charge
    a = sympy.Symbol('a', positive=True) # Acceleration

    # For unit checks
    mass = sympy.Symbol(r'\mathcal{M}', positive=True)
    length = sympy.Symbol(r'\mathcal{L}', positive=True)
    time = sympy.Symbol(r'\mathcal{T}', positive=True)
    unit_check = {R:length,
                  c:length/time,
                  v:length/time,
                  q:sympy.sqrt(mass*length**3/time**2),
                  a:length/time**2}

    # Section a
    print("a) How long does it take for the light front to reach a distance R?")
    answer_a = R/c
    display(answer_a)
    print('unit check')
    display(answer_a.subs(unit_check))
    print(validate.exercise_1_a(answer_a))

    # Section b
    print('b) Had the particle kept on moving at velocity v, what distance would it traverse?')
    answer_b = sympy.Integer(0)
    display(answer_b)
    print('unit check')
    display(answer_b.subs(unit_check))
    print(validate.exercise_1_b(answer_b))

    # Section c
    print('c) Given the acceleration and velocity, how long does it take for the particle to halt?')
    answer_c = sympy.Integer(0)
    display(answer_c)
    print('unit check')
    display(answer_c.subs(unit_check))
    print(validate.exercise_1_c(answer_c))

    # Section d
    print("d) Using Coulomb's law, what is the radial electric field close to the light front?")
    answer_d = sympy.Integer(0)
    display(answer_d)
    print('unit check')
    display(answer_d.subs(unit_check))
    print(validate.exercise_1_d(answer_d))

    # Section e
    print('e) Using the geometric construction above, find the tangential component of the electric field')
    answer_e = sympy.Integer(0)
    display(answer_e)
    print('unit check')
    display(answer_e.subs(unit_check))
    print(validate.exercise_1_e(answer_e))

    # Section f
    print('f) Find the energy density of the tangential electric field')
    answer_f = sympy.Integer(0)
    display(answer_f)
    print('unit check')
    display(answer_f.subs(unit_check))
    print(validate.exercise_1_f(answer_f))

    # Section g
    print('g) Find the energy flux')
    answer_g = sympy.Integer(0)
    display(answer_g)
    print('unit check')
    display(answer_g.subs(unit_check))
    print(validate.exercise_1_g(answer_g))

    # Section h
    print('h) Find the luminosity assuming the flux does not change considerably across the light front')
    answer_h = sympy.Integer(0)
    display(answer_h)
    print('unit check')
    display(answer_h.subs(unit_check))
    print(validate.exercise_1_h(answer_h))

exercise_1()
```

This last result (up to a numerical prefactor) is called the [Larmor formula](https://en.wikipedia.org/wiki/Larmor_formula). See [here](https://blog.cupcakephysics.com/electromagnetism/2014/11/23/thomsons-derivation-of-the-larmor-formula.html) for a more rigorous derivation.

## Bremsstrahlung / Free - free emission

When a free electron passes near a free proton, the electron gets scattered. From Larmor's formula we know that in that process the electron must emit some radiation. We assume that the electron is moving so fast that the scattering is small. We will also employ the so called impulse approximation, where all the interaction happens at the point of closest approach. Another thing to note is that such scatterings happen at all impact parameters, but the radiation is dominated by those that happen with the smallest separation, which is given by the [de Broglie wavelength](https://en.wikipedia.org/wiki/Matter_wave) of the electron. In this section we will estimate the amount of energy emitted in this process.

```
def exercise_2():

    import validate
    import importlib
    importlib.reload(validate)

    h = sympy.Symbol('h', positive=True) # Planck constant
    v = sympy.Symbol('v', positive=True) # Velocity
    m = sympy.Symbol('m', positive=True) # Electron mass
    q = sympy.Symbol('q', positive=True) # Elementary charge
    c = sympy.Symbol('c', positive=True) # Speed of light
    n = sympy.Symbol('n', positive=True) # Number density

    # Unit check
    mass = sympy.Symbol(r'\mathcal{M}', positive=True)
    length = sympy.Symbol(r'\mathcal{L}', positive=True)
    time = sympy.Symbol(r'\mathcal{T}', positive=True)
    energy = mass*length**2/time**2
    unit_check = {h:energy*time,
                  v:length/time,
                  m:mass,
                  q:sympy.sqrt(energy*length),
                  c:length/time,
                  n:length**-3}

    print("a) Due to Heisenberg's uncertainty principle, the minimum distance between the electron and proton cannot"+
          "drop below the de Broglie wavelength. What is the de Broglie wavelength?")
    answer_a = sympy.Integer(0)
    display(answer_a)
    print('unit check')
    display(answer_a.subs(unit_check))
    print(validate.exercise_2_a(answer_a))

    print("b) Find the electron's acceleration at the point of closest approach")
    answer_b = sympy.Integer(0)
    display(answer_b)
    print('unit check')
    display(answer_b.subs(unit_check))
    print(validate.exercise_2_b(answer_b))

    print("c) Find the luminosity at that point")
    answer_c = sympy.Integer(0)
    display(answer_c)
    print('unit check')
    display(answer_c.subs(unit_check))
    print(validate.exercise_2_c(answer_c))

    print("d) How long does the electron spend close to the proton,"+
          " i.e. at a distance comparable to the minimal distance?")
    answer_d = sympy.Integer(0)
    display(answer_d)
    print('unit check')
    display(answer_d.subs(unit_check))
    print(validate.exercise_2_d(answer_d))

    print("e) How much energy is emitted in a single interaction?")
    answer_e = sympy.Integer(0)
    display(answer_e)
    print('unit check')
    display(answer_e.subs(unit_check))
    print(validate.exercise_2_e(answer_e))

    print("f) What is the cross section for this interaction?")
    answer_f = sympy.Integer(0)
    display(answer_f)
    print('unit check')
    display(answer_f.subs(unit_check))
    print(validate.exercise_2_f(answer_f))

    print("g) What is the average time interval between consecutive interactions?")
    answer_g = sympy.Integer(0)
    display(answer_g)
    print('unit check')
    display(answer_g.subs(unit_check))
    print(validate.exercise_2_g(answer_g))

    print("h) What is the average luminosity for a single electron?")
    answer_h = sympy.Integer(0)
    display(answer_h)
    print('unit check')
    display(answer_h.subs(unit_check))
    print(validate.exercise_2_g(answer_g))

    print("i) What is the emissivity (luminosity per unit volume)?")
    answer_i = sympy.Integer(0)
    display(answer_i)
    print('unit check')
    display(answer_i.subs(unit_check))
    print(validate.exercise_2_i(answer_i))

exercise_2()
```

In thermal equilibrium, the velocity is given by the thermal velocity $v \approx \sqrt{k T/m}$. Thus we have an expression for the emissivity that only depends on the plasma's density, temperature and physical constants.

In the next section we will determine the spectrum of free free emission. For this purpose we will consider interactions between electrons and protons with an impact parameter larger than the minimum discussed in the previous question. We denote the impact parametr as $b$, and the thickness of annulus by $\Delta b \ll b$. Hint: you can use the expression from the previous section with the substitution $b = h/m v$.

The luminosity is given by

$$ \Delta L = \frac{n^2 q^6 \Delta b}{c^3 m^2 b^2}$$

The duration of the emission is $b/v$, the frequency is the reciprocal of the time, so $v/b$, and the photon energy is therefore $h v/b$. The range of frequencies produced by electrons entering the annulus is

$\Delta \nu \approx \frac{v \Delta b}{b^2}$

The luminosity per frequency is

$\frac{\Delta L}{\Delta \nu} \approx \frac{n^2 q^6}{c^3 m^2 v}$

and so we get a flat spectrum, which cuts off when the photon energy is roughly equal to the kinetic energy of the electron $m v^2$.

## Compton scattering

Compton scattering is the name given to the scattering of photons by free electrons. In the next exercise we will calculate the cross section for this process. For this purpose, we will consider a slow EM wave with amplitude $E$. The field accelerates the electron, which in turn produces EM radiation. This emitted radiation comes at the expense of the incident wave, and so we get scattering.

```
def exercise_3():

    import validate
    import importlib
    importlib.reload(validate)

    q = sympy.Symbol('q', positive=True) # Elementary charge
    E = sympy.Symbol('E', positive=True) # Amplitude of the EM field
    m = sympy.Symbol('m', positive=True) # Mass of the electron
    c = sympy.Symbol('c', positive=True) # Speed of light

    # Unit check
    mass = sympy.Symbol(r'\mathcal{M}', positive=True)
    length = sympy.Symbol(r'\mathcal{L}', positive=True)
    time = sympy.Symbol(r'\mathcal{T}', positive=True)
    energy = mass*length**2/time**2
    charge = sympy.sqrt(energy*length)
    unit_check = {q:charge,
                  E:charge/length**2,
                  m:mass,
                  c:length/time}

    print('a) What is the amplitude of the acceleration of the electron?')
    answer_a = sympy.Integer(0)
    display(answer_a)
    print('Unit check')
    display(answer_a.subs(unit_check))
    print(validate.exercise_3_a(answer_a))

    print('b) What is the corresponding Larmor luminosity?')
    answer_b = sympy.Integer(0)
    display(answer_b)
    print('Unit check')
    display(answer_b.subs(unit_check))
    print(validate.exercise_3_b(answer_b))

    print('c) What is the incident flux?')
    answer_c = sympy.Integer(0)
    display(answer_c)
    print('Unit check')
    display(answer_c.subs(unit_check))
    print(validate.exercise_3_c(answer_c))

    print('d) What is the cross section?')
    answer_d = sympy.Integer(0)
    display(answer_d)
    print('Unit check')
    display(answer_d.subs(unit_check))
    print(validate.exercise_3_d(answer_d))

exercise_3()
```

The last expression is called the Thompson cross section. The cross section for scattering does not depend on the amplitude of the wave or the frequency. The length scale associated with this cross section is called the classical electron radius $r_e = q^2/m c^2$

## Blackbody Emission

Illustration of the density of state

Blackbody spectrum

In this section we discuss [blackbody emission](https://en.wikipedia.org/wiki/Black-body_radiation). In this section we present a simplified derivation for the low end of the spectrum, also known as the Rayleigh Jeans tail. This is largely based on the derivation [here](http://hyperphysics.phy-astr.gsu.edu/hbase/quantum/rayj.html).

```
def exercise_4():

    import validate
    import importlib
    importlib.reload(validate)

    nu = sympy.Symbol('nu', positive=True) # Frequency
    c = sympy.Symbol('c', positive=True) # Speed of light
    kT = sympy.Symbol('kT', positive=True) # Temperature

    # Unit check
    mass = sympy.Symbol(r'\mathcal{M}', positive=True)
    length = sympy.Symbol(r'\mathcal{L}', positive=True)
    time = sympy.Symbol(r'\mathcal{T}', positive=True)
    energy = mass*length**2/time**2
    unit_check = {nu:1/time,
                  c:length/time,
                  kT:energy}

    print('a) What is the wavelength?')
    answer_a = sympy.Integer(0)
    display(answer_a)
    print('unit check')
    display(answer_a.subs(unit_check))
    print(validate.exercise_4_a(answer_a))

    print('b) According to the equipartition theorem, in equilibrium '+
          'every degree of freedom gets an energy comparable to kT.'+
          'When considering a waves of a certain wavelength, '+
          'assume you can divid space into cubes whose side is comparable to the wavelength, '+
          'and each is a degree of freedom. What is the energy density?')
    answer_b = sympy.Integer(0)
    display(answer_b)
    print('unit check')
    display(answer_b.subs(unit_check))
    print(validate.exercise_4_b(answer_b))

    print('c) What is the flux per unit frequency?')
    answer_c = sympy.Integer(0)
    display(answer_c)
    print('unit check')
    display(answer_c.subs(unit_check))
    print(validate.exercise_4_c(answer_c))

exercise_4()
```

The last relation is called the Rayleigh Jeans tail. This spectrum increases indefinitely with frequency (ultraviolet catastrophy). In reality, the spectrum is truncated when the photon energy is comparable to the thermal energy $h \nu \approx k T$. By integrating over $\nu$ from $0$ to $kT/h$ we get the Stefan Boltzmann law

$$ f \approx \int_0^{kT/h} k T \frac{\nu^2}{c^2} d\nu \approx \frac{k^4 T^4}{c^2 h^3}$$

We also note that from the results above it is possible to find the blackbody energy density

$$ k T \frac{\nu^3}{c^3} |_{\nu = k T/h} = \frac{k^4 T^4}{h^3 c^3} $$

## Detailed Balance and Kramer's Opacity

So far we've only discussed processes that emit photons and scatter them, but not processes that absorb them. The absorption coefficient $\alpha_{\nu}$ can be determined from the emissivity $\varepsilon_{\nu}$ using the principle of detailed balance. According to this principle, in equilibrium the number of photons created in a certain frequency must equal to the number of photons absorbed in this frequency, to maintain a constant, blackbody flux per unit frequency $B_{\nu}$.

$$\varepsilon_{\nu} = \alpha_{\nu} B_{\nu} \Rightarrow \alpha_{\nu} = \varepsilon_{\nu} / B_{\nu}$$

In the literature, the opacity $\kappa$ is sometimes used instead of absorption coefficient, and the relation between them is $\alpha = \kappa \rho$ where $\rho$ is the mass density. The units of the absorption coefficients are 1/length, and for opacity the units are length^2/mass.

```
def exercise_5():

    import validate
    import importlib
    importlib.reload(validate)

    n = sympy.Symbol('n', positive=True) # Number density
    q = sympy.Symbol('q', positive=True) # Elementary charge
    m = sympy.Symbol('m', positive=True) # Electron mass
    kT = sympy.Symbol('kT', positive=True) # Temperature
    c = sympy.Symbol('c', positive=True) # Speed of light
    nu = sympy.Symbol('nu', positive=True) # Frequency
    h = sympy.Symbol('h', positive=True) # Planck constant

    # Unit check
    mass = sympy.Symbol(r'\mathcal{M}', positive=True)
    length = sympy.Symbol(r'\mathcal{L}', positive=True)
    time = sympy.Symbol(r'\mathcal{T}', positive=True)
    energy = mass*length**2/time**2
    unit_check = {nu:1/time,
                  c:length/time,
                  kT:energy,
                  n:length**-3,
                  q:sympy.sqrt(mass*length**3/time**2),
                  m:mass,
                  h:mass*length**2/time}

    # For unit check
    length = sympy.Symbol(r'\mathcal{L}', positive=True) # Length unit
    mass = sympy.Symbol(r'\mathcal{M}', positive=True) # Mass unit
    time = sympy.Symbol(r'\mathcal{T}', positive=True) # Time unit

    print('a) Using the principle of detailed balance, find the coefficient of free - free absorption')
    emissivity = n**2*q**6*sympy.sqrt(m/kT)/c**3/m**2
    blackbody = kT*nu**2/c**2
    answer_a = sympy.Integer(0)
    display(answer_a)
    print('unit check')
    display(answer_a.subs(unit_check))
    print(validate.exercise_5_a(answer_a))

    print('b) Find the average absorption coefficient when the photon energy is comparable to the thermal energy')
    answer_b = sympy.Integer(0)
    display(answer_b)
    print('unit check')
    display(answer_b.subs(unit_check))
    print(validate.exercise_5_b(answer_b))

    print('c) Find the average free free absorption cross section')
    answer_c = sympy.Integer(0)
    display(answer_c)
    print('unit check')
    display(answer_c.subs(unit_check))
    print(validate.exercise_5_c(answer_c))

    print('d) Find the temperature at which the free free cross section is equal to the Thompson cross section')
    answer_d = sympy.Integer(0)
    display(answer_d)
    print('unit check')
    display(answer_d.subs(unit_check))
    print(validate.exercise_5_d(answer_d))

exercise_5()
```

We found that the average absorption coefficient scales as $T^{-7/2}$. This is known as [Kramer's opacity law](https://en.wikipedia.org/wiki/Kramers%27_opacity_law). We also found the condition for which this absorption will dominate over Compton scattering. Finally, we note that although this process is technically absorption, by which a photon disappears, if the medium is opaque and hot then the energy absorbed will be promptly re - emitted. In such a case, absorption behaves just the same as scattering.

To conclude, in this part we reviewed different continuum radiative processes (i.e. ones not involving lines and atomic or molecular structure). In the next section we will use them to analyse the structure of stars and explain some of the observed trends.

# End of Part 1

# Stellar Structure and Evolution

## Main Sequence Stars

Stars are supported against gravity by thermal or radiation pressure

$$ p \approx \frac{G M^2}{R^4}$$

The balance between gravity and thermal pressure is called hydrostatic equilibrium. The high temperature necessary to produce this pressure ($T_c \approx 10^7 \rm K$) is maintained through thermonuclear burning. These nuclear reactions are so sensitive to the temperature, that to a crude approximation one might assume that the core temperature is roughly constant. Outside the star the temperature is only a few thousand degrees. The temperature gradient is maintained due to photon diffusion.

Given an absorption coefficient $\alpha$ (units: reciprocal of length), the mean free path is $1/\alpha$. Without diffusion, the time it takes a photon to leave the star is just the light crossing time $R/c$. With diffusion, the photon goes back and forth and travels a much larger distance $\alpha R^2$, and so the time the photon spends inside the star is $t_d \approx \alpha R^2/c$. A commonly used quantity is the optical depth $\tau \approx \alpha R$. The radiative energy in the star is

$$U \approx R^3 k^4 T_c^4/h^3 c^3$$

and so the luminosity is

$$L \approx \frac{U}{t_d}$$

In the next exercise we explore the properties of stars with different properties

```
def exercise_6():

    import validate
    import importlib
    importlib.reload(validate)

    kT_c = sympy.Symbol('kT_c', positive=True) # Core temperature
    G = sympy.Symbol('G', positive=True) # Gravitation constant
    M = sympy.Symbol('M', positive=True) # Mass of the star
    m_p = sympy.Symbol('m_p', positive=True) # Proton mass
    m_e = sympy.Symbol('m_e', positive=True) # Electron mass
    h = sympy.Symbol('h', positive=True) # Planck constant
    q = sympy.Symbol('q', positive=True) # Elementary charge
    c = sympy.Symbol('c', positive=True) # Speed of light

    # Unit check
    mass = sympy.Symbol(r'\mathcal{M}', positive=True)
    length = sympy.Symbol(r'\mathcal{L}', positive=True)
    time = sympy.Symbol(r'\mathcal{T}', positive=True)
    energy = mass*length**2/time**2
    unit_check = {G:energy*length/mass**2,
                  m_p:mass,
                  M:mass,
                  kT_c:energy,
                  q:sympy.sqrt(energy*length),
                  h:energy*time,
                  m_e:mass,
                  c:length/time}

    # Fiducial values
    gram = sympy.Symbol(r'\rm gram', positive=True)
    cm = sympy.Symbol(r'\rm cm', positive=True)
    second = sympy.Symbol(r'\rm second', positive=True)
    erg = gram*(cm/second)**2
    fiducial_values = {G:6.7e-8*cm**3/gram/second**2,
                       m_p:1.7e-24*gram,
                       M:2e33*gram,
                       kT_c:1.4e-9*erg,
                       q:4.8e-10*sympy.sqrt(erg*cm),
                       h:6.6e-27*erg*second,
                       m_e:9e-28*gram,
                       c:3e10*cm/second}
    solar_radius = 7e10*cm
    solar_luminosity = 1e33*erg/second
    solar_mass = 2e33*gram
    R_odot = sympy.Symbol(r'R_{\odot}', positive=True) # Solar radius
    L_odot = sympy.Symbol(r'L_{\odot}', positive=True) # Solar luminosity
    M_odot = sympy.Symbol(r'M_{\odot}', positive=True) # Solar mass
    year = sympy.Symbol(r'\rm year', positive=True)

    print('a) Use the condition of hydrostatic equilibrium to obtain an expression for the radius of a' +
           ' gas dominated star')
    answer_a = sympy.Integer(0)
    display(answer_a)
    print('fiducial values')
    display(answer_a.subs(fiducial_values)*R_odot/solar_radius)
    print(validate.exercise_6_a(answer_a))

    print("b) Estimate the diffusion time assuming Kramer's mean opacity")
    answer_b = sympy.Integer(0)
    display(answer_b)
    print('fiducial values')
    display(answer_b.subs(fiducial_values)*year/1e7/second)
    print(validate.exercise_6_b(answer_b))

    print('c) Find the luminosity of the star')
    answer_c = sympy.Integer(0)
    display(answer_c)
    print('fiducial_values')
    display(answer_c.subs(fiducial_values)*L_odot/solar_luminosity)
    print(validate.exercise_6_c(answer_c))

    print('d) What would be the diffusion time for Thompson opacity instead')
    answer_d = sympy.Integer(0)
    display(answer_d)
    print('fiducial values')
    display(answer_d.subs(fiducial_values)*year/1e7/second)
    print(validate.exercise_6_d(answer_d))

    print('e) What is the luminosity for Thompson opacity')
    answer_e = sympy.Integer(0)
    display(answer_e)
    print('fiducial values')
    display(answer_e.subs(fiducial_values)*L_odot/solar_luminosity)
    print(validate.exercise_6_e(answer_e))

    print('f) Find the radius of a radiation dominated star')
    answer_f= sympy.Integer(0)
    display(answer_f)
    print('fiducial values')
    display(answer_f.subs(fiducial_values)*R_odot/solar_radius)
    print(validate.exercise_6_f(answer_f))

    print('g) Find the diffusion time from a radiation dominated star with Thompson opacity')
    answer_g = sympy.Integer(0)
    display(answer_g)
    print('fiducial values')
    display(answer_g.subs(fiducial_values)*year/1e7/second)
    print(validate.exercise_6_g(answer_g))

    print('h) Find the luminosity of a radiation dominated star with Thompson opacity')
    answer_h = sympy.Integer(0)
    display(answer_h)
    print('fiducial values')
    display(answer_h.subs(fiducial_values)*L_odot/solar_luminosity)
    print(validate.exercise_6_h(answer_h))

    print('i) Above what mass does the Thompson optical depth exceed Kramersfor a gas dominated stars?')
    answer_i = sympy.Integer(0)
    display(answer_i)
    print('fiducial values')
    display(answer_i.subs(fiducial_values)/solar_mass*M_odot)
    print(validate.exercise_6_i(answer_i))

    print('j) Above what mass does the star become radiation dominated?')
    answer_j = sympy.Integer(0)
    display(answer_j)
    print('fiducial values')
    display(answer_j.subs(fiducial_values)*M_odot/solar_mass)
    print(validate.exercise_6_j(answer_j))

exercise_6()
```

As we can see, the approximations used here are only accurate within a factor of a few. We can estimate this accuracy by comparison of the calculated luminosity and radius to those of our sun. Then again, considering how simplistic our treatment is, it is impressive that we got this close. Most importantly, we neglected the actual structure of the star. Since the star can have very different properties in different regions, different processes can be dominant in different regions. In our analysis we also neglected the structure of the star, turbulence, rotation, magnetic field etc.

We also reproduce some observational trends (e.g. [Demircan & Kahraman 1991](http://articles.adsabs.harvard.edu/pdf/1991Ap%26SS.181..313D)). Below a few solar masses the luminosity scales with mass as $L \propto M^5$, between a few and a few tens $L \propto M^3$ and at even higher masses $L \propto M$. In reality the transition will be gradual because parts of the star might be in different regimes. The lifetimes of stars roughly scales as $M/L$, so the lifetime decreases very steeply with mass, until it reaches some critical value around a few million years, at which point the mass to luminosity ratio becomes constant. This timescale is sometimes called the Eddington time.

The linear relation between luminosity and mass we obtained for massive stars has a deeper meaning. This is the highest luminosity the star can sustain. A higher luminosity will unbind material from the star. This is called Eddington's luminosity.

At the very low end of the mass spectrum the data points deviate from the steep profile we predicted. This is because for low mass stars the radiative energy does not diffuse, but is rather advected by turbulence. As a very crude approximation, one can assume that the timescale for advecting the radiative energy is comparable to the Keplerian timescale, so

$$t \propto \sqrt{R^3/M}$$

$$R \propto M$$

$$U \propto R^3$$

$$L \approx \frac{U}{t} \propto R^{3/2} M^{1/2} \propto M^2$$

We can also use the Blackbody formulae to find the surface temperature dependence on mass or luminosity

Low masses

$$ R \propto M, L \propto M^2 \Rightarrow T \propto \left(L/R^2\right)^{1/4} \propto M^0 \propto L^{0}$$

Medium masses

$$ R \propto M, L \propto M^5 \Rightarrow T \propto \left(L/R^2\right)^{1/4} \propto M^{3/4} \propto L^{3/20}$$

High masses

$$ R \propto M, L \propto M^3 \Rightarrow T \propto \left(L/R^2\right)^{1/4} \propto M^{1/4} \propto L^{1/12}$$

Very high masses

$$ R \propto M^{1/2}, L \propto M \Rightarrow T \propto \left(L/R^2\right)^{1/4} \propto M^{0} \propto L^{0}$$

Some of these trends can be seen in the famous [HR diagram](https://en.wikipedia.org/wiki/Hertzsprung%E2%80%93Russell_diagram)

## Minimum Stellar Mass

In the next exercise we will consider the minimum mass of a star. Stars form from gravitationally collapsing gas clouds. If the clouds are massive enough the collapse triggers thermonuclear reactions that in turn produce thermal pressure that halts the collapse. If the clouds are not massive enough, electron degeneracy pressure halts the collpase and the object never gets hot enough to trigger a significant amount of thermonuclear burning. Such objects are called brown dwarfs. We start this exercise by analysing the properties of an object supported by electron degeneracy pressure, starting from the Heisenberg uncertainty principle $\Delta x \cdot \Delta p \ge h$

```
def exercise_7():

    import validate
    import importlib
    importlib.reload(validate)

    n = sympy.Symbol('n', positive=True) # Number density
    h = sympy.Symbol('h', positive=True) # Planck constant
    m_e = sympy.Symbol('m_e', positive=True) # Electron mass
    M = sympy.Symbol('M', positive=True) # Object mass
    m_p = sympy.Symbol('m_p', positive=True) # Proton mass
    G = sympy.Symbol('G', positive=True) # Gravitation constant
    kT = sympy.Symbol('kT', positive=True) # Necessary temperature for nuclear burning

    # Unit check
    mass = sympy.Symbol(r'\mathcal{M}', positive=True) # Mass
    length = sympy.Symbol(r'\mathcal{L}', positive=True) # Length
    time = sympy.Symbol(r'\mathcal{T}', positive=True) # Time
    energy = mass*(length/time)**2
    unit_check = {n:length**-3,
                  h:energy*time,
                  m_e:mass,
                  m_p:mass,
                  M:mass,
                  G:energy*length/mass**2,
                  kT:energy}

    print('a) Given the number density n, what is the average distance between particles?')
    answer_a = sympy.Integer(0)
    display(answer_a)
    print('unit check')
    display(answer_a.subs(unit_check))
    print(validate.exercise_7_a(answer_a))

    print('b) Using the uncertainty principle and the result of the previous section as uncertainty in position,'+
          ' what is the typical momentum? (this is called the Fermi momentum)')
    answer_b = sympy.Integer(0)
    display(answer_b)
    print('unit check')
    display(answer_b.subs(unit_check))
    print(validate.exercise_7_b(answer_b))

    print('c) What is the typical kinetic energy associated with momentum from the previous section?')
    answer_c = sympy.Integer(0)
    display(answer_c)
    print('unit check')
    display(answer_c.subs(unit_check))
    print(validate.exercise_7_c(answer_c))

    print('d) What is the energy density?')
    answer_d = sympy.Integer(0)
    display(answer_d)
    print('unit check')
    display(answer_d.subs(unit_check))
    print(validate.exercise_7_d(answer_d))

    print('e) Use the answer from the previous section as pressure, '+
          'equate it to the gravitational pressure and find the radius')
    answer_e = sympy.Integer(0)
    display(answer_e)
    print('unit check')
    display(answer_e.subs(unit_check))
    print(validate.exercise_7_e(answer_e))

    print('f) Find the mass for which the gas dominated temperature is equal to the nuclear burning temperature')
    answer_f = sympy.Integer(0)
    display(answer_f)
    print('unit check')
    display(answer_f.subs(unit_check))
    print(validate.exercise_7_f(answer_f))

exercise_7()
```

Substituting numbers yields [0.02](https://www.wolframalpha.com/input/?i=%28boltzmann+constant%29%5E%283%2F4%29*%281e7+kelvin%29%5E%283%2F4%29*%28%28planck+constant%29%2F%282*pi%29%29%5E%283%2F2%29%2F%28gravitation+constant%29%5E%283%2F2%29%2F%28proton+mass%29%5E2%2F%28electron+mass%29%5E%283%2F4%29%2F%28solar+mass%29) $M_{\odot}$. A more [detailed calculation](https://ui.adsabs.harvard.edu/abs/1962AJ.....67S.579K/abstract) puts the limit at around 0.07 $M_{\odot}$. Gas clouds below this limit collapse to form brown dwarfs, and above this limit they collapse to form stars.

Another oddity worth noting is that for a degenerate object, the radius decreases with the mass, as opposed to almost anything else. A consequence of this anomaly is that planets / brown dwarfs have a maximum radius, but this a topic for another lecture.

## Maximum stellar mass

In the previous section we discussed the minimum mass of a star, but is there a maximum mass? It turns out there is. To understand this limit, we need to understand the stability of stars. As we discussed earlier, stars balance gravity with thermal pressure. Pressure due to gravity scales as $R^{-4}$, but thermal pressure scales as $R^{-3 \gamma}$, where $\gamma$ is the adiabatic index. When $\gamma>4/3$ the star has a stable response to perturbations: when you squeeze it, the pressure increases, which causes the star to expand. However, when $\gamma \le 4/3$ the response is not stable, meaning that squeezing the star causes the pressure to drop, which causes it to contract more and so on. A photon gas has $\gamma=4/3$. From the discussion of the previous section, low mass star are almost entirely gas dominated, but become more radiation dominated as the mass increases. Since gas pressure is always present, the adiabatic index is always slightly above 4/3, and the excess is proportional to the gas to radiation pressure ratio. However, as the mass of the star increases, general relativistic effect become important. In particular, gravity is slightly stronger than Newtonian gravity, by the so called Schwartzschild parameter $G M / R c^2$. One interpretation of this value is that it is the ratio between the binding energy of the star and the rest mass energy. This effect is partly responsible, among other things, for the precession of Mercury.

```
def exercise_8():

    import validate
    import importlib
    importlib.reload(validate)

    G = sympy.Symbol('G', positive=True) # Gravitation constant
    h = sympy.Symbol('h', positive=True) # Planck constant
    kT = sympy.Symbol('kT', positive=True) # Nuclear burning temperature
    c = sympy.Symbol('c', positive=True) # Speed of light
    m_p = sympy.Symbol('m_p', positive=True) # Proton mass

    # Unit test
    mass = sympy.Symbol(r'\mathcal{M}', positive=True)
    length = sympy.Symbol(r'\mathcal{L}', positive=True)
    time = sympy.Symbol(r'\mathcal{T}', positive=True)
    energy = mass*(length/time)**2
    unit_check = {h:energy*time,
                  c:length/time,
                  m_p:mass,
                  G:energy*length/mass**2,
                  kT:energy}

    print('a) Equate the gas to radiation pressure ratio to the Schwartzschild parameter to find the mass, '+
          'assume a radiation dominated star')
    answer_a = sympy.Integer(0)
    display(answer_a)
    print('unit check')
    display(answer_a.subs(unit_check))
    print(validate.exercise_8_a(answer_a))

exercise_8()
```

[Substituting numbers](https://www.wolframalpha.com/input/?i=%28speed+of+light%29%5E%287%2F2%29*%28reduced+planck+constant%29%5E%283%2F2%29%2FG%5E%283%2F2%29%2F%281e7+kelvin%29%2F%28boltzmann+constant%29%2F%28proton+mass%29) yields about $10^6 \, M_{\odot}$. A collapsing gas cloud more massive than this cannot support itself against gravity and will continue to collapse until it forms a supermassive black hole. Some people think that the the seeds for supermassive black holes in the centres of galaxies formed from such clouds.

In reality massive stars are very rare. This because they form less often than low mass stars and have shorter lives. Moreover, the most [massive star ever observed](https://en.wikipedia.org/wiki/R136a1) is around 300 $M_{\odot}$, considerably less massive than the the limit above. Stars more massive than about 100 $M_{\odot}$ ([Ledoux mass](https://ui.adsabs.harvard.edu/abs/1941ApJ....94..537L/abstract)) develop an instability that causes them to expel chunks of themselves out into space. Even at lower masses, some massive stars lose around half their mass to strong stellar winds.

## Stellar End of Life

 Evolutionary tracks of stars with different masses

All stars more massive than about 0.3 $M_{\odot}$ become giants toward the end of their lives (below this mass stars skip this stage and turn directly into white dwarfs, see below). Stars less massive than about 0.8 $M_{\odot}$ have a lifetime longer than the age of the universe, so they will turn into giants eventually, but we can't see any such giants today. In the giant phase the radius increases by about two orders of magnitude and the luminosity even more. When the Sun turns into a red giant in a few billion years it will boil the Earth before engulfing it.

Giants are so extended and cold that the dominant opacity mechanism is [dissociation of $\rm H^{-}$ ions](https://en.wikipedia.org/wiki/Hayashi_track). The properties of this mechanism is beyond the scope of this lecture, but its most important feature is that it has a very [steep temperature dependence](https://www.astro.princeton.edu/~gk/A403/opac.pdf), so in essence it restricts the surface temperature to the range 4000-3000K over a wide range of luminosities.

During the giant phase stars can expel large portions of the mass in stellar winds or episodic mass loss, to form [planetary nebula](https://en.wikipedia.org/wiki/Planetary_nebula). Side note, planetary nebula have nothing to do with planets, and they got their name because they don't twinkle like stars (since they are extended objects), which is one of they ways you can tell apart planets from stars. At some point the star exhausts all of the remaining nuclear fuel. At that point the star collapses until electron degeneracy pressure kicks in. The resulting object is called white dwarf, except for some rare caveats this is the end state of a low mass star.

In the next exercise we will calculate the upper limit on the mass of a white dwarf. The upper mass limit on white dwarfs is similar to the upper mass limit on stars in the sense that it becomes unstable when the effective adiabatic index drops to 4/3. In the case of massive stars it is because photons outnumber baryons, and in the case of a white dwarf the electrons become relativistic.

```
def exercise_9():

    import validate
    import importlib
    importlib.reload(validate)

    n = sympy.Symbol('n', positive=True) # Number density
    h = sympy.Symbol('h', positive=True) # Planck constant
    c = sympy.Symbol('c', positive=True) # Speed of light
    G = sympy.Symbol('G', positive=True) # Gravitation constant
    m_p = sympy.Symbol('m_p', positive=True) # Proton mass

    # Unit check
    mass = sympy.Symbol(r'\mathcal{M}', positive=True)
    length = sympy.Symbol(r'\mathcal{L}', positive=True)
    time = sympy.Symbol(r'\mathcal{T}', positive=True)
    energy = mass*(length/time)**2
    unit_check = {n:length**-3,
                  h:energy*time,
                  c:length/time,
                  m_p:mass,
                  G:energy*length/mass**2}

    print('a) Recall the definition of the Fermi momentum')
    answer_a = sympy.Integer(0)
    display(answer_a)
    print('unit check')
    display(answer_a.subs(unit_check))
    print(validate.exercise_9_a(answer_a))

    print('b) In the ultra relativistic regime, what would be the corresponding energy?')
    answer_b = sympy.Integer(0)
    display(answer_b)
    print('unit check')
    display(answer_b.subs(unit_check))
    print(validate.exercise_9_b(answer_b))

    print('c) What is the energy density?')
    answer_c = sympy.Integer(0)
    display(answer_c)
    print('unit check')
    display(answer_c.subs(unit_check))
    print(validate.exercise_9_c(answer_c))

    print('d) Assume the energy density is comparable to the pressure and compare it to the '+
          'gravitational pressure to obtain the mass')
    answer_d = sympy.Integer(0)
    display(answer_d)
    print('unit check')
    display(answer_d.subs(unit_check))
    print(validate.exercise_9_d(answer_d))

exercise_9()
```

The last expression we found is called the [Chandrasekhar limit](https://en.wikipedia.org/wiki/Chandrasekhar_limit) and its value is 1.4 $M_{\odot}$.

While there are some thermonuclear burning in white dwarfs, their emission is primarily powered by residual heat, and so the luminosity decreases with age. As a very crude approximation, we can estimate the luminosity as the binding energy over the age, which for $10^9$ year is [a few $L_{\odot}$](https://www.wolframalpha.com/input/?i=%28gravitation+constant%29*%28solar+mass%29%5E2%2F%28earth+radius%29%2F%281e9+year%29%2F%28solar+luminosity%29). However, since the radius is about 100 times smaller than that of the sun, the temperature is 10 times larger, and that is why they appear white.

Giant stars have a degenerate core at their centre, which eventually evolves to be the white dwarf. As the mass of the star increases, so does the mass of the white dwarf. When the mass of the star exceeds about 8 solar masses, the mass of the core exceeds the Chandrasekhar mass. In this case, the star ends its life in a spectacular explosion called a core collapse supernova. The supernova leaves behind a compact object called a relic, which could either be a neutron star or a black hole.

Despite their mysterious reputation, from an astrophysical perspective black holes are quite simple. When an object collapses to a black hole, all of the information about the object is destroyed except for three conserved quantities: mass, angular momentum and electric charge (of which only the first two are of astrophysical significance). One can estimate the radius of a black hole using the condition that the escape velocity at the horizon is equal to the speed of light $R \approx G M/c^2$. This radius is known as either the Schwarzschild radius, or gravitational radius. In principle the spin also affects the radius, but it only changes the radius by a factor of a few.

Neutron stars are supported against gravity by the strong nuclear force. Many of them are very close to the Chandrasekhar mass. This is because a supernova happens when the degenerate core exceeds the Chandrasekhar limit. The neutron star can gain more mass during the supernova and later via an accretion disc. In the following exercise we will obtain a theoretical upper limit on the mass of a neutron star, above which nuclear forces cannot resist gravity and the neutron star collapses to a black hole.

```
def exercise_10():

    import validate
    import importlib
    importlib.reload(validate)

    m_pi = sympy.Symbol(r'm_{\pi}', positive=True) # Pion mass
    h = sympy.Symbol('h', positive=True) # Planck constant
    c = sympy.Symbol('c', positive=True) # Speed of light
    m_n = sympy.Symbol('m_n', positive=True) # Nucleon mass
    G = sympy.Symbol('G', positive=True) # Gravitation constant
    M = sympy.Symbol('M', positive=True) # Mass

    # Unit check
    mass = sympy.Symbol(r'\mathcal{M}', positive=True)
    length = sympy.Symbol(r'\mathcal{L}', positive=True)
    time = sympy.Symbol(r'\mathcal{T}', positive=True)
    energy = mass*(length/time)**2
    unit_check = {h:energy*time,
                  m_pi:mass,
                  c:length/time,
                  m_n:mass,
                  G:energy*length/mass**2,
                  M:mass}

    print('a) As a crude approximation, one can assume that the strong force is mediated by pions. '+
          'What is the Compton wavelength of a pion?')
    answer_a = sympy.Integer(0)
    display(answer_a)
    print('unit check')
    display(answer_a.subs(unit_check))
    print(validate.exercise_10_a(answer_a))

    print('b) Suppose that the distance between neighbouring nucleons is the pion Compton wavelenght. '+
          'What is the nuclear mass density?')
    answer_b = sympy.Integer(0)
    display(answer_b)
    print('unit check')
    display(answer_b.subs(unit_check))
    print(validate.exercise_10_b(answer_b))

    print('c) For an object with total mass M, '+
          'how small must the radius be so that the escape velocity is equal to the speed of light?')
    answer_c = sympy.Integer(0)
    display(answer_c)
    print('unit check')
    display(answer_c.subs(unit_check))
    print(validate.exercise_10_c(answer_c))

    print('d) What would be a mass of an object with nuclear density and the radius you found in the previous section?')
    answer_d = sympy.Integer(0)
    display(answer_d)
    print('unit check')
    display(answer_d.subs(unit_check))
    print(validate.exercise_10_d(answer_d))

exercise_10()
```

A more careful calculation puts this upper limit at around 3 $M_{\odot}$. Empirical studies place the limit at around 2.2 $M_{\odot}$.

# Conclusion

The purpose of this talk was to introduce you to the complex topic of stellar evolution. We've only skimmed the tip of the iceberg, but already we are able to look at the HR diagram and see more than a Rorschach picutre. We covered the different regimes of main sequence stars, and touched upon the giant branch and the white dwarf branch.

# Suggested reading

[Radiative processes in astrophysics by Rybicky and Lightman](http://www.bartol.udel.edu/~owocki/phys633/RadProc-RybLightman.pdf)

[Radiative transfer on AstroBaki](https://casper.ssl.berkeley.edu/astrobaki/index.php/Radiative_Transfer_Equation)

[Stellar structure on AstroBaki](https://casper.ssl.berkeley.edu/astrobaki/index.php/Stellar_Structure)

[Stellar structure and Evolution by O. R. Pols](http://www.ucolick.org/~woosley/ay112-14/texts/pols11.pdf)

# Acknowledgements

I'd like to thank Emily Tyhurst, Sarah Gossan, Katie Breivik, Antoine Marchal and Patrick Breysse for the valueable feedback.

```

```


```python
%pip install -q -U gtbook
```

    Note: you may need to restart the kernel to use updated packages.

```python
import math
import numpy as np
import pandas as pd

import plotly.express as px
try:
    import google.colab
except:
    import plotly.io as pio
    pio.renderers.default = "png"

import gtsam
from gtbook.drone import axes
```

# Multi-rotor Aircraft

> An amazing way to fly in 3D.

**This Section is still in draft mode and was released for adventurous spirits (and TAs) only.**

```python
from gtbook.display import randomImages
from IPython.display import display
display(randomImages(7, 2, "steampunk", 1))
```

The kinematics of quadrotors and multicopters are those of simple rigid 3D bodies. Many different coordinate frame conventions are used in the literature. I introduce one good choice below, and convert equations from the literature to this convention where needed.

The kinematics equations are most useful for navigation and control when expressed in the navigation frame $\mathcal{N}$, which for MAV applications is almost universally assumed to be non-rotating and aligned with gravity, but can otherwise be defined arbitrarily, e.g., **East-North-Up (ENU)**. The origin of the navigation frame is often chosen as the take-off point, but does not have to be.

We also define a body frame $\mathcal{B}$ as having its origin at the center of mass of the vehicle. Following convention in aerospace applications, we fix the x-axis as pointing to the front of the vehicle (not always the direction of travel), the y-axis as pointing to the left, and the z-axis pointing up, the so-called **FLU convention**.

We then define, respectively,

• the vehicle's position $r^{n}\doteq[x,y,z]^{T}$,

• its linear velocity $v^n=\dot{r^{n}}\doteq[u,v,w]^{T}$,

• the attitude $R^n_b\doteq[i^b,j^b,k^b]\in SO(3)$, a $3\times 3$ rotation matrix from $\mathcal{B}$ to $\mathcal{N}$,

• the body angular velocity $\omega^b\doteq[p,q,r]^{T}$.

Above the superscript $n$ and $b$ denote quantities expressed in the *navigation* and *body* frame, respectively.

## Drone Dynamics

Let us take the [Skydio 2+](https://www.skydio.com/skydio-2-plus) as a prototypical and relevant example. Its mass, with battery, is $800g$, but let's just say $1kg$ to make the calculations easier. Its dimensions are $229mm \times 274mm$, but let's assume the motors are at $(+/- 0.10m, +/- 0.10m)$, which is not too far off.

## Hover
To hover, assuming $g=10m/s^2$, the four rotors have to provide a thrust of 10N upwards to compensate for gravity, i.e., 2.5N per motor. Of course, we need to be able to accelerate upwards, so let's assume each motor can provide up to double that, i.e., 0 to 5N. So, while the drone is level, here are some sample accelerations we can deliver:

- $f_i=0N$ for $i\in{1..4}$: downwards acceleration at $-10\frac{m}{s^2}$.
- $f_i=2.5N$ for $i\in{1..4}$: stable hover $0\frac{m}{s^2}$.
- $f_i=5N$ for $i\in{1..4}$: upwards acceleration at $10\frac{m}{s^2}$.

In general, the force $F^b_z$ aligned with the body Z-axis will be

$$
F^b_z = \sum_{i=1}^4 f_i
$$

## Forward Flight

Of course, when we *tilt* the quadrotor forwards, we will direct some of that thrust towards generating horizontal acceleration. To get a handle on this, we need to calculate the thrust in the *navigation* frame, but this is just a matter of multiplying with the rotation matrix $R^n_b$:

$$
F^n = R^n_b \begin{bmatrix}0 \\ 0 \\ F^b_z\end{bmatrix} = \hat{z}^n_b F^b_z
$$

where $\hat{z}^n_b$ is the third column of $R^n_b$, i.e., this is simply the Z-axis $\hat{z}$ of the body frame, expressed in the navigation frame. *In other words, the thrust in the navigation frame is always aligned with the body's Z-axis, and hence by rotating the quadrotor appropriately we can achieve any thrust direction we desire*.

Let us assume that the FLU body frame is currently oriented along the Y-axis of the ENU navigation frame, i.e., we are flying NORTH, then if we tilt forward with an angle of $\theta$ with respect to vertical, we obtain

$$
F^n = \begin{bmatrix}0 \\ \sin \theta \\ \cos \theta\end{bmatrix} F^b_z
$$

and hence if we want to maintain level flight, we need to keep compensating for gravity by maintaining

$$
\cos \theta F^b_z = 10N.
$$

That in turn means that the forward trust (along the navigation Y-axis) will be

$$
F^n_y = \sin \theta F^b_z = \sin \theta \frac{10N}{\cos \theta} = \tan \theta \cdot 10N
$$

## Maximum Tilt and Thrust

We are also constrained, however, by the maximum thrust $F^b_z$ we can deliver, which is 20N. Hence,

$$
F^b_z = \frac{10N}{\cos \theta} \leq 20N \rightarrow \cos \theta \geq 0.5 \rightarrow -60^\circ \leq \theta \leq 60^\circ,
$$

the maximum tilt angle is 60 degrees. At that maximum tilt angle, we have

$$
F^n_y = \tan \theta_{\max} \cdot 10N \approx 17N
$$

So, we are limited to $17N$ of forward thrust, and this when tilting very aggressively at 60 degrees. If we tilt more, we can improve thrust even more (up to 20N) but the drone will start falling out of the sky.

## Drag and Maximum Velocity

Importantly, constant forward thrust does *not* mean that the drone will continue accelerating, because of *drag*. In fact, from the spec-sheet of the Skydio-2 we see that the top speed (in autonomous mode) is 36Mph, which is about $16m/s$, and the theoretical top speed is probably more like $20m/s$. Since drag increases *quadratically* with velocity, we can calculate the corresponding drag coefficient as the drag force has to exactly balance the maximum forward thrust at the top speed:

$$
17N = k_d v_{top}^2 = k_d 20^2 m^2/s^2 \rightarrow k_d = 0.0425
$$

Now that we have the drag coefficient $k_d$, we can calculate a simple mapping from tilt angle $\theta$ to forward velocity. Indeed, given tilt $\theta$, can calculate the forward thrust $F^n_y$ in the navigation frame, and the velocity can be obtained by equating that to the drag force $k^d v^2$:

$$
F^n_y = \tan \theta \cdot 10N = k_d v^2 \rightarrow v = \sqrt{\frac{\tan \theta \cdot 10}{k_d}} = \sqrt{\frac{10}{k_d}} \sqrt{\tan \theta} \approx 15 \sqrt{\tan \theta}
$$

```python
vmap = {}
for theta in [0,1,5,10,15,20,30,40,50]:
  vmap[theta] = 15 * math.sqrt(math.tan(math.radians(theta)))
  print(f"v({theta}) ~ {round(vmap[theta])} m/s")
```

    v(0) ~ 0 m/s
    v(1) ~ 2 m/s
    v(5) ~ 4 m/s
    v(10) ~ 6 m/s
    v(15) ~ 8 m/s
    v(20) ~ 9 m/s
    v(30) ~ 11 m/s
    v(40) ~ 14 m/s
    v(50) ~ 16 m/s

```python
px.line(x=list(vmap.keys()), y= list(vmap.values()))
```

## Full Rigid-Body Kinematics

Below I develop all attitude kinematics and dynamics directly on $SO(3)$, following Murray et al. (1994).
In particular, the vehicle's kinematics are given by

$$
\begin{align}
\dot{r}^{n}	&= v^n \\
\dot{R}^n_b	&= R^n_b\hat{\omega}^b
\end{align}
$$

where the $3\times 3$ skew-symmetric matrix $\hat{\omega}^b\doteq (R^n_b)^{T}\dot{R}^n_b$ is obtained from the 3-vector $\omega^b$ as follows:

$$
\hat{\omega}^b\doteq\begin{bmatrix}
 & -\omega^b_{z} & \omega^b_{y}\\
\omega^b_{z} &  & -\omega^b_{x}\\
-\omega^b_{y} & \omega^b_{x}
\end{bmatrix}.
$$

Note $\hat{\omega}^b\in so(3)$, the Lie algebra associated with the 3D rotation group $SO(3)$.
Also, the kinematics can also expressed using the spatial angular velocity $\omega^n\doteq R^n_b\omega^b$, i.e.,

$$
\dot{R}^n_b	= \hat{\omega}^n R^n_b
$$

However, it is much easier to visualize and calculate the angular velocity in the body frame.

## Simulation aka Forward Integrating

If time histories $v^n(t)$ and $\omega^b(t)$ for the linear and angular velocities are available, the vehicle's position $r^{n}(t)$ and attitude $R^n_b(t)$ can be integrated forward using the following two equations:

$$
\begin{align}
r^n_{k+1} &= r^n_{k} + d^k_{k+1}[v^n(t), \Delta t]\\
R^n_{b,k+1} &= R^n_{b,k} R^k_{k+1}[\omega^b(t), \Delta t]
\end{align}
$$

where $\Delta t$ is the sample time and

- $d^k_{k+1}[v^n(t), \Delta t]$ is the relative displacement obtained by exactly integrating the velocity $v^n(t)$ over the interval $(t_k,t_{k+1}]$, and
- $R^k_{k+1}[\omega^b(t), \Delta t]$ is the relative rotation obtained by exactly integrating the angular velocity $\omega^b(t)$ over that same interval.

### Integrating Position

The above is exact, but when implementing this in code we need to use a numerical integration scheme. For position, the simplest integration scheme is the **forward Euler method**

$$
d^k_{k+1}[v^n(t), \Delta t] \approx v^n(t_k) \Delta t
$$

which approximates velocity $v^n(t)$ over the interval $(t_k,t_{k+1}]$ as constant with value $v^n(t_k)$. There is also the **backward Euler method**, which uses $v^n(t_{k+1})$, and the **trapezoidal method**, which uses the average $0.5v^n(t_k)+0.5v^n(t_{k+1})$. More sophisticated integration schemes are possible, allowing for larger sample times $\Delta t$, but they require more function evaluations of the velocity.

### Integrating Attitude

For the attitude part, it turns out that the Euler method can be implemented *in closed form* using **Rodrigues' rotation formula**:

$$
R^k_{k+1}[\omega^b(t), \Delta t] \approx I + \sin\theta K + (1-\cos\theta) K^2
$$

where $\theta=\|\omega^b_k\| \Delta t$ is the amount of rotation over the $\Delta t$ interval, and the $3\times 3$ matrix $K=\hat{\omega}^b_k/\|\omega^b_k\|$ is the screw symmetric matrix $\hat{\omega}^b_k$ normalized by $\|\omega^b_k\|$. The approximation sign above is because this is *still* an Euler step, only exact if the time-varying angular velocity $\omega^b(t)$ is actually constant over the interval.
This formula is also sometimes written in terms of an exponentiation,

$$
\exp \{ \hat{\omega}^b_k \Delta t \} = I + \sin\theta K + (1-\cos\theta) K^2
$$
because Rodrigues' formula is also the expression of the *exponential map* in $SO(3)$, a concept that generalizes to other Lie groups and which we will encounter again later in this chapter.

The last term in Rodrigues' formula is typically small for small sample times $\Delta t$ because (a) $(1-\cos\theta)$ is small for small $\theta$, and (b) the matrix $K^2$ contains squares of numbers smaller than 1. Hence, an often used first-order approximation for Euler's method in rotation spaces is

$$
R^k_{k+1}[\omega^b(t), \Delta t] \approx I + \sin\theta K \approx I + \hat{\omega}^b_k \Delta t =
\begin{bmatrix}
1 & -\omega^b_{z}\Delta t  & \omega^b_{y}\Delta t \\
\omega^b_{z}\Delta t  & 1 & -\omega^b_{x}\Delta t \\
-\omega^b_{y}\Delta t  & \omega^b_{x}\Delta t  & 1
\end{bmatrix}
$$
where we also used the small angle approximation $\sin \theta \approx \theta$.
Note that strictly speaking this approximation (unlike Rodrigues` formula) departs the rotation manifold and we need to periodically re-normalize to ensure that $R^n_b\in SO(3)$.

## An Example in Code

We demonstrate the above by forward integrating a quadrotor's position and orientation starting from a "forward flight" configuration at the origin, i.e., pitched forward by 10 degrees. We first specify the attitude using `Rot3.Pitch`:

```python
nRb0 = gtsam.Rot3.Pitch(math.radians(10))
print(np.round(nRb0.matrix(),2))
```

    [[ 0.98  0.    0.17]
     [ 0.    1.    0.  ]
     [-0.17  0.    0.98]]

Note that the forward X-axis (first column) is pointed slightly down, the Y-axis is level, and the Z-axis is pointed up but slightly forward. We have calculated before that the velocity at this angle is $6~m/s$, and let us also assume that the 4 rotors conspire to create a small angular velocity around the vertical body axis:

```python
vb = gtsam.Point3(6,0,0)
wb = gtsam.Point3(0,0,math.radians(10)) # 10 degrees per second
```

We can now integrate these, taking care to convert the body velocity to the navigation frame at each time step:

```python
delta_t = 1 # in seconds
K = 10 # number of intervals to integrate

# allocate storage for time series and initialize for k=0
rn = np.zeros((3, K+1), float)
rn[:,0] = gtsam.Point3(0,0,0)
nRb = np.zeros((3, 3, K+1), float)
nRb[:,:,0] = nRb0.matrix()

# integrate forward
for k in range(K):
    vn = nRb[:,:,k] @ vb
    vn[2] = 0 # we assume gravity is compensated for
    rn[:,k+1] = rn[:,k] + vn * delta_t
    delta_R = gtsam.Rot3.Expmap(wb * delta_t)
    nRb[:,:,k+1] = nRb[:,:,k] @ delta_R.matrix()
```

The plotting code is almost as verbose, and this is using the `gtbook.drone.axes` method, which hides more magic to create the axes:

```python
fig = px.line_3d(x=rn[0], y=rn[1], z=rn[2])
scale, labels = 10, ["F", "L", "U"]
for k in range(K+1):
    nTb = gtsam.Pose3(gtsam.Rot3(nRb[:,:,k]), rn[:,k]);
    fig.add_traces(axes(nTb, scale, labels))
fig.update_layout(margin=dict(l=0, r=0, t=0, b=0))
fig.update_layout(scene_aspectmode='data', showlegend=False)
fig.show()
```

You can see that the drone takes off in the positive X-direction, which is East in the ENU convention, and then gradually turns towards the North.

**Exercises**

- Change the initial forward orientation to be the *northern* direction.
- Change the direction of the turn to the other side.
- Change the code to use different values for $\Delta t$ and observe the results.

## Full Rigid-Body Dynamics*

> Not hard to write down, but harder to understand.

Ultimately we need to move a quadrotor to provide forces by the four rotors, which will produce a force and a moment on the quadrotor body. These in turn will generate linear and angular accelerations, through the quadrotor **dynamics**.

To start, everyone knows the famous Newton's law:

$$
F^n = m \dot{v}^n
$$

where the **mass** m is a scalar *resisting* motion: the higher the mass, the higher the force needed to produce an acceleration in any dimension. Note that we could consider $m$ to be a diagonal matrix with the mass on the diagonal: acceleration is resisted the same way in all three axes, in whatever coordinate frame we choose.

The same is (approximately) true for rotational motion

$$
\tau^b \approx I \dot{\omega}^b
$$

where the approximation is good for small angular velocities $\omega^b$.
The inertial matrix $I$ is typically a diagonal matrix with "small-small-big" diagonal elements in the FLU body coordinate frame, as the motor masses are further away from the Z-axis than from the X and Y axes, and hence resist acceleration around the Z-axis more.

One last step is still missing, which is how to translate from forces at the rotors to a force and moment on the *body*. In the body coordinate frame, the force equation is easy:

$$
F^b_z = \sum_i f_i.
$$

Above the forces $f_i$ at the rotors are all oriented along the body Z-coordinate axis (up!) and hence when we sum them we get a single force component $F^b_z$ along the body Z-axis.

The torque $\tau^b$ in the body frame is a bit more involved, and is a function of the force vector $f$. The exact formula depends on the geometry of the rotors. For example, for a 4-motor “X” configuration with motor 1 forward left and numbered clockwise, we obtain

$$
\tau^b = \begin{bmatrix}
l(f_{1}-f_{2}-f_{3}+f_{4})\\
l(f_{1}+f_{2}-f_{3}-f_{4})\\
\kappa (f_{1}-f_{2}+f_{3}-f_{4})\\
\end{bmatrix}
$$

where $l$ is the distance from the rotors to the center of mass, and $\kappa$ is a torque constant.
From this equation you can see that a quadrotor is like a differential drive robot but with *two* differential axes. Controlling it is easy in principle: *tilt, then move*. A part of $F^b_z$ will be used to keep the quadrotor flying, the other part will be used to overcome drag.

The final piece of the puzzle is how the rotors generate the individual forces $f_i$. We will not discuss that in detail here, but suffice to say that they are well understood functions of the rotor speed. Each motor also generates a small moment $\kappa f_i$ around it's rotor axis,  proportional to the force with proportionality constant $\kappa$. The rotation direction for each rotor determines which sign to use to calculate the Z-component of the body torque $\tau^b$.

## Gyroscopic effects*

> Dotting the I's.

If a quadrotor undergoes large angular velocities, we cannot ignore gyroscopic effects and we have to modify the rotational dynamics equation above to

$$
\tau^b = I \dot{\omega}^b - \omega^b \times I \omega^b,
$$

where $\times$ is the cross-product. The second term is a set of quadratic components in the angular velocity $\omega^b$ and that is why for small angular velocities they can be ignored. In fact, in many cases the orientation of a quadrotor is *actively controlled* to stay constant during hovering or in fast forward flight, and it is only in complicated maneuvers that large angular velocities occur.


```python
from IPython.display import Image
Image('../../Python_probability_statistics_machine_learning_2E.png',width=200)
```

```python
import numpy as np
np.random.seed(12345)
```

# Conditional Expectation and Mean Squared Error

In this section, we work through a detailed example using conditional
expectation and optimization methods.  Suppose we have two fair six-sided dice
($X$ and $Y$) and we want to measure the sum of the two variables as $Z=X+Y$.
Further, let's suppose that given $Z$, we want the best estimate of $X$ in the
mean-squared-sense. Thus, we want to minimize the following:

$$
J(\alpha) = \sum ( x - \alpha z )^2 \mathbb{P}(x,z)
$$

 where $\mathbb{P}$ is the probability mass function for this problem.
The idea is that when we have solved this problem, we will have a function of
$Z$ that is going to be the minimum MSE  estimate of $X$.  We can substitute in
for $Z$ in $J$ and get:

$$
J(\alpha) = \sum ( x - \alpha (x+y) )^2 \mathbb{P}(x,y)
$$

  Let's work out the steps in Sympy in the following:

```python
import sympy as S
from sympy.stats import density, E, Die

x=Die('D1',6)     # 1st six sided die
y=Die('D2',6)     # 2nd six sides die
a=S.symbols('a')
z = x+y           # sum of 1st and 2nd die
J = E((x-a*(x+y))**2) # expectation
print(S.simplify(J))
```

    329*a**2/6 - 329*a/6 + 91/6

 With all that setup we can now use basic calculus to minimize the
objective function $J$,

```python
sol,=S.solve(S.diff(J,a),a) # using calculus to minimize
print(sol) # solution is 1/2
```

    1/2

**Programming Tip.**

Sympy has a `stats` module that can do some basic work with expressions
involving probability densities and expectations. The above code uses its `E`
function to compute the expectation.

 This says that $z/2$ is the MSE estimate of $X$ given $Z$ which means
geometrically (interpreting the MSE as a squared distance weighted by the
probability mass function) that $z/2$ is as *close* to $x$ as we are going to
get for a given $z$.

Let's look at the same problem using the conditional expectation operator $
\mathbb{E}(\cdot|z) $ and apply it to our definition of $Z$. Then

$$
\mathbb{E}(z|z)=\mathbb{E}(x+y|z)=\mathbb{E}(x|z)+\mathbb{E}(y|z)=z
$$

 using the linearity of the expectation. Now, since by the
symmetry of the problem (i.e., two identical die), we have

$$
\mathbb{E}(x|z)=\mathbb{E}(y|z)
$$

 we can plug this in and solve

$$
2 \mathbb{E}(x|z)=z
$$

 which once again gives,

$$
\mathbb{E}(x|z)  =\frac{z}{2}
$$

```python
%matplotlib inline

from numpy import arange,array
from matplotlib.pylab import subplots, cm
from sympy import Integer
fig,ax = subplots()
v = arange(1,7) + arange(1,7)[:,None]
foo=lambda i: density(z)[Integer(i)].evalf() # some tweaks to get a float out
Zmass=array(list(map(foo,v.flat)),dtype=float).reshape(6,6)

pc=ax.pcolor(arange(1,8),arange(1,8),Zmass,cmap=cm.gray)
_=ax.set_xticks([(i+0.5) for i in range(1,7)])
_=ax.set_xticklabels([str(i) for i in range(1,7)])
_=ax.set_yticks([(i+0.5) for i in range(1,7)])
_=ax.set_yticklabels([str(i) for i in range(1,7)])
for i in range(1,7):
    for j in range(1,7):
        _=ax.text(i+.5,j+.5,str(i+j),fontsize=18,fontweight='bold',color='goldenrod')

_=ax.set_title(r'Probability Mass for $Z$',fontsize=18)
_=ax.set_xlabel('$X$ values',fontsize=18)
_=ax.set_ylabel('$Y$ values',fontsize=18);
cb=fig.colorbar(pc)
_=cb.ax.set_title(r'Probability',fontsize=12)
fig.savefig('fig-probability/Conditional_expectation_MSE_001.png')
```

The values of $Z$ are in yellow with the corresponding values for $X$ and $Y$ on the axes. The gray scale colors indicate the underlying joint probability density.

 which is equal to the estimate we just found by minimizing the MSE.
Let's explore this further with [Figure](#fig:Conditional_expectation_MSE_001).  [Figure](#fig:Conditional_expectation_MSE_001) shows the values of $Z$ in yellow with
the corresponding values for $X$ and $Y$ on the axes.  Suppose $z=2$, then the
closest $X$ to this is $X=1$, which is what $\mathbb{E}(x|z)=z/2=1$ gives. What
happens when $Z=7$? In this case, this value is spread out diagonally along the
$X$ axis so if $X=1$, then $Z$ is 6 units away, if $X=2$, then $Z$ is 5 units
away and so on.

Now, back to the original question, if we had $Z=7$ and we wanted
to get as close as we could to this using $X$, then why not choose
$X=6$ which is only one unit away from $Z$? The problem with doing
that is $X=6$ only occurs 1/6 of the time, so we are not likely to
get it right the other 5/6 of the time. So, 1/6 of the time we are
one unit away but 5/6 of the time we are much more than one unit
away. This means that the MSE score is going to be worse. Since
each value of $X$ from 1 to 6 is equally likely, to play it safe,
we choose $7/2$ as the estimate, which is what the conditional
expectation suggests.

We can check this claim with samples using Sympy below:

```python
import numpy as np
from sympy import stats
# Eq constrains Z
samples_z7 = lambda : stats.sample(x, S.Eq(z,7))
#using 6 as an estimate
mn= np.mean([(6-samples_z7())**2 for i in range(100)])
#7/2 is the MSE estimate
mn0= np.mean([(7/2.-samples_z7())**2 for i in range(100)])
print('MSE=%3.2f using 6 vs MSE=%3.2f using 7/2 ' % (mn,mn0))
```

    MSE=10.75 using 6 vs MSE=3.01 using 7/2

**Programming Tip.**

The `stats.sample(x, S.Eq(z,7))` function call samples the `x` variable subject
to a condition on the `z` variable. In other words, it generates random samples
of `x` die, given that the sum of the outcomes of that die and the `y` die add
up to `z==7`.

 Please run the above code repeatedly until you are convinced that the
$\mathbb{E}(x|z)$ gives the lower MSE every time.  To push this reasoning,
let's consider the case where the die is so biased so that the outcome of `6`
is ten times more probable than any of the other outcomes. That is,

$$
\mathbb{P}(6) = 2/3
$$

 whereas $\mathbb{P}(1)=\mathbb{P}(2)=\ldots=\mathbb{P}(5)=1/15$.
We can explore this using Sympy as in the following:

```python
# here 6 is ten times more probable than any other outcome
x=stats.FiniteRV('D3',{1:1/15., 2:1/15.,
                       3:1/15., 4:1/15.,
                       5:1/15., 6:2/3.})
```

 As before, we construct the sum of the two dice, and plot the
corresponding probability mass function in [Figure](#fig:Conditional_expectation_MSE_002).  As compared with [Figure](#fig:Conditional_expectation_MSE_001), the probability mass has been shifted
away from the smaller numbers.

```python
z = x + y
foo=lambda i: density(z)[S.Integer(i)].evalf() # some tweaks to get a float out
v = np.arange(1,7) + np.arange(1,7)[:,None]
Zmass=np.array(list(map(foo,v.flat)),dtype=float).reshape(6,6)

from matplotlib.pylab import subplots, cm
fig,ax=subplots()
pc=ax.pcolor(np.arange(1,8),np.arange(1,8),Zmass,cmap=cm.gray)
_=ax.set_xticks([(i+0.5) for i in range(1,7)])
_=ax.set_xticklabels([str(i) for i in range(1,7)])
_=ax.set_yticks([(i+0.5) for i in range(1,7)])
_=ax.set_yticklabels([str(i) for i in range(1,7)])
for i in range(1,7):
    for j in range(1,7):
        _=ax.text(i+.5,j+.5,str(i+j),fontsize=18,fontweight='bold',color='goldenrod')

_=ax.set_title(r'Probability Mass for $Z$; Nonuniform Case',fontsize=13)
_=ax.set_xlabel('$X$ values',fontsize=18)
_=ax.set_ylabel('$Y$ values',fontsize=18);
cb=fig.colorbar(pc)
_=cb.ax.set_title(r'Probability',fontsize=10)
fig.savefig('fig-probability/Conditional_expectation_MSE_002.png')
```

The values of $Z$ are in yellow with the corresponding values for $X$ and $Y$ on the axes.

Let's see what the conditional expectation says about how we can estimate $X$
from $Z$.

```python
E(x, S.Eq(z,7)) # conditional expectation E(x|z=7)
```

$\displaystyle 5.0$

  Now that we have $\mathbb{E}(x|z=7) = 5$, we can generate
samples as before and see if this gives the minimum MSE.

```python
samples_z7 = lambda : stats.sample(x, S.Eq(z,7))
#using 6 as an estimate
mn= np.mean([(6-samples_z7())**2 for i in range(100)])
#5 is the MSE estimate
mn0= np.mean([(5-samples_z7())**2 for i in range(100)])
print('MSE=%3.2f using 6 vs MSE=%3.2f using 5 ' % (mn,mn0))
```

    MSE=4.78 using 6 vs MSE=2.56 using 5

Using a simple example, we have emphasized the connection between minimum mean
squared error problems and conditional expectation. Hopefully, the last two
figures helped expose the role of the probability density.  Next, we'll
continue revealing  the true power of the conditional expectation as we
continue to develop corresponding geometric intuition.


# Start-to-Finish Example: Validating Shifted Kerr-Schild initial data against ETK version:

## Author: Patrick Nelson

**Notebook Status:** Validated

**Validation Notes:** This module validates all expressions used to set up initial data in
* [Tutorial-ADM_Initial_Data-ShiftedKerrSchild](../Tutorial-ADM_Initial_Data-ShiftedKerrSchild.ipynb)

against the C-code implementation of these expressions found in the original (trusted) [`GiRaFFEfood` Einstein Toolkit thorn](link), and confirms roundoff-level agreement.

### NRPy+ Source Code for this module:
* [BSSN/ShiftedKerrSchild.py](../../edit/BSSN/ShiftedKerrSchild.py) [\[**tutorial**\]](../Tutorial-ADM_Initial_Data-ShiftedKerrSchild.ipynb) Generates Exact Wald initial data

## Introduction:

This notebook validates the initial data routines that set up the Shifted Kerr-Schild initial spacetime data against the ETK implementation of the same equations.

When this notebook is run, the significant digits of agreement between the old ETK and new NRPy+ versions of the algorithm will be evaluated. If the agreement falls below a thresold, the point, quantity, and level of agreement are reported [here](#compile_run).

# Table of Contents
$$\label{toc}$$

This notebook is organized as follows

1. [Step 1](#setup): Set up core functions and parameters for unit testing the initial data algorithms
    1. [Step 1.a](#spacetime) Generate the spacetime metric
    1. [Step 1.b](#download) Download original ETK files
    1. [Step 1.c](#free_params) Output C codes needed for declaring and setting Cparameters; also set `free_parameters.h`
    1. [Step 1.d](#interface) Create dummy files for the CCTK version of the code
1. [Step 2](#mainc): `ShiftedKerrSchild_unit_test.c`: The Main C Code
    1. [Step 2.a](#compile_run): Compile and run the code to validate the output
1. [Step 3](#drift_notes): Output this notebook to $\LaTeX$-formatted PDF file
1. [Step 4](#latex_pdf_output): Output this notebook to $\LaTeX$-formatted PDF file

# Step 1: Set up core functions and parameters for unit testing the initial data algorithms" \[Back to [top](#toc)\]

$$\label{setup}$$

We'll start by appending the relevant paths to `sys.path` so that we can access sympy modules in other places. Then, we'll import NRPy+ core functionality and set up a directory in which to carry out our test. We will also declare the gridfunctions that are needed for this portion of the code.

```python
import os, sys           # Standard Python modules for multiplatform OS-level functions
# First, we'll add the parent directory to the list of directories Python will check for modules.
nrpy_dir_path = os.path.join("..")
if nrpy_dir_path not in sys.path:
    sys.path.append(nrpy_dir_path)
nrpy_dir_path = os.path.join("..","..")
if nrpy_dir_path not in sys.path:
    sys.path.append(nrpy_dir_path)

from outputC import outCfunction, lhrh # NRPy+: Core C code output module
import sympy as sp               # SymPy: The Python computer algebra package upon which NRPy+ depends
import finite_difference as fin  # NRPy+: Finite difference C code generation module
import NRPy_param_funcs as par   # NRPy+: Parameter interface
import grid as gri               # NRPy+: Functions having to do with numerical grids
import indexedexp as ixp         # NRPy+: Symbolic indexed expression (e.g., tensors, vectors, etc.) support
import reference_metric as rfm   # NRPy+: Reference metric support
import cmdline_helper as cmd     # NRPy+: Multi-platform Python command-line interface

out_dir = "Validation/"
cmd.mkdir(out_dir)

thismodule = "Start_to_Finish_UnitTest-GiRaFFEfood_NRPy"

# Register the gridfunctions we need for this function
gammaDD = ixp.register_gridfunctions_for_single_rank2("AUXEVOL","gammaDD","sym01")
betaU = ixp.register_gridfunctions_for_single_rank1("AUXEVOL","betaU")
alpha = gri.register_gridfunctions("AUXEVOL","alpha")
```

## Step 1.a: Generate the spacetime metric \[Back to [top](#toc)\]
$$\label{spacetime}$$

While many of the initial data we will use assume a flat background spacetime, some will require a specific metric. We will set those up as needed here.

```python
# Exact Wald is more complicated. We'll need the Shifted Kerr Schild metric in Cartesian coordinates.
import BSSN.ShiftedKerrSchild as sks
sks.ShiftedKerrSchild(True)
import reference_metric as rfm
par.set_parval_from_str("reference_metric::CoordSystem","Cartesian")
rfm.reference_metric()
# Use the Jacobian matrix to transform the vectors to Cartesian coordinates.
gammaSphDD = ixp.zerorank2()
for i in range(3):
    for j in range(3):
        gammaSphDD[i][j] += sks.gammaSphDD[i][j].subs(sks.r,rfm.xxSph[0]).subs(sks.th,rfm.xxSph[1])

unused_gammaUU,gammaSphDET = ixp.symm_matrix_inverter3x3(gammaSphDD)
sqrtgammaSphDET = sp.sqrt(gammaSphDET)

betaSphU = ixp.zerorank1()
for i in range(3):
    betaSphU[i] += sks.betaSphU[i].subs(sks.r,rfm.xxSph[0]).subs(sks.th,rfm.xxSph[1])
alpha = sks.alphaSph.subs(sks.r,rfm.xxSph[0]).subs(sks.th,rfm.xxSph[1])

# Use the Jacobian matrix to transform the vectors to Cartesian coordinates.
par.set_parval_from_str("reference_metric::CoordSystem","Spherical")
rfm.reference_metric()
Jac_dUCart_dDrfmUD,Jac_dUrfm_dDCartUD = rfm.compute_Jacobian_and_inverseJacobian_tofrom_Cartesian()
# Transform the coordinates of the Jacobian matrix from spherical to Cartesian:
par.set_parval_from_str("reference_metric::CoordSystem","Cartesian")
rfm.reference_metric()
tmpa,tmpb,tmpc = sp.symbols("tmpa,tmpb,tmpc")
for i in range(3):
    for j in range(3):
        Jac_dUCart_dDrfmUD[i][j] = Jac_dUCart_dDrfmUD[i][j].subs([(rfm.xx[0],tmpa),(rfm.xx[1],tmpb),(rfm.xx[2],tmpc)])
        Jac_dUCart_dDrfmUD[i][j] = Jac_dUCart_dDrfmUD[i][j].subs([(tmpa,rfm.xxSph[0]),(tmpb,rfm.xxSph[1]),(tmpc,rfm.xxSph[2])])
        Jac_dUrfm_dDCartUD[i][j] = Jac_dUrfm_dDCartUD[i][j].subs([(rfm.xx[0],tmpa),(rfm.xx[1],tmpb),(rfm.xx[2],tmpc)])
        Jac_dUrfm_dDCartUD[i][j] = Jac_dUrfm_dDCartUD[i][j].subs([(tmpa,rfm.xxSph[0]),(tmpb,rfm.xxSph[1]),(tmpc,rfm.xxSph[2])])
gammaDD = rfm.basis_transform_tensorDD_from_rfmbasis_to_Cartesian(Jac_dUrfm_dDCartUD, gammaSphDD)
betaU = rfm.basis_transform_vectorD_from_rfmbasis_to_Cartesian(Jac_dUrfm_dDCartUD, betaSphU)

# We only need to set alpha and betaU in C for the original Exact Wald
name = "Shifted_Kerr_Schild_initial_metric"
desc = "Generate a spinning black hole with Shifted Kerr Schild metric."
values_to_print = [
                   lhrh(lhs=gri.gfaccess("auxevol_gfs","gammaDD00"),rhs=gammaDD[0][0]),
                   lhrh(lhs=gri.gfaccess("auxevol_gfs","gammaDD01"),rhs=gammaDD[0][1]),
                   lhrh(lhs=gri.gfaccess("auxevol_gfs","gammaDD02"),rhs=gammaDD[0][2]),
                   lhrh(lhs=gri.gfaccess("auxevol_gfs","gammaDD11"),rhs=gammaDD[1][1]),
                   lhrh(lhs=gri.gfaccess("auxevol_gfs","gammaDD12"),rhs=gammaDD[1][2]),
                   lhrh(lhs=gri.gfaccess("auxevol_gfs","gammaDD22"),rhs=gammaDD[2][2]),
                   lhrh(lhs=gri.gfaccess("auxevol_gfs","betaU0"),rhs=betaU[0]),
                   lhrh(lhs=gri.gfaccess("auxevol_gfs","betaU1"),rhs=betaU[1]),
                   lhrh(lhs=gri.gfaccess("auxevol_gfs","betaU2"),rhs=betaU[2]),
                   lhrh(lhs=gri.gfaccess("auxevol_gfs","alpha"),rhs=alpha)
                  ]

outCfunction(
    outfile  = os.path.join(out_dir,name+".h"), desc=desc, name=name,
    params   ="const paramstruct *params,REAL *xx[3],REAL *auxevol_gfs",
    body     = fin.FD_outputC("returnstring",values_to_print,params="outCverbose=False"),
    loopopts ="AllPoints,Read_xxs")

```

    Output C function Shifted_Kerr_Schild_initial_metric() to file Validation/Shifted_Kerr_Schild_initial_metric.h

## Step 1.b: Download original ETK files \[Back to [top](#toc)\]

$$\label{download}$$

Here, we download the relevant portion of the original `GiRaFFE` code from Bitbucket.

```python
# First download the original GiRaFFE source code
import urllib

original_file_url  = [
                      "https://bitbucket.org/zach_etienne/wvuthorns/raw/0a82c822748baf754c153db484d8bd2d0b7e39cb/ShiftedKerrSchild/src/ShiftedKerrSchild.c",
                     ]
original_file_name = [
                      "ShiftedKerrSchild.c",
                     ]

for i in range(len(original_file_url)):
    original_file_path = os.path.join(out_dir,original_file_name[i])

    # Then download the original GiRaFFE source code
    # We try it here in a couple of ways in an attempt to keep
    # the code more portable
    try:
        original_file_code = urllib.request.urlopen(original_file_url[i]).read().decode('utf-8')
    except:
        original_file_code = urllib.urlopen(original_file_url[i]).read().decode('utf-8')

    # Write down the file the original GiRaFFE source code
    with open(original_file_path,"w") as file:
        file.write(original_file_code)

```

## Step 1.c: Output C codes needed for declaring and setting Cparameters; also set `free_parameters.h` \[Back to [top](#toc)\]

$$\label{free_params}$$

Based on declared NRPy+ Cparameters, first we generate `declare_Cparameters_struct.h`, `set_Cparameters_default.h`, and `set_Cparameters[-SIMD].h`.

Then we output `free_parameters.h`, which sets some basic grid parameters as well as the speed limit parameter we need for this function.

```python
# Step 3.d
# Step 3.d.ii: Set free_parameters.h
with open(os.path.join(out_dir,"free_parameters.h"),"w") as file:
    file.write("""
// Set free-parameter values.

const int NGHOSTS = 3;

// Set free-parameter values for the initial data.
// Override parameter defaults with values based on command line arguments and NGHOSTS.
const int Nx0x1x2 = 5;
params.Nxx0 = Nx0x1x2;
params.Nxx1 = Nx0x1x2;
params.Nxx2 = Nx0x1x2;
params.Nxx_plus_2NGHOSTS0 = params.Nxx0 + 2*NGHOSTS;
params.Nxx_plus_2NGHOSTS1 = params.Nxx1 + 2*NGHOSTS;
params.Nxx_plus_2NGHOSTS2 = params.Nxx2 + 2*NGHOSTS;
// Step 0d: Set up space and time coordinates
// Step 0d.i: Declare \Delta x^i=dxx{0,1,2} and invdxx{0,1,2}, as well as xxmin[3] and xxmax[3]:
const REAL xxmin[3] = {-1.5,-1.5,-1.5};
const REAL xxmax[3] = { 1.5, 1.5, 1.5};

params.dxx0 = (xxmax[0] - xxmin[0]) / ((REAL)params.Nxx0);
params.dxx1 = (xxmax[1] - xxmin[1]) / ((REAL)params.Nxx1);
params.dxx2 = (xxmax[2] - xxmin[2]) / ((REAL)params.Nxx2);
params.invdx0 = 1.0 / params.dxx0;
params.invdx1 = 1.0 / params.dxx1;
params.invdx2 = 1.0 / params.dxx2;
params.r0 = 0.4;
params.a = 0.0;
\n""")

# Generates declare_Cparameters_struct.h, set_Cparameters_default.h, and set_Cparameters[-SIMD].h
par.generate_Cparameters_Ccodes(os.path.join(out_dir))
```

## Step 1.d: Create dummy files for the CCTK version of the code \[Back to [top](#toc)\]

$$\label{interface}$$

The original `GiRaFFE` code depends on some functionalities of the CCTK. Since we only care about this one small function, we can get around this by creating some nearly-empty, non-functional files that can be included to satisfy the pre-processor without changing functionality. We will later replace what little functionality we need with some basic global variables and macros.

```python
#include "cctk.h"
#include "cctk_Arguments.h"
#include "cctk_Parameters.h"
with open(os.path.join(out_dir,"cctk.h"),"w") as file:
    file.write("""//""")

with open(os.path.join(out_dir,"cctk_Arguments.h"),"w") as file:
    file.write("""#define DECLARE_CCTK_ARGUMENTS //
#define CCTK_ARGUMENTS void
""")

with open(os.path.join(out_dir,"cctk_Parameters.h"),"w") as file:
    file.write("""#define DECLARE_CCTK_PARAMETERS //
""")
```

# Step 2: `ShiftedKerrSchild_unit_test.C`: The Main C Code \[Back to [top](#toc)\]

$$\label{mainc}$$

Now that we have our vector potential and analytic magnetic field to compare against, we will start writing our unit test. We'll also import common C functionality, define `REAL`, the number of ghost zones, and the faces, and set the standard macros for NRPy+ style memory access.

```python
%%writefile $out_dir/ShiftedKerrSchild_unit_test.C
// These are common packages that we are likely to need.
#include "stdio.h"
#include "stdlib.h"
#include "math.h"
#include "stdint.h" // Needed for Windows GCC 6.x compatibility

#define REAL double
#include "declare_Cparameters_struct.h"

// Standard NRPy+ memory access:
#define IDX4S(g,i,j,k) \
( (i) + Nxx_plus_2NGHOSTS0 * ( (j) + Nxx_plus_2NGHOSTS1 * ( (k) + Nxx_plus_2NGHOSTS2 * (g) ) ) )

// Standard formula to calculate significant digits of agreement:
#define SDA(a,b) 1.0-log10(2.0*fabs(a-b)/(fabs(a)+fabs(b)))

// Memory access definitions for NRPy+
#define GAMMADD00GF 0
#define GAMMADD01GF 1
#define GAMMADD02GF 2
#define GAMMADD11GF 3
#define GAMMADD12GF 4
#define GAMMADD22GF 5
#define BETAU0GF 6
#define BETAU1GF 7
#define BETAU2GF 8
#define ALPHAGF 9
#define KDD00GF 10
#define KDD01GF 11
#define KDD02GF 12
#define KDD11GF 13
#define KDD12GF 14
#define KDD22GF 15
#define NUM_AUXEVOL_GFS 16

// Include the functions that we want to test:
#include "Shifted_Kerr_Schild_initial_metric.h"

// Define CCTK macros
#define CCTK_REAL double
#define CCTK_INT int
#define CCTK_VPARAMWARN(...) //
#define CCTK_EQUALS(a,b) 1
struct cGH{};
const cGH* cctkGH;

// More definitions to interface with ETK code:
const int cctk_lsh[3] = {11,11,11};
const int grid_size = cctk_lsh[0]*cctk_lsh[1]*cctk_lsh[2];
```

    Overwriting Validation//ShiftedKerrSchild_unit_test.C

```python
import os
gfs_list = ['x','y','z','r','SKSgrr','SKSgrth','SKSgrph','SKSgthth','SKSgthph','SKSgphph','SKSbetar','SKSbetath','SKSbetaph']
with open(os.path.join(out_dir,"ShiftedKerrSchild_unit_test.C"), 'a') as file:
    for gf in gfs_list:
        file.write("CCTK_REAL *"+gf+" = (CCTK_REAL *)malloc(sizeof(CCTK_REAL)*grid_size);\n")
```

```python
%%writefile -a $out_dir/ShiftedKerrSchild_unit_test.C
CCTK_REAL *alp;
CCTK_REAL *betax;
CCTK_REAL *betay;
CCTK_REAL *betaz;
CCTK_REAL *gxx;
CCTK_REAL *gxy;
CCTK_REAL *gxz;
CCTK_REAL *gyy;
CCTK_REAL *gyz;
CCTK_REAL *gzz;
CCTK_REAL *kxx;
CCTK_REAL *kxy;
CCTK_REAL *kxz;
CCTK_REAL *kyy;
CCTK_REAL *kyz;
CCTK_REAL *kzz;

CCTK_REAL KerrSchild_radial_shift;
CCTK_REAL BH_mass;
CCTK_REAL BH_spin;

// Dummy ETK function:
#define CCTK_GFINDEX3D(cctkGH,i,j,k) (i) + cctk_lsh[0] * ( (j) + cctk_lsh[1] * (k) )

#include "ShiftedKerrSchild.c"

int main() {
    paramstruct params;
#include "set_Cparameters_default.h"

    // Step 0c: Set free parameters, overwriting Cparameters defaults
    //          by hand or with command-line input, as desired.
#include "free_parameters.h"
#include "set_Cparameters-nopointer.h"

    // Set CCTK parameters to match NRPy+ parameters
    KerrSchild_radial_shift = r0;
    BH_mass = M;
    BH_spin = a;

    // Step 0d.ii: Set up uniform coordinate grids
    REAL *xx[3];
    xx[0] = (REAL *)malloc(sizeof(REAL)*Nxx_plus_2NGHOSTS0);
    xx[1] = (REAL *)malloc(sizeof(REAL)*Nxx_plus_2NGHOSTS1);
    xx[2] = (REAL *)malloc(sizeof(REAL)*Nxx_plus_2NGHOSTS2);
    for(int j=0;j<Nxx_plus_2NGHOSTS0;j++) xx[0][j] = xxmin[0] + (j-NGHOSTS)*dxx0;
    for(int j=0;j<Nxx_plus_2NGHOSTS1;j++) xx[1][j] = xxmin[1] + (j-NGHOSTS)*dxx1;
    for(int j=0;j<Nxx_plus_2NGHOSTS2;j++) xx[2][j] = xxmin[2] + (j-NGHOSTS)*dxx2;

    for(int k=0;k<Nxx_plus_2NGHOSTS2;k++)
        for(int j=0;j<Nxx_plus_2NGHOSTS1;j++)
            for(int i=0;i<Nxx_plus_2NGHOSTS0;i++) {
                int index = CCTK_GFINDEX3D(cctkGH,i,j,k);
                x[index] = xx[0][i];
                y[index] = xx[1][j];
                z[index] = xx[2][k];
                r[index] = sqrt(x[index]*x[index] + y[index]*y[index] + z[index]*z[index]);
    }

    //for(int j=0;j<Nxx_plus_2NGHOSTS0;j++) printf("x[%d] = %.5e\n",j,xx[0][j]);

    // This is the array to which we'll write the NRPy+ variables.
    REAL *auxevol_gfs  = (REAL *)malloc(sizeof(REAL) * NUM_AUXEVOL_GFS * Nxx_plus_2NGHOSTS2 * Nxx_plus_2NGHOSTS1 * Nxx_plus_2NGHOSTS0);
    REAL *auxevol_ETK_gfs  = (REAL *)malloc(sizeof(REAL) * NUM_AUXEVOL_GFS * Nxx_plus_2NGHOSTS2 * Nxx_plus_2NGHOSTS1 * Nxx_plus_2NGHOSTS0);

    // Memory access for metric gridfunctions for Exact Wald:
    gxx = auxevol_ETK_gfs + (grid_size*GAMMADD00GF);
    gxy = auxevol_ETK_gfs + (grid_size*GAMMADD01GF);
    gxz = auxevol_ETK_gfs + (grid_size*GAMMADD02GF);
    gyy = auxevol_ETK_gfs + (grid_size*GAMMADD11GF);
    gyz = auxevol_ETK_gfs + (grid_size*GAMMADD12GF);
    gzz = auxevol_ETK_gfs + (grid_size*GAMMADD22GF);
    alp = auxevol_ETK_gfs + (grid_size*ALPHAGF);
    betax = auxevol_ETK_gfs + (grid_size*BETAU0GF);
    betay = auxevol_ETK_gfs + (grid_size*BETAU1GF);
    betaz = auxevol_ETK_gfs + (grid_size*BETAU2GF);
    kxx = auxevol_ETK_gfs + (grid_size*KDD00GF);
    kxy = auxevol_ETK_gfs + (grid_size*KDD01GF);
    kxz = auxevol_ETK_gfs + (grid_size*KDD02GF);
    kyy = auxevol_ETK_gfs + (grid_size*KDD11GF);
    kyz = auxevol_ETK_gfs + (grid_size*KDD12GF);
    kzz = auxevol_ETK_gfs + (grid_size*KDD22GF);

    Shifted_Kerr_Schild_initial_metric(&params,xx,auxevol_gfs);
    ShiftedKS_ID();

    int all_agree = 1;

    for(int i0=0;i0<Nxx_plus_2NGHOSTS0;i0++){
        for(int i1=0;i1<Nxx_plus_2NGHOSTS1;i1++){
            for(int i2=0;i2<Nxx_plus_2NGHOSTS2;i2++){
                if(SDA(auxevol_gfs[IDX4S(BETAU0GF, i0,i1,i2)],betax[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)])<10.0){
                    printf("Quantity betaU0 only agrees with the original GiRaFFE to %.2f digits at i0,i1,i2=%d,%d,%d!\n",
                           SDA(auxevol_gfs[IDX4S(BETAU0GF, i0,i1,i2)],betax[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)]),i0,i1,i2);
                    all_agree=0;
                }
                if(SDA(auxevol_gfs[IDX4S(BETAU1GF, i0,i1,i2)],betay[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)])<10.0){
                    printf("Quantity betaU1 only agrees with the original GiRaFFE to %.2f digits at i0,i1,i2=%d,%d,%d!\n",
                           SDA(auxevol_gfs[IDX4S(BETAU1GF, i0,i1,i2)],betay[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)]),i0,i1,i2);
                    all_agree=0;
                }
                if(SDA(auxevol_gfs[IDX4S(BETAU2GF, i0,i1,i2)],betaz[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)])<10.0){
                    printf("Quantity betaU2 only agrees with the original GiRaFFE to %.2f digits at i0,i1,i2=%d,%d,%d!\n",
                           SDA(auxevol_gfs[IDX4S(BETAU2GF, i0,i1,i2)],betaz[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)]),i0,i1,i2);
                    all_agree=0;
                }
                if(SDA(auxevol_gfs[IDX4S(GAMMADD00GF, i0,i1,i2)],gxx[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)])<10.0){
                    printf("Quantity betaU0 only agrees with the original GiRaFFE to %.2f digits at i0,i1,i2=%d,%d,%d!\n",
                           SDA(auxevol_gfs[IDX4S(GAMMADD00GF, i0,i1,i2)],gxx[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)]),i0,i1,i2);
                    all_agree=0;
                }
                if(SDA(auxevol_gfs[IDX4S(GAMMADD11GF, i0,i1,i2)],gyy[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)])<10.0){
                    printf("Quantity betaU1 only agrees with the original GiRaFFE to %.2f digits at i0,i1,i2=%d,%d,%d!\n",
                           SDA(auxevol_gfs[IDX4S(GAMMADD11GF, i0,i1,i2)],gyy[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)]),i0,i1,i2);
                    all_agree=0;
                }
                if(SDA(auxevol_gfs[IDX4S(GAMMADD22GF, i0,i1,i2)],gzz[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)])<10.0){
                    printf("Quantity betaU2 only agrees with the original GiRaFFE to %.2f digits at i0,i1,i2=%d,%d,%d!\n",
                           SDA(auxevol_gfs[IDX4S(GAMMADD22GF, i0,i1,i2)],gzz[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)]),i0,i1,i2);
                    all_agree=0;
                }
                //printf("NRPy: %.15e,%.15e,%.15e\n",auxevol_gfs[IDX4S(BETAU0GF, i0,i1,i2)],auxevol_gfs[IDX4S(BETAU1GF, i0,i1,i2)],auxevol_gfs[IDX4S(BETAU2GF, i0,i1,i2)]);
                //printf("CCTK: %.15e,%.15e,%.15e\n",betax[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)],betay[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)],betaz[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)]);
                //printf("NRPy: %.15e,%.15e,%.15e\n",auxevol_gfs[IDX4S(GAMMADD01GF, i0,i1,i2)],auxevol_gfs[IDX4S(GAMMADD02GF, i0,i1,i2)],auxevol_gfs[IDX4S(GAMMADD12GF, i0,i1,i2)]);
                //printf("CCTK: %.15e,%.15e,%.15e\n",gxy[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)],gxz[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)],gyz[CCTK_GFINDEX3D(cctkGH,i0,i1,i2)]);
            }
        }
    }

    if(all_agree) printf("All quantities agree at all points!\n");
```

    Appending to Validation//ShiftedKerrSchild_unit_test.C

```python
with open(os.path.join(out_dir,"ShiftedKerrSchild_unit_test.C"), 'a') as file:
    for gf in gfs_list:
        file.write("    free("+gf+");\n")
```

```python
%%writefile -a $out_dir/ShiftedKerrSchild_unit_test.C
    free(auxevol_gfs);
    free(auxevol_ETK_gfs);
}
```

    Appending to Validation//ShiftedKerrSchild_unit_test.C

## Step 2.a: Compile and run the code to validate the output \[Back to [top](#toc)\]

$$\label{compile_run}$$

Finally, we can compile and run the code we have written. Once run, this code will output the level of agreement between the two codes and some information to help interpret those numbers.

```python
import time

print("Now compiling, should take ~2 seconds...\n")
start = time.time()
# cmd.C_compile(os.path.join(out_dir,"ShiftedKerrSchild_unit_test.c"), os.path.join(out_dir,"ShiftedKerrSchild_unit_test"))
!g++ -Ofast -fopenmp -march=native -funroll-loops Validation/ShiftedKerrSchild_unit_test.C -o Validation/ShiftedKerrSchild_unit_test -lstdc++
end = time.time()
print("Finished in "+str(end-start)+" seconds.\n\n")

results_file = "out_ShiftedKerrSchild_test.txt"

# os.chdir(out_dir)
os.chdir(out_dir)
# cmd.Execute(os.path.join("GiRaFFEfood_NRPy_unit_test"))
cmd.Execute("ShiftedKerrSchild_unit_test",file_to_redirect_stdout=results_file)
os.chdir(os.path.join("../"))
```

    Now compiling, should take ~2 seconds...

    Finished in 0.3955354690551758 seconds.

    (EXEC): Executing `taskset -c 0,1,2,3 ./ShiftedKerrSchild_unit_test `...
    (BENCH): Finished executing in 0.20961785316467285 seconds.

Here, we add some emergency brakes so that if the output from the test isn't good, we throw an error to stop the notebook dead in its tracks. This way, our automatic testing infrastructure can let us know if something goes wrong. We will also print the output from the test for convenience's sake.

```python
with open(os.path.join(out_dir,results_file),"r") as file:
    output = file.readline()
    print(output)
    if output!="All quantities agree at all points!\n": # If this isn't the first line of this file, something went wrong!
        sys.exit(1)
```

    All quantities agree at all points!

# Step 4: Output this notebook to $\LaTeX$-formatted PDF file \[Back to [top](#toc)\]
$$\label{latex_pdf_output}$$

The following code cell converts this Jupyter notebook into a proper, clickable $\LaTeX$-formatted PDF file. After the cell is successfully run, the generated PDF may be found in the root NRPy+ tutorial directory, with filename
[Tutorial-Start_to_Finish_UnitTest-GiRaFFEfood_NRPy.pdf](Tutorial-Start_to_Finish_UnitTest-GiRaFFEfood_NRPy.pdf) (Note that clicking on this link may not work; you may need to open the PDF file through another means.)

```python
import cmdline_helper as cmd    # NRPy+: Multi-platform Python command-line interface
cmd.output_Jupyter_notebook_to_LaTeXed_PDF("Tutorial-Start_to_Finish_UnitTest-GiRaFFEfood_NRPy")
```

    [NbConvertApp] WARNING | pattern 'Tutorial-Start_to_Finish_UnitTest-GiRaFFEfood_NRPy.ipynb' matched no files
    Created Tutorial-Start_to_Finish_UnitTest-GiRaFFEfood_NRPy.tex, and
        compiled LaTeX file to PDF file Tutorial-Start_to_Finish_UnitTest-
        GiRaFFEfood_NRPy.pdf