# Javascript Interface Code for Head-On Black Hole Collision with Psi4

## Author: Karinne Summers
### Based on the Start-to-Finish Example: Head-On Black Hole Collision by Zach Etienne with formatting improvements courtesy Brandon Clark

## This module implements a basic numerical relativity code to merge two black holes, as well as the gravitational wave analysis provided by the $\psi_4$ NRPy+ tutorial notebooks ([$\psi_4$](Tutorial-Psi4.ipynb) & [$\psi_4$ tetrad](Tutorial-Psi4_tetrads.ipynb)).

### Here we place the black holes initially on the $z$-axis, so the entire simulation is axisymmetric about the $\phi$-axis. Minimal sampling in the $\phi$ direction greatly speeds up the simulation.

## Introduction:
Here we use NRPy+ to generate the C source code necessary to set up initial data for two black holes (Brill-Lindquist, [Brill & Lindquist, Phys. Rev. 131, 471, 1963](https://journals.aps.org/pr/abstract/10.1103/PhysRev.131.471); see also Eq. 1 of [Brandt & Brügmann, arXiv:gr-qc/9711015v1](https://arxiv.org/pdf/gr-qc/9711015v1.pdf)). Then we use it to generate the RHS expressions for [Method of Lines](https://reference.wolfram.com/language/tutorial/NDSolveMethodOfLines.html) time integration based on the [explicit Runge-Kutta fourth-order scheme](https://en.wikipedia.org/wiki/Runge%E2%80%93Kutta_methods) (RK4).

The entire algorithm is outlined as follows, with links to the relevant NRPy+ tutorial notebooks listed at each step:

1. Allocate memory for gridfunctions, including temporary storage for the Method of Lines time integration
    * [**NRPy+ tutorial on Method of Lines algorithm**](Tutorial-Method_of_Lines-C_Code_Generation.ipynb).
1. Set gridfunction values to initial data
    * [**NRPy+ tutorial on Brill-Lindquist initial data**](Tutorial-ADM_Initial_Data-Brill-Lindquist.ipynb)
    * [**NRPy+ tutorial on validating Brill-Lindquist initial data**](Tutorial-Start_to_Finish-BSSNCurvilinear-Setting_up_Exact_Initial_Data.ipynb).
1. Next, integrate the initial data forward in time using the Method of Lines coupled to a Runge-Kutta explicit timestepping algorithm:
    1. At the start of each iteration in time, output the Hamiltonian constraint violation
        * [**NRPy+ tutorial on BSSN constraints**](Tutorial-BSSN_constraints.ipynb).
    1. At each RK time substep, do the following:
        1. Evaluate BSSN RHS expressions
            * [**NRPy+ tutorial on BSSN right-hand sides**](Tutorial-BSSN_time_evolution-BSSN_RHSs.ipynb)
            * [**NRPy+ tutorial on BSSN gauge condition right-hand sides**](Tutorial-BSSN_time_evolution-BSSN_gauge_RHSs.ipynb)
        1. Apply singular, curvilinear coordinate boundary conditions [*a la* the SENR/NRPy+ paper](https://arxiv.org/abs/1712.07658)
            * [**NRPy+ tutorial on setting up singular, curvilinear boundary conditions**](Tutorial-Start_to_Finish-Curvilinear_BCs.ipynb)
        1. Enforce constraint on conformal 3-metric: $\det{\bar{\gamma}_{ij}}=\det{\hat{\gamma}_{ij}}$
            * [**NRPy+ tutorial on enforcing $\det{\bar{\gamma}_{ij}}=\det{\hat{\gamma}_{ij}}$ constraint**](Tutorial-BSSN-Enforcing_Determinant_gammabar_equals_gammahat_Constraint.ipynb)

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

This notebook is organized as follows

1. [Step 1](#initializenrpy): Set core NRPy+ parameters for numerical grids and reference metric
1. [Step 2](#adm_id): Import Brill-Lindquist ADM initial data C function from the [`BSSN.BrillLindquist`](../edit/BSSN/BrillLindquist.py) NRPy+ module
1. [Step 3](#bssn): Output C code for BSSN spacetime solve
    1. [Step 3.a](#bssnrhs): Output C code for BSSN RHS expressions
    1. [Step 3.b](#hamconstraint): Output C code for Hamiltonian constraint
    1. [Step 3.c](#enforce3metric): Enforce conformal 3-metric $\det{\bar{\gamma}_{ij}}=\det{\hat{\gamma}_{ij}}$ constraint
    1. [Step 3.d](#psi4): Compute $\psi_4$, which encodes gravitational wave information in our numerical relativity calculations
    1. [Step 3.e](#decomposepsi4): Decompose $\psi_4$ into spin-weight -2 spherical harmonics
        1. [Step 3.e.i](#spinweight): Output ${}^{-2}Y_{\ell,m}$, up to and including $\ell=\ell_{\rm max}$=`l_max` (set to 2 here)
        1. [Step 3.e.ii](#full_diag): Decomposition of $\psi_4$ into spin-weight -2 spherical harmonics: Full C-code diagnostic implementation
    1. [Step 3.f](#coutput): Output all NRPy+ C-code kernels, in parallel if possible
    1. [Step 3.g](#cparams_rfm_and_domainsize): Output C codes needed for declaring and setting Cparameters; also set `free_parameters.h`
1. [Step 4](#bc_functs): Set up boundary condition functions for chosen singular, curvilinear coordinate system
1. [Step 5](#mainc): `BrillLindquist_Playground.c`: The Main C Code
1. [Step 6](#compileexec): Compile generated C codes & perform the black hole collision calculation

```python
# nrpytutorial should be in a subdirectory, so, do the following.
import os,sys
nrpy_dir_path = os.path.join("nrpytutorial")
if nrpy_dir_path not in sys.path:
    sys.path.append(nrpy_dir_path)
```

# Step 1: Set core NRPy+ parameters for numerical grids and reference metric \[Back to [top](#toc)\]
$$\label{initializenrpy}$$

```python
# Step P1: Import needed NRPy+ core modules:
from outputC import lhrh,outCfunction,outC_function_dict # NRPy+: Core C code output module
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
import shutil, os, sys, time           # Standard Python modules for multiplatform OS-level functions, benchmarking

# Step P2: Create C code output directory:
Ccodesdir = os.path.join("EMCC_BSSN_Two_BHs_Collide_Psi4/")
# First remove C code output directory if it exists
# Courtesy https://stackoverflow.com/questions/303200/how-do-i-remove-delete-a-folder-that-is-not-empty
shutil.rmtree(Ccodesdir, ignore_errors=True)
# Then create a fresh directory
cmd.mkdir(Ccodesdir)

# Step P3: Create executable output directory:
outdir = os.path.join(Ccodesdir,"output/")
cmd.mkdir(outdir)

# Step 1: Set the spatial dimension parameter
#         to three (BSSN is a 3+1 decomposition
#         of Einstein's equations), and then read
#         the parameter as DIM.
DIM = 3
par.set_parval_from_str("grid::DIM",DIM)

# Step 1.a: Enable SIMD-optimized code?
#           I.e., generate BSSN and Ricci C code kernels using SIMD-vectorized
#           compiler intrinsics, which *greatly improve the code's performance*,
#           though at the expense of making the C-code kernels less
#           human-readable.
#           * Important note in case you wish to modify the BSSN/Ricci kernels
#             here by adding expressions containing transcendental functions
#             (e.g., certain scalar fields):
#           Note that SIMD-based transcendental function intrinsics are not
#           supported by the default installation of gcc or clang (you will
#           need to use e.g., the SLEEF library from sleef.org, for this
#           purpose). The Intel compiler suite does support these intrinsics
#           however without the need for external libraries.
enable_SIMD = True

# Step 2: Set some core parameters, including CoordSystem MoL timestepping algorithm,
#                                 FD order, floating point precision, and CFL factor:
# Choices are: Spherical, SinhSpherical, SinhSphericalv2, Cylindrical, SinhCylindrical,
#              SymTP, SinhSymTP
CoordSystem     = "SinhSpherical"

# Decompose psi_4 (second time derivative of gravitational
#                  wave strain) into all spin-weight=-2
#                  l,m spherical harmonics, starting at l=2
#                  going up to and including l_max, set here:
l_max = 2

# domain_size sets the default value for:
#   * Spherical's params.RMAX
#   * SinhSpherical*'s params.AMAX
#   * Cartesians*'s -params.{x,y,z}min & .{x,y,z}max
#   * Cylindrical's -params.ZMIN & .{Z,RHO}MAX
#   * SinhCylindrical's params.AMPL{RHO,Z}
#   * *SymTP's params.AMAX
domain_size = 10 # Length scale of computational domain
FD_order    = 8   # Finite difference order: even numbers only, starting with 2. 12 is generally unstable

# sinh_width sets the default value for:
#   * SinhSpherical's params.SINHW
#   * SinhCylindrical's params.SINHW{RHO,Z}
#   * SinhSymTP's params.SINHWAA
sinh_width      = 0.2 # If Sinh* coordinates chosen

# sinhv2_const_dr sets the default value for:
#   * SinhSphericalv2's params.const_dr
#   * SinhCylindricalv2's params.const_d{rho,z}
sinhv2_const_dr = 0.05 # If Sinh*v2 coordinates chosen

# SymTP_bScale sets the default value for:
#   * SinhSymTP's params.bScale
SymTP_bScale    = 0.5 # If SymTP chosen

# Step 2.b: Set the timestepping order,
#           the core data type, and the CFL factor.
# Step 2.b: Set the order of spatial and temporal derivatives;
#           the core data type, and the CFL factor.
# RK_method choices include: Euler, "RK2 Heun", "RK2 MP", "RK2 Ralston", RK3, "RK3 Heun", "RK3 Ralston",
#              SSPRK3, RK4, DP5, DP5alt, CK5, DP6, L6, DP8
RK_method = "RK4"
REAL      = "double" # Best to use double here.
CFL_FACTOR = 0.1     # (GETS OVERWRITTEN WHEN EXECUTED.) In pure axisymmetry (symmetry_axes = 2 below) 1.0 works fine. Otherwise 0.5 or lower.

# Step 3: Generate Runge-Kutta-based (RK-based) timestepping code.
#       As described above the Table of Contents, this is a 3-step process:
#       3.A: Evaluate RHSs (RHS_string)
#       3.B: Apply boundary conditions (post_RHS_string, pt 1)
#       3.C: Enforce det(gammahat) = det(gammahat) constraint (post_RHS_string, pt 2)
import MoLtimestepping.C_Code_Generation as MoL
from MoLtimestepping.RK_Butcher_Table_Dictionary import Butcher_dict
RK_order  = Butcher_dict[RK_method][1]
cmd.mkdir(os.path.join(Ccodesdir,"MoLtimestepping/"))
MoL.MoL_C_Code_Generation(RK_method,
    RHS_string      = """
Ricci_eval(&rfmstruct, &params, RK_INPUT_GFS, auxevol_gfs);
rhs_eval(&rfmstruct, &params, auxevol_gfs, RK_INPUT_GFS, RK_OUTPUT_GFS);""",
    post_RHS_string = """
apply_bcs_curvilinear(&params, &bcstruct, NUM_EVOL_GFS, evol_gf_parity, RK_OUTPUT_GFS);
enforce_detgammahat_constraint(&rfmstruct, &params,                     RK_OUTPUT_GFS);\n""",
    outdir = os.path.join(Ccodesdir,"MoLtimestepping/"))

# Step 4: Set the coordinate system for the numerical grid
par.set_parval_from_str("reference_metric::CoordSystem",CoordSystem)
rfm.reference_metric() # Create ReU, ReDD needed for rescaling B-L initial data, generating BSSN RHSs, etc.

# Step 5: Set the finite differencing order to FD_order (set above).
par.set_parval_from_str("finite_difference::FD_CENTDERIVS_ORDER", FD_order)

# Step 6: Copy SIMD/SIMD_intrinsics.h to $Ccodesdir/SIMD/SIMD_intrinsics.h
cmd.mkdir(os.path.join(Ccodesdir,"SIMD"))
shutil.copy(os.path.join("nrpytutorial/SIMD/")+"SIMD_intrinsics.h",os.path.join(Ccodesdir,"SIMD/"))

# Step 7: Set the direction=2 (phi) axis to be the symmetry axis; i.e.,
#         axis "2", corresponding to the i2 direction.
#      This sets all spatial derivatives in the phi direction to zero.
par.set_parval_from_str("indexedexp::symmetry_axes","2")
```

## Step 1.c: Output needed C code for finding the minimum proper distance between grid points, needed for [CFL](https://en.wikipedia.org/w/index.php?title=Courant%E2%80%93Friedrichs%E2%80%93Lewy_condition&oldid=806430673)-limited timestep \[Back to [top](#toc)\]
$$\label{cfl}$$

In order for our explicit-timestepping numerical solution to the scalar wave equation to be stable, it must satisfy the [CFL](https://en.wikipedia.org/w/index.php?title=Courant%E2%80%93Friedrichs%E2%80%93Lewy_condition&oldid=806430673) condition:
$$
\Delta t \le \frac{\min(ds_i)}{c},
$$
where $c$ is the wavespeed, and
$$ds_i = h_i \Delta x^i$$
is the proper distance between neighboring gridpoints in the $i$th direction (in 3D, there are 3 directions), $h_i$ is the $i$th reference metric scale factor, and $\Delta x^i$ is the uniform grid spacing in the $i$th direction:

```python
# Output the find_timestep() function to a C file.
rfm.out_timestep_func_to_file(os.path.join(Ccodesdir,"find_timestep.h"))
```

# Step 2: Import Brill-Lindquist ADM initial data C function from the [`BSSN.BrillLindquist`](../edit/BSSN/BrillLindquist.py) NRPy+ module \[Back to [top](#toc)\]
$$\label{adm_id}$$

The [`BSSN.BrillLindquist`](../edit/BSSN/BrillLindquist.py) NRPy+ module does the following:

1. Set up Brill-Lindquist initial data [ADM](https://en.wikipedia.org/wiki/ADM_formalism) quantities in the **Cartesian basis**, as [documented here](Tutorial-ADM_Initial_Data-Brill-Lindquist.ipynb).
1. Convert the ADM **Cartesian quantities** to **BSSN quantities in the desired Curvilinear basis** (set by reference_metric::CoordSystem), as [documented here](Tutorial-ADM_Initial_Data-Converting_ADMCartesian_to_BSSNCurvilinear.ipynb).
1. Sets up the standardized C function for setting all BSSN Curvilinear gridfunctions in a pointwise fashion, as [written here](../edit/BSSN/BSSN_ID_function_string.py), and returns the C function as a Python string.

```python
import BSSN.BrillLindquist as bl
def BrillLindquistID():
    print("Generating optimized C code for Brill-Lindquist initial data. May take a while, depending on CoordSystem.")
    start = time.time()

    bl.BrillLindquist() # Registers ID C function in dictionary, used below to output to file.
    with open(os.path.join(Ccodesdir,"initial_data.h"),"w") as file:
        file.write(outC_function_dict["initial_data"])
    end = time.time()
    print("(BENCH) Finished BL initial data codegen in "+str(end-start)+" seconds.")
```

# Step 3: Output C code for BSSN spacetime solve \[Back to [top](#toc)\]
$$\label{bssn}$$

## Step 3.a: Output C code for BSSN RHS expressions \[Back to [top](#toc)\]
$$\label{bssnrhs}$$

```python
import BSSN.BSSN_RHSs as rhs
import BSSN.BSSN_gauge_RHSs as gaugerhs
# Set the *covariant*, second-order Gamma-driving shift condition
par.set_parval_from_str("BSSN.BSSN_gauge_RHSs::ShiftEvolutionOption", "GammaDriving2ndOrder_Covariant")

print("Generating symbolic expressions for BSSN RHSs...")
start = time.time()
# Enable rfm_precompute infrastructure, which results in
#   BSSN RHSs that are free of transcendental functions,
#   even in curvilinear coordinates, so long as
#   ConformalFactor is set to "W" (default).
cmd.mkdir(os.path.join(Ccodesdir,"rfm_files/"))
par.set_parval_from_str("reference_metric::enable_rfm_precompute","True")
par.set_parval_from_str("reference_metric::rfm_precompute_Ccode_outdir",os.path.join(Ccodesdir,"rfm_files/"))

# Evaluate BSSN + BSSN gauge RHSs with rfm_precompute enabled:
import BSSN.BSSN_quantities as Bq
par.set_parval_from_str("BSSN.BSSN_quantities::LeaveRicciSymbolic","True")

rhs.BSSN_RHSs()
gaugerhs.BSSN_gauge_RHSs()

# We use betaU as our upwinding control vector:
Bq.BSSN_basic_tensors()
betaU = Bq.betaU

import BSSN.Enforce_Detgammahat_Constraint as EGC
enforce_detg_constraint_symb_expressions = EGC.Enforce_Detgammahat_Constraint_symb_expressions()

# Next compute Ricci tensor
par.set_parval_from_str("BSSN.BSSN_quantities::LeaveRicciSymbolic","False")
Bq.RicciBar__gammabarDD_dHatD__DGammaUDD__DGammaU()

# Now register the Hamiltonian as a gridfunction.
H = gri.register_gridfunctions("AUX","H")
# Then define the Hamiltonian constraint and output the optimized C code.
import BSSN.BSSN_constraints as bssncon
bssncon.BSSN_constraints(add_T4UUmunu_source_terms=False)

# Now that we are finished with all the rfm hatted
#           quantities in generic precomputed functional
#           form, let's restore them to their closed-
#           form expressions.
par.set_parval_from_str("reference_metric::enable_rfm_precompute","False") # Reset to False to disable rfm_precompute.
rfm.ref_metric__hatted_quantities()
end = time.time()
print("(BENCH) Finished BSSN symbolic expressions in "+str(end-start)+" seconds.")

def BSSN_RHSs():
    print("Generating C code for BSSN RHSs in "+par.parval_from_str("reference_metric::CoordSystem")+" coordinates.")
    start = time.time()

    # Construct the left-hand sides and right-hand-side expressions for all BSSN RHSs
    lhs_names = [        "alpha",       "cf",       "trK"]
    rhs_exprs = [gaugerhs.alpha_rhs, rhs.cf_rhs, rhs.trK_rhs]
    for i in range(3):
        lhs_names.append(        "betU"+str(i))
        rhs_exprs.append(gaugerhs.bet_rhsU[i])
        lhs_names.append(   "lambdaU"+str(i))
        rhs_exprs.append(rhs.lambda_rhsU[i])
        lhs_names.append(        "vetU"+str(i))
        rhs_exprs.append(gaugerhs.vet_rhsU[i])
        for j in range(i,3):
            lhs_names.append(   "aDD"+str(i)+str(j))
            rhs_exprs.append(rhs.a_rhsDD[i][j])
            lhs_names.append(   "hDD"+str(i)+str(j))
            rhs_exprs.append(rhs.h_rhsDD[i][j])

    # Sort the lhss list alphabetically, and rhss to match.
    #   This ensures the RHSs are evaluated in the same order
    #   they're allocated in memory:
    lhs_names,rhs_exprs = [list(x) for x in zip(*sorted(zip(lhs_names,rhs_exprs), key=lambda pair: pair[0]))]

    # Declare the list of lhrh's
    BSSN_evol_rhss = []
    for var in range(len(lhs_names)):
        BSSN_evol_rhss.append(lhrh(lhs=gri.gfaccess("rhs_gfs",lhs_names[var]),rhs=rhs_exprs[var]))

    # Set up the C function for the BSSN RHSs
    desc="Evaluate the BSSN RHSs"
    name="rhs_eval"
    outCfunction(
        outfile  = os.path.join(Ccodesdir,name+".h"), desc=desc, name=name,
        params   = """rfm_struct *restrict rfmstruct,const paramstruct *restrict params,
                      const REAL *restrict auxevol_gfs,const REAL *restrict in_gfs,REAL *restrict rhs_gfs""",
        body     = fin.FD_outputC("returnstring",BSSN_evol_rhss, params="outCverbose=False,enable_SIMD=True",
                                  upwindcontrolvec=betaU),
        loopopts = "InteriorPoints,enable_SIMD,enable_rfm_precompute")
    end = time.time()
    print("(BENCH) Finished BSSN_RHS C codegen in " + str(end - start) + " seconds.")

def Ricci():
    print("Generating C code for Ricci tensor in "+par.parval_from_str("reference_metric::CoordSystem")+" coordinates.")
    start = time.time()
    desc="Evaluate the Ricci tensor"
    name="Ricci_eval"
    outCfunction(
        outfile  = os.path.join(Ccodesdir,name+".h"), desc=desc, name=name,
        params   = """rfm_struct *restrict rfmstruct,const paramstruct *restrict params,
                      const REAL *restrict in_gfs,REAL *restrict auxevol_gfs""",
        body     = fin.FD_outputC("returnstring",
                                  [lhrh(lhs=gri.gfaccess("auxevol_gfs","RbarDD00"),rhs=Bq.RbarDD[0][0]),
                                   lhrh(lhs=gri.gfaccess("auxevol_gfs","RbarDD01"),rhs=Bq.RbarDD[0][1]),
                                   lhrh(lhs=gri.gfaccess("auxevol_gfs","RbarDD02"),rhs=Bq.RbarDD[0][2]),
                                   lhrh(lhs=gri.gfaccess("auxevol_gfs","RbarDD11"),rhs=Bq.RbarDD[1][1]),
                                   lhrh(lhs=gri.gfaccess("auxevol_gfs","RbarDD12"),rhs=Bq.RbarDD[1][2]),
                                   lhrh(lhs=gri.gfaccess("auxevol_gfs","RbarDD22"),rhs=Bq.RbarDD[2][2])],
                                   params="outCverbose=False,enable_SIMD=True"),
        loopopts = "InteriorPoints,enable_SIMD,enable_rfm_precompute")
    end = time.time()
    print("(BENCH) Finished Ricci C codegen in " + str(end - start) + " seconds.")
```

    Generating symbolic expressions for BSSN RHSs...
    (BENCH) Finished BSSN symbolic expressions in 10.577486991882324 seconds.

## Step 3.b: Output C code for Hamiltonian constraint \[Back to [top](#toc)\]
$$\label{hamconstraint}$$

Next output the C code for evaluating the Hamiltonian constraint [(**Tutorial**)](Tutorial-BSSN_constraints.ipynb). In the absence of numerical error, this constraint should evaluate to zero. However it does not due to numerical (typically truncation and roundoff) error. We will therefore measure the Hamiltonian constraint violation to gauge the accuracy of our simulation, and, ultimately determine whether errors are dominated by numerical finite differencing (truncation) error as expected.

```python
def Hamiltonian():
    start = time.time()
    print("Generating optimized C code for Hamiltonian constraint. May take a while, depending on CoordSystem.")
    # Set up the C function for the Hamiltonian RHS
    desc="Evaluate the Hamiltonian constraint"
    name="Hamiltonian_constraint"
    outCfunction(
        outfile  = os.path.join(Ccodesdir,name+".h"), desc=desc, name=name,
        params   = """rfm_struct *restrict rfmstruct,const paramstruct *restrict params,
                      REAL *restrict in_gfs, REAL *restrict aux_gfs""",
        body     = fin.FD_outputC("returnstring",lhrh(lhs=gri.gfaccess("aux_gfs", "H"), rhs=bssncon.H),
                                  params="outCverbose=False"),
        loopopts = "InteriorPoints,enable_rfm_precompute")

    end = time.time()
    print("(BENCH) Finished Hamiltonian C codegen in " + str(end - start) + " seconds.")
```

## Step 3.c: Enforce conformal 3-metric $\det{\bar{\gamma}_{ij}}=\det{\hat{\gamma}_{ij}}$ constraint \[Back to [top](#toc)\]
$$\label{enforce3metric}$$

Then enforce conformal 3-metric $\det{\bar{\gamma}_{ij}}=\det{\hat{\gamma}_{ij}}$ constraint (Eq. 53 of [Ruchlin, Etienne, and Baumgarte (2018)](https://arxiv.org/abs/1712.07658)), as [documented in the corresponding NRPy+ tutorial notebook](Tutorial-BSSN-Enforcing_Determinant_gammabar_equals_gammahat_Constraint.ipynb)

Applying curvilinear boundary conditions should affect the initial data at the outer boundary, and will in general cause the $\det{\bar{\gamma}_{ij}}=\det{\hat{\gamma}_{ij}}$ constraint to be violated there. Thus after we apply these boundary conditions, we must always call the routine for enforcing the $\det{\bar{\gamma}_{ij}}=\det{\hat{\gamma}_{ij}}$ constraint:

```python
def gammadet():
    start = time.time()
    print("Generating optimized C code for gamma constraint. May take a while, depending on CoordSystem.")

    # Set up the C function for the det(gammahat) = det(gammahat)
    EGC.output_Enforce_Detgammahat_Constraint_Ccode(Ccodesdir,exprs=enforce_detg_constraint_symb_expressions)
    end = time.time()
    print("(BENCH) Finished gamma constraint C codegen in " + str(end - start) + " seconds.")
```

## Step 3.d: Compute $\psi_4$, which encodes gravitational wave information in our numerical relativity calculations \[Back to [top](#toc)\]
$$\label{psi4}$$

The [Weyl scalar](https://en.wikipedia.org/wiki/Weyl_scalar) $\psi_4$ encodes gravitational wave information in our numerical relativity calculations. For more details on how it is computed, see [this NRPy+ tutorial notebook for information on $\psi_4$](Tutorial-Psi4.ipynb) and [this one on the Quasi-Kinnersley tetrad](Tutorial-Psi4_tetrads.ipynb) (as implemented in [Baker, Campanelli, Lousto (2001)](https://arxiv.org/pdf/gr-qc/0104063.pdf)).

$\psi_4$ is related to the gravitational wave strain via
$$
\psi_4 = \ddot{h}_+ - i \ddot{h}_\times,
$$
where $\ddot{h}_+$ is the second time derivative of the $+$ polarization of the gravitational wave strain $h$, and $\ddot{h}_\times$ is the second time derivative of the $\times$ polarization of the gravitational wave strain $h$.

```python
import BSSN.Psi4_tetrads as BP4t
par.set_parval_from_str("BSSN.Psi4_tetrads::TetradChoice","QuasiKinnersley")
#par.set_parval_from_str("BSSN.Psi4_tetrads::UseCorrectUnitNormal","True")
import BSSN.Psi4 as BP4
print("Generating symbolic expressions for psi4...")
start = time.time()
BP4.Psi4()
end = time.time()
print("(BENCH) Finished psi4 symbolic expressions in "+str(end-start)+" seconds.")

psi4r_0pt = gri.register_gridfunctions("AUX","psi4r_0pt")
psi4r_1pt = gri.register_gridfunctions("AUX","psi4r_1pt")
psi4r_2pt = gri.register_gridfunctions("AUX","psi4r_2pt")
psi4i_0pt = gri.register_gridfunctions("AUX","psi4i_0pt")
psi4i_1pt = gri.register_gridfunctions("AUX","psi4i_1pt")
psi4i_2pt = gri.register_gridfunctions("AUX","psi4i_2pt")

desc="""Since it's so expensive to compute, instead of evaluating
psi_4 at all interior points, this functions evaluates it on a
point-by-point basis."""
name="psi4"
outCfunction(
    outfile  = os.path.join(Ccodesdir,name+".h"), desc=desc, name=name,
    params   = """const paramstruct *restrict params,
                  const int i0,const int i1,const int i2,
                  REAL *restrict xx[3], const REAL *restrict in_gfs, REAL *restrict aux_gfs""",
    body     = """
    const int idx = IDX3S(i0,i1,i2);
    const REAL xx0 = xx[0][i0];const REAL xx1 = xx[1][i1];const REAL xx2 = xx[2][i2];
// Real part of psi_4, divided into 3 terms
    {
#include "Psi4re_pt0_lowlevel.h"
    }
    {
#include "Psi4re_pt1_lowlevel.h"
    }
    {
#include "Psi4re_pt2_lowlevel.h"
    }
// Imaginary part of psi_4, divided into 3 terms
    {
#include "Psi4im_pt0_lowlevel.h"
    }
    {
#include "Psi4im_pt1_lowlevel.h"
    }
    {
#include "Psi4im_pt2_lowlevel.h"
    }""")

def Psi4re(part):
    print("Generating C code for psi4_re_pt"+str(part)+" in "+par.parval_from_str("reference_metric::CoordSystem")+" coordinates.")
    start = time.time()
    Psi4re_pt = fin.FD_outputC("returnstring",
                   [lhrh(lhs=gri.gfaccess("aux_gfs","psi4r_"+str(part)+"pt"),rhs=BP4.psi4_re_pt[part])],
                   params="outCverbose=False,CSE_sorting=none") # Generating the CSE for psi4 is the slowest
                                                                # operation in this notebook, and much of the CSE
                                                                # time is spent sorting CSE expressions. Disabling
                                                                # this sorting makes the C codegen 3-4x faster,
                                                                # but the tradeoff is that every time this is
                                                                # run, the CSE patterns will be different
                                                                # (though they should result in mathematically
                                                                # *identical* expressions). You can expect
                                                                # roundoff-level differences as a result.
    with open(os.path.join(Ccodesdir,"Psi4re_pt"+str(part)+"_lowlevel.h"), "w") as file:
        file.write(Psi4re_pt)
    end = time.time()
    print("(BENCH) Finished generating psi4_re_pt"+str(part)+" in "+str(end-start)+" seconds.")

def Psi4im(part):
    print("Generating C code for psi4_im_pt"+str(part)+" in "+par.parval_from_str("reference_metric::CoordSystem")+" coordinates.")
    start = time.time()
    Psi4im_pt = fin.FD_outputC("returnstring",
                   [lhrh(lhs=gri.gfaccess("aux_gfs","psi4i_"+str(part)+"pt"),rhs=BP4.psi4_im_pt[part])],
                   params="outCverbose=False,CSE_sorting=none") # Generating the CSE for psi4 is the slowest
                                                                # operation in this notebook, and much of the CSE
                                                                # time is spent sorting CSE expressions. Disabling
                                                                # this sorting makes the C codegen 3-4x faster,
                                                                # but the tradeoff is that every time this is
                                                                # run, the CSE patterns will be different
                                                                # (though they should result in mathematically
                                                                # *identical* expressions). You can expect
                                                                # roundoff-level differences as a result.
    with open(os.path.join(Ccodesdir,"Psi4im_pt"+str(part)+"_lowlevel.h"), "w") as file:
        file.write(Psi4im_pt)
    end = time.time()
    print("(BENCH) Finished generating psi4_im_pt"+str(part)+" in "+str(end-start)+" seconds.")
```

    Generating symbolic expressions for psi4...
    (BENCH) Finished psi4 symbolic expressions in 44.386873722076416 seconds.
    Output C function psi4() to file EMCC_BSSN_Two_BHs_Collide_Psi4/psi4.h

## Step 3.e: Decompose $\psi_4$ into spin-weight -2 spherical harmonics \[Back to [top](#toc)\]
$$\label{decomposepsi4}$$

Instead of measuring $\psi_4$ for all possible (gravitational wave) observers in our simulation domain, we instead decompose it into a natural basis set, which by convention is the spin-weight -2 spherical harmonics.

Here we implement the algorithm for decomposing $\psi_4$ into spin-weight -2 spherical harmonic modes. The decomposition is defined as follows:

$$
{}^{-2}\left[\psi_4\right]_{\ell,m}(t,R) = \int \int \psi_4(t,R,\theta,\phi)\  \left[{}^{-2}Y^*_{\ell,m}(\theta,\phi)\right] \sin \theta d\theta d\phi,
$$

where

* ${}^{-2}Y^*_{\ell,m}(\theta,\phi)$ is the complex conjugate of the spin-weight $-2$ spherical harmonic $\ell,m$ mode
* $R$ is the (fixed) radius at which we extract $\psi_4$ information
* $t$ is the time coordinate
* $\theta,\phi$ are the polar and azimuthal angles, respectively (we use [the physics notation for spherical coordinates](https://en.wikipedia.org/wiki/Spherical_coordinate_system) here)

### Step 3.e.i Output ${}^{-2}Y_{\ell,m}$, up to and including $\ell=\ell_{\rm max}$=`l_max` (set to 2 here) \[Back to [top](#toc)\]
$$\label{spinweight}$$

Here we output all spin-weight $-2$ spherical harmonics [**Tutorial Module**](Tutorial-SpinWeighted_Spherical_Harmonics.ipynb) for $\ell=0$ up to and including $\ell=\ell_{\rm max}$=`l_max` (set to 2 here).

```python
import SpinWeight_minus2_SphHarmonics.SpinWeight_minus2_SphHarmonics as swm2
cmd.mkdir(os.path.join(Ccodesdir,"SpinWeight_minus2_SphHarmonics"))
swm2.SpinWeight_minus2_SphHarmonics(maximum_l=l_max,
                filename=os.path.join(Ccodesdir,"SpinWeight_minus2_SphHarmonics/SpinWeight_minus2_SphHarmonics.h"))
```

### Step 3.e.ii Decomposition of $\psi_4$ into spin-weight -2 spherical harmonics: Full C-code diagnostic implementation \[Back to [top](#toc)\]
$$\label{full_diag}$$

Note that this diagnostic implementation assumes that `Spherical`-like coordinates are used (e.g., `SinhSpherical` or `Spherical`), which are the most natural coordinate system for decomposing $\psi_4$ into spin-weight -2 modes.

First we process the inputs needed to compute $\psi_4$ at all needed $\theta,\phi$ points

```python
## Code for this moved to step 5
```

Next we implement the integral:

$$
{}^{-2}\left[\psi_4\right]_{\ell,m}(t,R) = \int \int \psi_4(t,R,\theta,\phi)\  \left[{}^{-2}Y^*_{\ell,m}(\theta,\phi)\right] \sin \theta d\theta d\phi.
$$

Since $\psi_4(t,R,\theta,\phi)$ and $\left[{}^{-2}Y^*_{\ell,m}(\theta,\phi)\right]$ are generally complex, for simplicity let's define
\begin{align}
\psi_4(t,R,\theta,\phi)&=a+i b \\
\left[{}^{-2}Y_{\ell,m}(\theta,\phi)\right] &= c + id\\
\implies \left[{}^{-2}Y^*_{\ell,m}(\theta,\phi)\right] = \left[{}^{-2}Y_{\ell,m}(\theta,\phi)\right]^* &=c-i d
\end{align}

Then the product (appearing within the integral) will be given by
\begin{align}
(a + i b) (c-i d) &= (ac + bd) + i(bc - ad),
\end{align}
which cleanly splits the real and complex parts. For better modularity, we output this algorithm to a function `decompose_psi4_into_swm2_modes()` in file `decompose_psi4_into_swm2_modes.h`. Here, we will call this function from within `output_psi4_spinweight_m2_decomposition()`, but in general it could be called from codes that do not use spherical coordinates, and the `psi4r_at_R_ext[]` and `psi4i_at_R_ext[]` arrays are filled using interpolations.

Finally, we complete the function `output_psi4_spinweight_m2_decomposition()`, now calling the above routine and freeing all allocated memory.

```python
## Code for this moved to step 5
```

## Step 3.f: Output all NRPy+ C-code kernels, in parallel if possible \[Back to [top](#toc)\]
$$\label{coutput}$$

```python
# Step 0: Import the multiprocessing module.
import multiprocessing

# Step 1: Create a list of functions we wish to evaluate in parallel
funcs = [Psi4re,Psi4re,Psi4re,Psi4im,Psi4im,Psi4im, BrillLindquistID,BSSN_RHSs,Ricci,Hamiltonian,gammadet]

# Step 1.a: Define master function for calling all above functions.
#           Note that lambdifying this doesn't work in Python 3
def master_func(idx):
    if idx < 3: # Call Psi4re(arg)
        funcs[idx](idx)
    elif idx < 6: # Call Psi4im(arg-3)
        funcs[idx](idx-3)
    else: # All non-Psi4 functions:
        funcs[idx]()

try:
    if os.name == 'nt':
        # It's a mess to get working in Windows, so we don't bother. :/
        #  https://medium.com/@grvsinghal/speed-up-your-python-code-using-multiprocessing-on-windows-and-jupyter-or-ipython-2714b49d6fac
        raise Exception("Parallel codegen currently not available in Windows")
    # Step 1.b: Import the multiprocessing module.
    import multiprocessing

    # Step 1.c: Evaluate list of functions in parallel if possible;
    #           otherwise fallback to serial evaluation:
    pool = multiprocessing.Pool()
    pool.map(master_func,range(len(funcs)))
except:
    # Steps 1.b-1.c, alternate: As fallback, evaluate functions in serial.
    #       This will happen on Android and Windows systems
    for idx in range(len(funcs)):
        master_func(idx)
```

    Generating C code for psi4_re_pt0 in SinhSpherical coordinates.Generating optimized C code for Brill-Lindquist initial data. May take a while, depending on CoordSystem.Generating C code for psi4_re_pt1 in SinhSpherical coordinates.Generating C code for psi4_im_pt0 in SinhSpherical coordinates.Generating C code for psi4_re_pt2 in SinhSpherical coordinates.Generating C code for BSSN RHSs in SinhSpherical coordinates.Generating C code for Ricci tensor in SinhSpherical coordinates.Generating optimized C code for Hamiltonian constraint. May take a while, depending on CoordSystem.Generating optimized C code for gamma constraint. May take a while, depending on CoordSystem.

    Generating C code for psi4_im_pt2 in SinhSpherical coordinates.
    Generating C code for psi4_im_pt1 in SinhSpherical coordinates.
    Output C function enforce_detgammahat_constraint() to file EMCC_BSSN_Two_BHs_Collide_Psi4/enforce_detgammahat_constraint.h
    (BENCH) Finished gamma constraint C codegen in 0.3458900451660156 seconds.
    (BENCH) Finished generating psi4_im_pt1 in 34.87725281715393 seconds.
    (BENCH) Finished generating psi4_im_pt2 in 48.07617902755737 seconds.
    (BENCH) Finished generating psi4_re_pt2 in 62.37357544898987 seconds.
    (BENCH) Finished generating psi4_re_pt1 in 64.43620777130127 seconds.
    Output C function rhs_eval() to file EMCC_BSSN_Two_BHs_Collide_Psi4/rhs_eval.h
    (BENCH) Finished BSSN_RHS C codegen in 64.70630764961243 seconds.
    Output C function Ricci_eval() to file EMCC_BSSN_Two_BHs_Collide_Psi4/Ricci_eval.h
    (BENCH) Finished Ricci C codegen in 69.96846079826355 seconds.
    (BENCH) Finished generating psi4_im_pt0 in 113.97330212593079 seconds.
    Output C function Hamiltonian_constraint() to file EMCC_BSSN_Two_BHs_Collide_Psi4/Hamiltonian_constraint.h
    (BENCH) Finished Hamiltonian C codegen in 127.71309232711792 seconds.
    (BENCH) Finished generating psi4_re_pt0 in 196.5384418964386 seconds.
    (BENCH) Finished BL initial data codegen in 286.0503706932068 seconds.

## Step 3.g: Output C codes needed for declaring and setting Cparameters; also set `free_parameters.h` \[Back to [top](#toc)\]
$$\label{cparams_rfm_and_domainsize}$$

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 initial data parameters, as well as grid domain & reference metric parameters, applying `domain_size` and `sinh_width`/`SymTP_bScale` (if applicable) as set above

```python
# Step 3.f.i: Generate declare_Cparameters_struct.h, set_Cparameters_default.h, and set_Cparameters[-SIMD].h
par.generate_Cparameters_Ccodes(os.path.join(Ccodesdir))

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

// Set free-parameter values for BSSN evolution:
params.eta = 2.0;

// Set free parameters for the (Brill-Lindquist) initial data
params.BH1_posn_x = 0.0; params.BH1_posn_y = 0.0; params.BH1_posn_z =+0.25;
params.BH2_posn_x = 0.0; params.BH2_posn_y = 0.0; params.BH2_posn_z =-0.25;
params.BH1_mass = 0.5; params.BH2_mass = 0.5;\n""")

# Append to $Ccodesdir/free_parameters.h reference metric parameters based on generic
#    domain_size,sinh_width,sinhv2_const_dr,SymTP_bScale,
#    parameters set above.
rfm.out_default_free_parameters_for_rfm(os.path.join(Ccodesdir,"free_parameters.h"),
                                        domain_size,sinh_width,sinhv2_const_dr,SymTP_bScale)

# Step 3.f.iii: Generate set_Nxx_dxx_invdx_params__and__xx.h:
rfm.set_Nxx_dxx_invdx_params__and__xx_h(Ccodesdir)

# Step 3.f.iv: Generate xx_to_Cart.h, which contains xx_to_Cart() for
#               (the mapping from xx->Cartesian) for the chosen
#               CoordSystem:
rfm.xx_to_Cart_h("xx_to_Cart","./set_Cparameters.h",os.path.join(Ccodesdir,"xx_to_Cart.h"))

# Step 3.f.v: Generate declare_Cparameters_struct.h, set_Cparameters_default.h, and set_Cparameters[-SIMD].h
par.generate_Cparameters_Ccodes(os.path.join(Ccodesdir))
```

# Step 4: Set up boundary condition functions for chosen singular, curvilinear coordinate system \[Back to [top](#toc)\]
$$\label{bc_functs}$$

Next apply singular, curvilinear coordinate boundary conditions [as documented in the corresponding NRPy+ tutorial notebook](Tutorial-Start_to_Finish-Curvilinear_BCs.ipynb)

```python
import CurviBoundaryConditions.CurviBoundaryConditions as cbcs
cbcs.Set_up_CurviBoundaryConditions(os.path.join(Ccodesdir,"boundary_conditions/"),
                                    Cparamspath=os.path.join("../"),path_prefix="../nrpytutorial/")
```

    Wrote to file "EMCC_BSSN_Two_BHs_Collide_Psi4/boundary_conditions/parity_conditions_symbolic_dot_products.h"
    Evolved parity: ( aDD00:4, aDD01:5, aDD02:6, aDD11:7, aDD12:8, aDD22:9,
        alpha:0, betU0:1, betU1:2, betU2:3, cf:0, hDD00:4, hDD01:5, hDD02:6,
        hDD11:7, hDD12:8, hDD22:9, lambdaU0:1, lambdaU1:2, lambdaU2:3, trK:0,
        vetU0:1, vetU1:2, vetU2:3 )
    Auxiliary parity: ( H:0, psi4i_0pt:0, psi4i_1pt:0, psi4i_2pt:0,
        psi4r_0pt:0, psi4r_1pt:0, psi4r_2pt:0 )
    AuxEvol parity: ( RbarDD00:4, RbarDD01:5, RbarDD02:6, RbarDD11:7,
        RbarDD12:8, RbarDD22:9 )
    Wrote to file "EMCC_BSSN_Two_BHs_Collide_Psi4/boundary_conditions/EigenCoord_Cart_to_xx.h"

# Step 5: `BrillLindquist_Playground.c`: The Main C Code \[Back to [top](#toc)\]

$$\label{mainc}$$

## Psi4 Header Files

*(Code moved here from 3.e.ii)*

```python
%%writefile $Ccodesdir/lowlevel_decompose_psi4_into_swm2_modes.h

void lowlevel_decompose_psi4_into_swm2_modes(const paramstruct *restrict params,
            const REAL curr_time, const REAL R_ext,
            const REAL *restrict th_array,const REAL *restrict sinth_array,const REAL *restrict ph_array,
            const REAL *restrict psi4r_at_R_ext,const REAL *restrict psi4i_at_R_ext) {
    #include "set_Cparameters.h"
    for(int l=2;l<=L_MAX;l++) { // L_MAX is a global variable, since it must be set in Python (so that SpinWeight_minus2_SphHarmonics() computes enough modes)
        for(int m=-l;m<=l;m++) {
            // Parallelize the integration loop:
            REAL psi4r_l_m = 0.0;
            REAL psi4i_l_m = 0.0;
            #pragma omp parallel for reduction(+:psi4r_l_m,psi4i_l_m)
            for(int i1=0;i1<Nxx_plus_2NGHOSTS1-2*NGHOSTS;i1++) {
                const REAL th    = th_array[i1];
                const REAL sinth = sinth_array[i1];
                for(int i2=0;i2<Nxx_plus_2NGHOSTS2-2*NGHOSTS;i2++) {
                    const REAL ph = ph_array[i2];
                    // Construct integrand for psi4 spin-weight s=-2,l=2,m=0 spherical harmonic
                    REAL ReY_sm2_l_m,ImY_sm2_l_m;
                    SpinWeight_minus2_SphHarmonics(l,m, th,ph,  &ReY_sm2_l_m,&ImY_sm2_l_m);

                    const int idx2d = i1*(Nxx_plus_2NGHOSTS2-2*NGHOSTS)+i2;
                    const REAL a = psi4r_at_R_ext[idx2d];
                    const REAL b = psi4i_at_R_ext[idx2d];
                    const REAL c = ReY_sm2_l_m;
                    const REAL d = ImY_sm2_l_m;
                    psi4r_l_m += (a*c + b*d) * dxx2  * sinth*dxx1;
                    psi4i_l_m += (b*c - a*d) * dxx2  * sinth*dxx1;
                }
            }
            // Step 4: Output the result of the integration to file.
            char filename[100];
            sprintf(filename,"outpsi4_l%d_m%d-%d-r%.2f.txt",l,m, Nxx0,(double)R_ext);
            if(m>=0) sprintf(filename,"outpsi4_l%d_m+%d-%d-r%.2f.txt",l,m, Nxx0,(double)R_ext);
            FILE *outpsi4_l_m;
            // 0 = n*dt when n=0 is exactly represented in double/long double precision,
            //          so no worries about the result being ~1e-16 in double/ld precision
            if(curr_time==0) outpsi4_l_m = fopen(filename, "w");
            else        outpsi4_l_m = fopen(filename, "a");
            fprintf(outpsi4_l_m,"%e %.15e %.15e\n", (double)(curr_time),
                    (double)psi4r_l_m,(double)psi4i_l_m);
            fclose(outpsi4_l_m);
        }
    }
}
```

    Writing EMCC_BSSN_Two_BHs_Collide_Psi4//lowlevel_decompose_psi4_into_swm2_modes.h

```python
%%writefile $Ccodesdir/driver_psi4_spinweightm2_decomposition.h

// Define global variable to be accessed in main C code
REAL *restrict diagnostic_output_gfs_p;

void driver_psi4_spinweightm2_decomposition(const paramstruct *restrict params,
                                            const REAL curr_time, const int R_ext_idx,
                                            REAL *restrict xx[3],
                                            const REAL *restrict y_n_gfs,
                                            REAL *restrict diagnostic_output_gfs,
                                            int i0, int i1, int i2, int lastRun) {

    // Step 0: Set global variable to local value
    diagnostic_output_gfs_p = diagnostic_output_gfs;

    #include "set_Cparameters.h"
    // Step 1: Set the extraction radius R_ext based on the radial index R_ext_idx
    REAL R_ext;
    {
        REAL xx0 = xx[0][R_ext_idx];
        REAL xx1 = xx[1][1];
        REAL xx2 = xx[2][1];
        REAL xCart[3];
        xx_to_Cart(params,xx,R_ext_idx,1,1,xCart);
        R_ext = sqrt(xCart[0]*xCart[0] + xCart[1]*xCart[1] + xCart[2]*xCart[2]);
    }

    // Step 2: Compute psi_4 at a specific point on this extraction radius and store to a local 2D array.
    const int sizeof_2Darray = sizeof(REAL)*(Nxx_plus_2NGHOSTS1-2*NGHOSTS)*(Nxx_plus_2NGHOSTS2-2*NGHOSTS);
    REAL *restrict psi4r_at_R_ext = (REAL *)malloc(sizeof_2Darray);
    REAL *restrict psi4i_at_R_ext = (REAL *)malloc(sizeof_2Darray);

    //         ... also store theta, sin(theta), and phi to corresponding 1D arrays.
    REAL *restrict sinth_array = (REAL *restrict)malloc(sizeof(REAL)*(Nxx_plus_2NGHOSTS1-2*NGHOSTS));
    REAL *restrict th_array    = (REAL *restrict)malloc(sizeof(REAL)*(Nxx_plus_2NGHOSTS1-2*NGHOSTS));
    REAL *restrict ph_array    = (REAL *restrict)malloc(sizeof(REAL)*(Nxx_plus_2NGHOSTS2-2*NGHOSTS));

#pragma omp parallel for
    th_array[i1-NGHOSTS]    =     xx[1][i1];
    sinth_array[i1-NGHOSTS] = sin(xx[1][i1]);
    ph_array[i2-NGHOSTS] = xx[2][i2];

    psi4(params, i0,i1,i2,  xx,  y_n_gfs,  diagnostic_output_gfs);

    if(lastRun==1){
        // Step 3: Once all points at this extraction radius have been compute perform integrations across all
        //         l,m modes from l=2 up to and including L_MAX (global variable):
        lowlevel_decompose_psi4_into_swm2_modes(params, curr_time,R_ext, th_array,sinth_array, ph_array,
                                            psi4r_at_R_ext,psi4i_at_R_ext);
    }
}
```

    Writing EMCC_BSSN_Two_BHs_Collide_Psi4//driver_psi4_spinweightm2_decomposition.h

## Main c files

```python
# Part P0: Define REAL, set the number of ghost cells NGHOSTS (from NRPy+'s FD_CENTDERIVS_ORDER),
#          and set the CFL_FACTOR (which can be overwritten at the command line)

with open(os.path.join(Ccodesdir,"BSSN_Playground_REAL__NGHOSTS__CFL_FACTOR.h"), "w") as file:
    file.write("""
// Part P0.a: Set the number of ghost cells, from NRPy+'s FD_CENTDERIVS_ORDER
#define NGHOSTS """+str(int(FD_order/2)+1)+"""
// Part P0.b: Set the numerical precision (REAL) to double, ensuring all floating point
//            numbers are stored to at least ~16 significant digits
#define REAL """+REAL+"""
// Part P0.c: Set the number of ghost cells, from NRPy+'s FD_CENTDERIVS_ORDER
REAL CFL_FACTOR = """+str(CFL_FACTOR)+"""; // Set the CFL Factor. Can be overwritten at command line.
// Part P0.d: We decompose psi_4 into all spin-weight=-2
//            l,m spherical harmonics, starting at l=2,
//            going up to and including l_max, set here:
#define L_MAX """+str(l_max)+"""
""")
```

```python
%%writefile $Ccodesdir/BrillLindquist_Playground.c

// Step P0: Define REAL and NGHOSTS; and declare CFL_FACTOR. This header is generated in NRPy+.
#include "BSSN_Playground_REAL__NGHOSTS__CFL_FACTOR.h"

#include "rfm_files/rfm_struct__declare.h"

#include "declare_Cparameters_struct.h"

#include "emscripten.h"

// All SIMD intrinsics used in SIMD-enabled C code loops are defined here:
#include "SIMD/SIMD_intrinsics.h"

// Step P1: Import needed header files
#include "stdio.h"
#include "stdlib.h"
#include "math.h"
#include "time.h"
#include "stdint.h" // Needed for Windows GCC 6.x compatibility
#ifndef M_PI
#define M_PI 3.141592653589793238462643383279502884L
#endif
#ifndef M_SQRT1_2
#define M_SQRT1_2 0.707106781186547524400844362104849039L
#endif
#define wavespeed 1.0 // Set CFL-based "wavespeed" to 1.0.

// Step P2: Declare the IDX4S(gf,i,j,k) macro, which enables us to store 4-dimensions of
//           data in a 1D array. In this case, consecutive values of "i"
//           (all other indices held to a fixed value) are consecutive in memory, where
//           consecutive values of "j" (fixing all other indices) are separated by
//           Nxx_plus_2NGHOSTS0 elements in memory. Similarly, consecutive values of
//           "k" are separated by Nxx_plus_2NGHOSTS0*Nxx_plus_2NGHOSTS1 in memory, etc.
// #define IDX4SS(g,i,j,k) IDX4S(g,i,j,k)
#define IDX4S(g,i,j,k) \
( (i) + Nxx_plus_2NGHOSTS0 * ( (j) + Nxx_plus_2NGHOSTS1 * ( (k) + Nxx_plus_2NGHOSTS2 * (g) ) ) )
#define IDX4ptS(g,idx) ( (idx) + (Nxx_plus_2NGHOSTS0*Nxx_plus_2NGHOSTS1*Nxx_plus_2NGHOSTS2) * (g) )
#define IDX3S(i,j,k) ( (i) + Nxx_plus_2NGHOSTS0 * ( (j) + Nxx_plus_2NGHOSTS1 * ( (k) ) ) )
#define LOOP_REGION(i0min,i0max, i1min,i1max, i2min,i2max) \
  for(int i2=i2min;i2<i2max;i2++) for(int i1=i1min;i1<i1max;i1++) for(int i0=i0min;i0<i0max;i0++)
#define LOOP_ALL_GFS_GPS(ii) _Pragma("omp parallel for") \
  for(int (ii)=0;(ii)<Nxx_plus_2NGHOSTS_tot*NUM_EVOL_GFS;(ii)++)

// Step P3: Set UUGF and VVGF macros, as well as xx_to_Cart()
#include "boundary_conditions/gridfunction_defines.h"

// Step P4: Set xx_to_Cart(const paramstruct *restrict params,
//                     REAL *restrict xx[3],
//                     const int i0,const int i1,const int i2,
//                     REAL xCart[3]),
//           which maps xx->Cartesian via
//    {xx[0][i0],xx[1][i1],xx[2][i2]}->{xCart[0],xCart[1],xCart[2]}
#include "xx_to_Cart.h"

// Step P5: Defines set_Nxx_dxx_invdx_params__and__xx(const int EigenCoord, const int Nxx[3],
//                                       paramstruct *restrict params, REAL *restrict xx[3]),
//          which sets params Nxx,Nxx_plus_2NGHOSTS,dxx,invdx, and xx[] for
//          the chosen Eigen-CoordSystem if EigenCoord==1, or
//          CoordSystem if EigenCoord==0.
#include "set_Nxx_dxx_invdx_params__and__xx.h"

// Step P6: Include basic functions needed to impose curvilinear
//          parity and boundary conditions.
#include "boundary_conditions/CurviBC_include_Cfunctions.h"

// Step P7: Implement the algorithm for upwinding.
//          *NOTE*: This upwinding is backwards from
//          usual upwinding algorithms, because the
//          upwinding control vector in BSSN (the shift)
//          acts like a *negative* velocity.
//#define UPWIND_ALG(UpwindVecU) UpwindVecU > 0.0 ? 1.0 : 0.0

// Step P8: Include function for enforcing detgammahat constraint.
#include "enforce_detgammahat_constraint.h"

// Step P9: Find the CFL-constrained timestep
#include "find_timestep.h"

// Step P10: Declare function necessary for setting up the initial data.
// Step P10.a: Define BSSN_ID() for BrillLindquist initial data
// Step P10.b: Set the generic driver function for setting up BSSN initial data
#include "initial_data.h"

// Step P11: Declare function for evaluating Hamiltonian constraint (diagnostic)
#include "Hamiltonian_constraint.h"

// Step P12: Declare rhs_eval function, which evaluates BSSN RHSs
#include "rhs_eval.h"

// Step P13: Declare Ricci_eval function, which evaluates Ricci tensor
#include "Ricci_eval.h"

// Step P14: Declare function for evaluating real and imaginary parts of psi4 (diagnostic)
#include "psi4.h"
#include "SpinWeight_minus2_SphHarmonics/SpinWeight_minus2_SphHarmonics.h"
#include "lowlevel_decompose_psi4_into_swm2_modes.h"
#include "driver_psi4_spinweightm2_decomposition.h"

// Step P15: Declare global pointers and variables to be referenced in getter function.
//           This must be done in order to access variables created in the initialize
//           function in the stepfoward function.
int dimensions[4] = {28, 8, 2, 7};
int arrNGHOSTS[4];
paramstruct params_p;
rfm_struct rfmstruct_p;
bc_struct bcstruct_p;
REAL N_final_p, output_every_N_p, dt_p;
REAL *y_n_gfs_p, *auxevol_gfs_p, *k_odd_gfs_p, *k_even_gfs_p, *y_nplus1_running_total_gfs_p, *xx_p[3];

// initialize() function:
// Step 0: Set up grid structure, allocate memory for gridfunctions, set up coordinates
// Step 1: Set up initial data to an exact solutiondef
// Step 2: Initialize global pointers and variables

int EMSCRIPTEN_KEEPALIVE initialize(REAL CFL_FACTOR){

    paramstruct params;
#include "set_Cparameters_default.h"

    // Step 0a: Set up numerical grid structure, first in space...
    int n1 = dimensions[0];
    int n2 = dimensions[1];
    int n3 = dimensions[2];
    const int Nxx[3] = {n1,n2,n3};

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

    // Step 0c: Uniform coordinate grids are stored to *xx[3]
    REAL *xx[3];
    // Step 0c.i: Set bcstruct
    bc_struct bcstruct;
    {
        int EigenCoord = 1;
        // Step 0c.ii: Call set_Nxx_dxx_invdx_params__and__xx(), which sets
        //             params Nxx,Nxx_plus_2NGHOSTS,dxx,invdx, and xx[] for the
        //             chosen Eigen-CoordSystem.
        set_Nxx_dxx_invdx_params__and__xx(EigenCoord, Nxx, &params, xx);
        // Step 0c.iii: Set Nxx_plus_2NGHOSTS_tot
#include "set_Cparameters-nopointer.h"
        const int Nxx_plus_2NGHOSTS_tot = Nxx_plus_2NGHOSTS0*Nxx_plus_2NGHOSTS1*Nxx_plus_2NGHOSTS2;
        // Step 0d: Find ghostzone mappings; set up bcstruct
#include "boundary_conditions/driver_bcstruct.h"
        // Step 0d.i: Free allocated space for xx[][] array
        for(int i=0;i<3;i++) free(xx[i]);
    }

    // Step 0e: Call set_Nxx_dxx_invdx_params__and__xx(), which sets
    //          params Nxx,Nxx_plus_2NGHOSTS,dxx,invdx, and xx[] for the
    //          chosen (non-Eigen) CoordSystem.
    int EigenCoord = 0;
    set_Nxx_dxx_invdx_params__and__xx(EigenCoord, Nxx, &params, xx);

    // Step 0f: Set all C parameters "blah" for params.blah, including
    //          Nxx_plus_2NGHOSTS0 = params.Nxx_plus_2NGHOSTS0, etc.
    #include "set_Cparameters-nopointer.h"
    const int Nxx_plus_2NGHOSTS_tot = Nxx_plus_2NGHOSTS0*Nxx_plus_2NGHOSTS1*Nxx_plus_2NGHOSTS2;

    // Step 0g: Time coordinate parameters
    const REAL t_final = 1.5*domain_size;

    // Step 0h: Set timestep based on smallest proper distance between gridpoints and CFL factor
    REAL dt = find_timestep(&params, xx);

    N_final_p = (int)(t_final / dt + 0.5); // The number of points in time.
                                             // Add 0.5 to account for C rounding down
                                             // typecasts to integers.
    REAL out_approx_every_t = 0.2;
    output_every_N_p = (int)(out_approx_every_t*((REAL)N_final_p)/t_final);

    // Step 0i: Error out if the number of auxiliary gridfunctions outnumber evolved gridfunctions.
    //              This is a limitation of the RK method. You are always welcome to declare & allocate
    //              additional gridfunctions by hand.
    if(NUM_AUX_GFS > NUM_EVOL_GFS) {
        fprintf(stderr,"Error: NUM_AUX_GFS > NUM_EVOL_GFS. Either reduce the number of auxiliary gridfunctions,\n");
        fprintf(stderr,"       or allocate (malloc) by hand storage for *diagnostic_output_gfs. \n");
        exit(1);
    }

    // Step 0j: Allocate memory for gridfunctions
#include "MoLtimestepping/RK_Allocate_Memory.h"
    REAL *restrict auxevol_gfs = (REAL *)malloc(sizeof(REAL) * NUM_AUXEVOL_GFS * Nxx_plus_2NGHOSTS_tot);

    // Step 0k: Set up precomputed reference metric arrays
    // Step 0k.i: Allocate space for precomputed reference metric arrays.
#include "rfm_files/rfm_struct__malloc.h"

    // Step 0k.ii: Define precomputed reference metric arrays.
    {
    #include "set_Cparameters-nopointer.h"
    #include "rfm_files/rfm_struct__define.h"
    }

    // Step 1a: Set up initial data to an exact solution
    initial_data(&params, xx, y_n_gfs);

    // Step 1b: Apply boundary conditions, as initial data
    //          are sometimes ill-defined in ghost zones.
    //          E.g., spherical initial data might not be
    //          properly defined at points where r=-1.
    apply_bcs_curvilinear(&params, &bcstruct, NUM_EVOL_GFS,evol_gf_parity, y_n_gfs);
    enforce_detgammahat_constraint(&rfmstruct, &params, y_n_gfs);

    //Step 2: Assign pointers/Initialize global variables
    arrNGHOSTS[0] = NGHOSTS;
    arrNGHOSTS[1] = Nxx_plus_2NGHOSTS0;
    arrNGHOSTS[2] = Nxx_plus_2NGHOSTS1;
    arrNGHOSTS[3] = Nxx_plus_2NGHOSTS2;
    dt_p = dt;
    params_p = params;
    rfmstruct_p = rfmstruct;
    bcstruct_p = bcstruct;
    y_n_gfs_p = y_n_gfs;
    auxevol_gfs_p = auxevol_gfs;
    k_odd_gfs_p = k_odd_gfs;
    k_even_gfs_p = k_even_gfs;
    y_nplus1_running_total_gfs_p = y_nplus1_running_total_gfs;
    xx_p[0]=xx[0];
    xx_p[1]=xx[1];
    xx_p[2]=xx[2];

    return 0;
}

// stepForward() function:
// Step 1: Define and initialize variables from initialize() function so they can be used in the RK-like Method
//         of Lines timestepping algorithm
// Step 2: Step forward one timestep (t -> t+dt) in time using chosen RK-like MoL timestepping algorithm

void EMSCRIPTEN_KEEPALIVE stepForward(){

    // Step 1: Redefine and initialize variables. In order to call each time-step one by one, we need to redefine
    //         some variables used in the MoL timestepping algorithm with the saved values from the initialization
    //         step
    int Nxx_plus_2NGHOSTS0 = arrNGHOSTS[1];
    int Nxx_plus_2NGHOSTS1 = arrNGHOSTS[2];
    int Nxx_plus_2NGHOSTS2 = arrNGHOSTS[3];
    int Nxx_plus_2NGHOSTS_tot = Nxx_plus_2NGHOSTS0*Nxx_plus_2NGHOSTS1*Nxx_plus_2NGHOSTS2;
    int N_final = N_final_p;
    int output_every_N = output_every_N_p;
    REAL *restrict diagnostic_output_gfs = diagnostic_output_gfs_p;
    REAL dt = dt_p;
    paramstruct params = params_p;
    rfm_struct rfmstruct = rfmstruct_p;
    bc_struct bcstruct = bcstruct_p;
    REAL *y_n_gfs = y_n_gfs_p;
    REAL *restrict auxevol_gfs = auxevol_gfs_p;
    REAL *k_odd_gfs = k_odd_gfs_p;
    REAL *k_even_gfs = k_even_gfs_p;
    REAL *restrict y_nplus1_running_total_gfs = y_nplus1_running_total_gfs_p;
    REAL *xx[3];
    xx[0]=xx_p[0];
    xx[1]=xx_p[1];
    xx[2]=xx_p[2];
#include "boundary_conditions/driver_bcstruct.h"

    // Step 2: Step forward one timestep (t -> t+dt) in time using
    //           chosen RK-like MoL timestepping algorithm

#include "MoLtimestepping/RK_MoL.h"
}

// runSpinWeightFx() function:
// Step 1: Runs Psi4 spinweight function given a specific point on a specific extraction radius

void EMSCRIPTEN_KEEPALIVE runSpinWeightFx(int n, const int R_ext_idx, int i0, int i1, int i2, int lastRun) {
    // Step 1: Call spinweight decomposition function
    driver_psi4_spinweightm2_decomposition(&params_p, ((REAL)n)*dt_p, R_ext_idx, xx_p, y_n_gfs_p,
                                           diagnostic_output_gfs_p, i0, i1, i2, lastRun);
}

// Getter functions used to access the data in javascript after compliling.

// getNFinal(): returns final time-step value
REAL EMSCRIPTEN_KEEPALIVE getNFinal(){
    return N_final_p;
}

// getNGHOSTS(): returns desired dimensions including ghosts shells
REAL EMSCRIPTEN_KEEPALIVE getNGHOSTS(int i){
    return arrNGHOSTS[i];
}

// getFxVal(): returns desired function value at specfic point in space, index i is determined from the
//             IDX3S and IDX4ptS arrays
REAL EMSCRIPTEN_KEEPALIVE getFxVal(int i){
    return y_n_gfs_p[i];
}

// getIDX3S(): returns IDX3S index for a given point
REAL EMSCRIPTEN_KEEPALIVE getIDX3S(int i0, int i1, int i2){
    int Nxx_plus_2NGHOSTS0 = arrNGHOSTS[1];
    int Nxx_plus_2NGHOSTS1 = arrNGHOSTS[2];
    int Nxx_plus_2NGHOSTS2 = arrNGHOSTS[3];
    return IDX3S(i0,i1,i2);
}

// getIDX4ptS(): returns IDX4ptS index to be used in getFxVal() using information from getIDX3S()
REAL EMSCRIPTEN_KEEPALIVE getIDX4ptS(int fx, REAL idx){
    int Nxx_plus_2NGHOSTS0 = arrNGHOSTS[1];
    int Nxx_plus_2NGHOSTS1 = arrNGHOSTS[2];
    int Nxx_plus_2NGHOSTS2 = arrNGHOSTS[3];
    return IDX4ptS(fx,idx);
}

// getCart(): returns the cartesian version of a given spherical coordinate
REAL EMSCRIPTEN_KEEPALIVE getCart(int i0, int i1, int i2, int index){
    REAL xCart[3];
    xx_to_Cart(&params_p,xx_p,i0,i1,i2,xCart);
    return xCart[index];
}

// setDim(): setter function allowing the user to change the resolution of the simulation
void EMSCRIPTEN_KEEPALIVE setDim(int dim1, int dim2, int dim3){
    dimensions[0] = dim1;
    dimensions[1] = dim2;
    dimensions[2] = dim3;
    dimensions[3]= dim1/4;
}

// getDim(): getter function for the dimensions/resolution of the simulation
int EMSCRIPTEN_KEEPALIVE getDim(int i){
    return dimensions[i];
}

// getPsi4R(): getter function for the real part of psi 4 at a given point
REAL EMSCRIPTEN_KEEPALIVE getPsi4R(int idx3d){
    int Nxx_plus_2NGHOSTS0 = arrNGHOSTS[1];
    int Nxx_plus_2NGHOSTS1 = arrNGHOSTS[2];
    int Nxx_plus_2NGHOSTS2 = arrNGHOSTS[3];
    REAL *restrict diagnostic_output_gfs = diagnostic_output_gfs_p;

    const REAL psi4r = (+diagnostic_output_gfs[IDX4ptS(PSI4R_0PTGF,idx3d)]
                                +diagnostic_output_gfs[IDX4ptS(PSI4R_1PTGF,idx3d)]
                                +diagnostic_output_gfs[IDX4ptS(PSI4R_2PTGF,idx3d)]);
    return psi4r;
}

// getPsi4R(): getter function for the imaginary part of psi 4 at a given point
REAL EMSCRIPTEN_KEEPALIVE getPsi4I(int idx3d){
    int Nxx_plus_2NGHOSTS0 = arrNGHOSTS[1];
    int Nxx_plus_2NGHOSTS1 = arrNGHOSTS[2];
    int Nxx_plus_2NGHOSTS2 = arrNGHOSTS[3];
    REAL *restrict diagnostic_output_gfs = diagnostic_output_gfs_p;

    const REAL psi4i = (+diagnostic_output_gfs[IDX4ptS(PSI4I_0PTGF,idx3d)]
                                +diagnostic_output_gfs[IDX4ptS(PSI4I_1PTGF,idx3d)]
                                +diagnostic_output_gfs[IDX4ptS(PSI4I_2PTGF,idx3d)]);
    return psi4i;
}

// runsim(): function that runs the initialize function with a specific resolution and CFL factor
void EMSCRIPTEN_KEEPALIVE runsim(){
    CFL_FACTOR = 1.0;

    initialize(CFL_FACTOR);
}
```

    Writing EMCC_BSSN_Two_BHs_Collide_Psi4//BrillLindquist_Playground.c

# Step 6: Compile

\[Back to [top](#toc)\]
$$\label{compileexec}$$

Use emscripten to generate compilied html, wasm and js files. Copy the wasm and js files to the main javascript directory.

```python
main_c_file = os.path.join(Ccodesdir,"BrillLindquist_Playground.c")
main_output_file = os.path.join(outdir,"BHCollidePsi4.html")
print("Attempting to compile\n", main_c_file, "\nto\n", main_output_file,"\n")
cmd.C_compile(main_c_file, main_output_file, compile_mode="emscripten")

print("\nFiles in output directory are:\n", os.listdir(outdir))
```

    Attempting to compile
     EMCC_BSSN_Two_BHs_Collide_Psi4/BrillLindquist_Playground.c
    to
     EMCC_BSSN_Two_BHs_Collide_Psi4/output/BHCollidePsi4.html

    Compiling executable...
    (EXEC): Executing `emcc -std=gnu99 -s -O3 -march=native -funroll-loops -s ALLOW_MEMORY_GROWTH=1 EMCC_BSSN_Two_BHs_Collide_Psi4/BrillLindquist_Playground.c -o EMCC_BSSN_Two_BHs_Collide_Psi4/output/BHCollidePsi4.html -lm `...
    (BENCH): Finished executing in 44.89996314048767 seconds.
    Finished compilation.

    Files in output directory are:
     ['BHCollidePsi4.html', 'BHCollidePsi4.js', 'BHCollidePsi4.wasm']

```bash
%%bash
cp EMCC_BSSN_Two_BHs_Collide_Psi4/output/BHCollidePsi4.wasm .
cp EMCC_BSSN_Two_BHs_Collide_Psi4/output/BHCollidePsi4.js .
```


```python
%matplotlib inline

import matplotlib
import numpy as np
import matplotlib.pyplot as plt

plt.rcParams["figure.figsize"] = (12, 9)
plt.rcParams["font.size"] = 18
```

# Reactor Physics

This is not a reactor physics class. However, to understand and analyze fuel management, advanced reactor designs, and spent fuel recycling, one must first have a background in reactor physics.

## Learning Objectives

- Describe criticality in terms of the life cycle of a fission neutron.
- Differentiate concepts such as neutron flux, reaction rates, cross sections, reactivity, multiplication factor.
- Calculate the multiplication factor using the 6 factor formula.
- Calculate the dynamic impact of reactivity feedback on criticality.
- Recognize the impact of moderator material choice on reactor behavior.
- Analyze reactor behavior.
- Calculate the impact of geometry on criticality.

## Neutron Multiplication Fundamentals

### Neutron interactions with matter.

\begin{align}
^1_0n + {^a_z}X \longrightarrow \left({^{a+1}_z}X\right)^*  \longrightarrow
\begin{cases}
^1_0n + {^a_z}X , & \mbox{Resonance Elastic Scattering}\\
^1_0n + \left({^a_z}X\right)^* , & \mbox{Elastic Scattering}\\
{^{a+1}_z}X +\gamma , & \mbox{Radiative Capture}\\
{^{a_1}_{z_2}}X + {^{a_2}_{z_2}}X + \nu^1_0n, & \mbox{Fission}
\end{cases}
\end{align}

### Cross sections

The likelihood of each of these events is captured by cross sections.

- $\sigma_x = $ microscopic cross section $[cm^2]$
- $\Sigma_x = $ macroscopic cross section $[1/length]$
- $\Sigma_x = N\sigma_x $
- $N = $ number density of target atoms $[\#/volume]$

### Cross sections are in units of area. Explain this to your neighbor.

### Reaction Rates

- The microscopic cross section is just the likelihood of the event reported as an area (typically in units of $10^{-24}cm^2$ or "barns" which, to a neutron is like hitting the "broad side of a barn")
- The macroscopic cross section is just the likelihood of the event per unit area of a certain density of target isotopes.
- The reaction rate is the macroscopic cross section times the flux of incident neutrons.

\begin{align}
R_{i,j}(\vec{r}) &= N_j(\vec{r})\int dE \phi(\vec{r},E)\sigma_{i,j}(E)\\
R_{i,j}(\vec{r}) &= \mbox{reactions of type i involving isotope j } [reactions/cm^3s]\\
N_j(\vec{r}) &= \mbox{number of nuclei participating in the reactions } [\#/cm^3]\\
E &= \mbox{energy} [MeV]\\
\phi(\vec{r},E)&= \mbox{flux of neutrons with energy E at position i } [\#/cm^2s]\\
\sigma_{i,j}(E)&= \mbox{cross section } [cm^2]\\
\end{align}

This can be written more simply as $R_x = \Sigma_x I N$, where I is intensity of the neutron flux.

### Neutron attenuation

$$
\begin{align}
I(x) &= I_0e^{-\Sigma_t x}\\
\end{align}
$$

where

$$
\begin{align}
        I(x) &= \mbox{intensity at distance x}\\
        I_0 &= \mbox{initial intensity}\\
        \Sigma_t &= \mbox{macroscopic total cross section} \\
        x &= \mbox{distance into material [m]}\\
\end{align}
$$

```python
import math
def attenuation(distance, initial=100, sig_t=1):
    """This function describes neutron attenuation into the slab"""
    return initial*math.exp(-sig_t*distance)

```

Rather than intensity, one can find the probability density:

In the case of decay:

\begin{align}
P(t)dt &= \lambda e^{-\lambda t}dt
\end{align}

From this, one can find the mean lifetime of a neutron before absorption:

\begin{align}
\bar{t} &= \int_0^\infty t'P(t')dt'\\
        &= \int_0^\infty t'\lambda e^{-\lambda t'}dt'\\
        &= \frac{1}{\lambda}
\end{align}

In the case of attenuation:
\begin{align}
P(x)dx &= \Sigma_te^{-\Sigma_tx}dx
\end{align}

So, the mean free path is:

\begin{align}
\bar{l} &= \int_0^\infty x'P(x')dx'\\
        &= \int_0^\infty x'\Sigma_te^{-\Sigma_t x'}dx'\\
        &= \frac{1}{\Sigma_t}
\end{align}

```python
def prob_dens(distance, initial=100, sig_t=1):
    return sig_t*attenuation(distance, initial=100, sig_t=1)

```

```python
sig_t = 0.05
i_0 = 100

# This code plots attenuation
import numpy as np
z = np.arange(24)
y = np.arange(24)
x = np.arange(24)
for h in range(0,24):
    x[h] = h
    y[h] = attenuation(h, initial=i_0, sig_t=sig_t)
    z[h] = prob_dens(h, initial=i_0, sig_t=sig_t)

# creates a figure and axes with matplotlib
fig, ax = plt.subplots()
scatter = plt.scatter(x, y, color='blue', s=y*20, alpha=0.4)
ax.plot(x, y, color='red')
ax.plot(x, z, color='green')

# adds labels to the plot
ax.set_ylabel('Percent of Neutrons')
ax.set_xlabel('Distance into slab')
ax.set_title('Attenuation')

# adds tooltips
import mpld3
labels = ['{0}% intensity'.format(i) for i in y]
tooltip = mpld3.plugins.PointLabelTooltip(scatter, labels=labels)
mpld3.plugins.connect(fig, tooltip)

mpld3.display()
```

### Source term

The source of neutrons in a reactor are the neutrons from fission.

\begin{align}
s &=\nu \Sigma_f \phi
\end{align}

where

\begin{align}
s &= \mbox{neutrons available for next generation of fissions}\\
\nu &= \mbox{the number of neutrons born per fission}\\
\Sigma_f &= \mbox{the cross section for fissions in the material}\\
\phi &= \mbox{initial neutron flux}
\end{align}

This can also be written as:

\begin{align}
s &= \nu\Sigma_f\phi\\
  &= \nu\frac{\Sigma_f}{\Sigma_{a,fuel}}\frac{\Sigma_{a,fuel}}{\Sigma_a}{\Sigma_a} \phi\\
  &= \eta f {\Sigma_a} \phi\\
\eta &= \frac{\nu\Sigma_f}{\Sigma_{a,fuel}} \\
      &= \mbox{number of neutrons produced per neutron absorbed by the fuel, "neutron reproduction factor"}\\
f &= \frac{\Sigma_{a,fuel}}{\Sigma_a} \\
   &= \mbox{number of neutrons absorbed in the fuel per neutron absorbed anywhere, "fuel utilization factor"}\\
\end{align}

This absorption and flux term at the end seeks to capture the fact that some of the neutrons escape. However, if we assume an infinite reactor, we know that all the neutrons are eventually absorbed in either the fuel or the coolant, so we can normalize by $\Sigma_a\phi$ and therefore:

\begin{align}
k_\infty &= \frac{\eta f \Sigma_a\phi}{\Sigma_a \phi}\\
&= \eta f
\end{align}

### Fission Spectrum

$\chi(E)$ is an empirical probability density function describing the energies of prompt fission neutrons.

\begin{align}
\chi (E) &= 0.453e^{-1.036E}\sinh\left(\sqrt{2.29E}\right)\\
\end{align}

```python
import numpy as np
import math
def chi(energy):
    return 0.453*np.exp(-1.036*energy)*np.sinh(np.sqrt(2.29*energy))

energies = np.arange(0.0,10.0, 0.1)

plt.plot(energies, chi(energies))
plt.title(r'Prompt Neutron Energy Distribution $\chi(E)$')
plt.xlabel("Prompt Neutron Energy [MeV]")
plt.ylabel("probability")
```

#### Questions about this plot:

- What is the most likely prompt neutron energy?
- Can you write an equation for the average neutron energy?

```python
### Max function
print(max([chi(e) for e in energies]), chi(0.7))
```

    0.3581027022872076 0.35810270228720753

#### Expectation Value

Recall that the average energy will be the expectation value of the probability density function.

\begin{align}
<\chi(E)> &= \int\chi(E)dE\\
&= E \chi(E)
\end{align}

```python
plt.plot(energies, [chi(e)*e for e in energies])
```

### What energy neutron do we prefer for fission in U235?

### Four Factor Formula

So, then, if we consider the impact of neutron energy on the likelihood of absorption, we can break the neutrons up into a thermal and a fast group.

So, we can say more explicitly:

\begin{align}
k_\infty =& \epsilon p \eta f\\
\epsilon =& \mbox{ fast fission factor}\\
         =& \frac{\mbox{neutrons produced by all fissions}}{\mbox{neutrons produced by thermal fissions}}\\
p =& \mbox{ resonance escape probability}\\
         =& \frac{\mbox{neutrons that reach thermal energies}}{\mbox{number of fast neutrons that start to slow down}}
\end{align}

If we consider the non-infinite nature of most reactors, we have to add two more factors to the formula:

\begin{align}
k_\infty =& P_{fnl} P_{tnl}\epsilon p \eta f\\
P_{fnl} =& \mbox{ fast neutron non leakage probability}\\
         =& \frac{\mbox{fast neutrons that do not leak from the reacotr}}{\mbox{fast neutrons produced by thermal fissions}}\\
P_{tnl} =& \mbox{ thermal neutron non leakage probability}\\
         =& \frac{\mbox{thermal neutrons that do not leak from the reacotr}}{\mbox{neutrons that reach thermal energies}}\\
\end{align}

## Neutron Transport Fundamentals

### Fast Homogenous Reactor

Consider a reactor that is:

- fast
- critical
- the fuel and coolant are a homogeneous mixture
- the reactor has only one region, no reflector ("bare" reactor)

This reactor can be described by the one group diffusion equation:

\begin{align}
D\nabla^2\phi-\Sigma_a\phi + s &= \frac{1}{v}\frac{\partial \phi}{\partial t}\\
D &= \mbox{ the diffusion coefficient}\\
\phi &= \mbox{ flux}\\
v &= \mbox{ neutron speed}
\end{align}

If the fission source, $s$ does not balance neutron absorption and leakage, then the right hand side of the one-group diffusion equation is nonzero and the power may increase or decrease with time.

### Reactivity is deviation from k

\begin{align}
\rho &= \frac{k-1}{k}
\end{align}

```python
def rho(k):
    """reactivity, rho
    :param k: multiplication factor
    """
    return (k-1)/k

# Plot reactivity as a function of k
import numpy as np
k_vals = np.arange(0.7, 2.0, 0.01)
r = [rho(k) for k in k_vals]
plt.plot(k_vals, r)
plt.ylabel(r'reactivity $\rho$ $[\frac{\delta k}{k}]$',fontsize=20,)
plt.xlabel(r'multiplication factor $k$ $[-]$',fontsize=20,)
plt.title(r'$\rho = \frac{k-1}{k}$', fontsize=20, y=1.02)
```

#### Steady state

The multiplication factor, k, can be used to adjust the source strength and reach a steady state diffusion equation:

\begin{align}
D\nabla^2\phi-\Sigma_a\phi + \frac{1}{k}\nu\Sigma_f\phi &= 0\\
\end{align}

## Diffusion Solution

```python
## This code example was adapted from
## https://github.com/marort91/AlphaEigenvalue/blob/master/RadiationTransportCoding/NeutronDiffusion_Python/NDE_CriticalityEigenvalue.ipynb

def diff(sig_tr):
    return 1.0/(3.0*sig_tr)

def sig_tr(e):
    sig_t(e) - mu*sig_s(e)

D = diff(0.1)
nusigf = 0.70
siga = 0.066

Lx = np.pi*((nusigf-siga)/D)**(-0.5)

N = 50;
h = Lx/(N-1)

x = np.zeros(N)

for i in range(N-1):
    x[i+1] = x[i] + h

L = np.zeros((N,N))
A = np.zeros((N,N))
M = np.zeros((N,N))

for i in range(N):
    L[i][i] = L[i][i] + (-2*(-D/(h**2)))

for i in range(1,N):
    L[i][i-1] = L[i][i-1] + (-D/h**2)

for i in range(N-1):
    L[i][i+1] = L[i][i+1] + (-D/h**2)

for i in range(N):
    A[i][i] = A[i][i] + siga

M = L + A

M[0][0] = 1
M[0][1] = 0
M[N-1][N-1] = 1
M[N-1][N-2] = 0

phi0 = np.ones((N,1))
phi0[0] = 0
phi0[N-1] = 0

tol = 1e-15
k = 1.00

def is_converged(k_old, k, tol):
    return np.abs(k - k_old) <= tol

for i in range(100):
    # update k
    k_old = k
    # solve for psi
    psi = np.linalg.solve(M, nusigf*phi0)

    # solve for k
    k = sum(nusigf*psi)/sum(nusigf*phi0)

    # solve for phi
    phi0 = (1/k)*psi
    phi0[0] = 0
    phi0[N-1] = 0

    # determine convergence
    if is_converged(k_old, k, tol):
        break

plt.plot(x, phi0)
plt.xlabel('Slab (cm)')
plt.ylabel('Neutron Flux')
plt.grid()

print("k-effective = ", k)

print(" approx alpha = ", rho(k) * sum(nusigf*phi0)/sum(phi0))
```

### One Group Reactor Equation

We can define a quantity, geometric bucking, as:

\begin{align}
B^2 &= \frac{1}{D}\left(\frac{\nu}{k}\Sigma_f - \Sigma_a\right)\\
\end{align}

Next, we can simplify the previous equation using this definition, so that the one-group reactor equation becomes:

\begin{align}
\nabla^2\phi + B^2\phi &= 0\\
\end{align}

To find the criticality of a reactor with a specific geometry, then, we can solve the geometric buckling equation for k:

\begin{align}
k &= \frac{\nu\Sigma_f}{DB^2 + \Sigma_a}\\
\end{align}

The buckling, B, is used to help describe the geometry of the system. The solutions of the one group reactor equation for boundary conditions corresponding to canonical shapes provide both flux and buckling formulae for each canonical shape:

#### Slab
Thickness a:
\begin{align}
\phi &= cos\left(\frac{\pi x}{a}\right)\\
B^2 &= \frac{\pi^2}{a^2}
\end{align}

#### Cylinder
Height H, Radius R:
\begin{align}
\phi &= J_0\left(\frac{\nu_0r}{R}\right)cos\left(\frac{\pi z}{H}\right)\\
B^2 &= \frac{\nu_0^2}{R^2} + \frac{\pi^2}{H^2}
\end{align}

#### Sphere
Radius R:
\begin{align}
\phi &= \left(\frac{1}{r}\right)sin\left(\frac{\pi r}{R}\right)\\
B^2 &= \frac{\pi^2}{R^2}
\end{align}

```python
def k(nusigf, diff, bsq, siga):
    return nusigf/(diff*bsq + siga)

def bsq_sphere(r):
    return (np.pi**2)/(r**2)

nusigf = 0.3
diff = 1.1
siga =0.01

fig, ax1 = plt.subplots()
radii = np.arange(0.1, 5.0, 0.01)
ax1.plot(radii, k(nusigf, diff, bsq_sphere(radii), siga), 'b-', label='k')
ax1.set_xlabel('radius (r)')
ax1.set_ylabel('k', color='b')
ax1.set_title('Criticality and Geometric Buckling of a Sphere')
for tl in ax1.get_yticklabels():
    tl.set_color('b')

ax2 = ax1.twinx()

ax2.plot(radii,  bsq_sphere(radii), 'darkorange', label=r'$B^2$')
ax2.set_ylabel(r'$B^2$', color='darkorange')
for tl in ax2.get_yticklabels():
    tl.set_color('darkorange')

ax1.legend()
ax2.legend(loc=2)
```

### Multigroup and beyond

To capture the variation of neutron energies in the diffusion equation, we can discretize the flux into energy groups.

\begin{align}
\phi &= \sum_{g=1}^{g=G}\phi_g\\
\phi_g &= \int_{E_g}^{E_{g-1}}\phi(E)dE\rvert_{g = 1,G}
\end{align}

The diffusion coefficient also needs to be individually evaluated for each energy group such that we have a $D_g$ for each group g. Also, it is important to consider possible paths of demise for potential fission neutrons.

The derivation, based on integration, can be found in your book, pages 128-130. It is based on the following integration:

\begin{align}
\int_{E_g}^{E_{g-1}}dE\left[-\nabla D(\vec{r},E)\nabla\phi(\vec{r},E)+ \Sigma_t(\vec{r},E)\phi(\vec{r},E)\right] &= \int_{E_g}^{E_{g-1}}dE\left[\int_{E'}\Sigma_{s}(\vec{r},E'\rightarrow E)\phi(\vec{r},E') + S(\vec{r},E)\right]\\
\end{align}

Once this integration is completed for a set of group boundaries, $g\in[1,G] \rightarrow E_g\in[E_1,E_G]$, one can generically write:

\begin{align}
-\nabla D_g\nabla\phi_g+ \Sigma_{t,g}\phi_g &= \sum_{g'=1}^{g'=G}\Sigma_{s}^{g'\rightarrow g}\phi_{g'} + S\int_{E_g}^{E_{g-1}}\chi(E)dE\\
\end{align}

Recall, however, that the fission spectrum is a probability density function, so one can also write the following identities:

\begin{align}
\int_0^\infty\chi(E) &= 1\\
\chi_g &= \int_{E_g}^{E_{g-1}}\chi(E)dE\\
\sum_{g=1}^{g=G}\chi_g &= 1\\
\end{align}

This simplifies the diffusion equation:

\begin{align}
-\nabla D_g\nabla^2\phi_g+ \Sigma_{t,g}\phi_g &= \sum_{g'=1}^{g'=G}\Sigma_{s}^{g'\rightarrow g}\phi_{g'} + \chi_gS\\
\end{align}

where

\begin{align}
S_g &= \sum_{g'=1}^{g'=G}(\nu\Sigma_f)_{g'}\phi_{g'}
\end{align}

#### Group Removal Cross Section

The right hand side summation of the scattering cross section is confusing though. Most of the scattering is from other groups into this one, but some of the scattering is from this group into itself. Keeping that term, $\Sigma_s^{g'\rightarrow g}$ on the right hand side is misleading. It's not a source of new neutrons. So, we have a change of variables that can clean this up, the group removal cross section.

\begin{align}
\Sigma_{R,g} &= \Sigma_{t,g} - \Sigma_{s}^{g'\rightarrow g}
\end{align}

If we use the group removal cross section, then we arrive at the following form of the multigroup equation.

\begin{align}
-\nabla D_g\nabla^2\phi_g+ \Sigma_{R,g}\phi_g &= \sum_{g'\ne G}^{g'=G}\Sigma_{s}^{g'\rightarrow g}\phi_{g'} + \chi_g\sum_{g'=1}^{g'=G}(\nu\Sigma_f)_{g'}\phi_{g'}\\
\end{align}

### Two Group Diffusion

Let's define just two groups, fast and thermal. Let's also state that all prompt neutrons are born fast and that the diffusion coefficient and cross sections do not vary in space. With these assumptions, we arrive at the following two equations:

\begin{align}
-D_1\nabla^2\phi_1 + \Sigma_{R,1}\phi_1 &= \Sigma_{s}^{2\rightarrow 1}\phi_{2} + \left[(\nu\Sigma_f)_{1}\phi_{1} + (\nu\Sigma_f)_{2}\phi_{2}\right]\\
\end{align}

\begin{align}
- D_2\nabla^2\phi_2+ \Sigma_{R,2}\phi_2 &= \Sigma_{s}^{1\rightarrow 2}\phi_{1}\\
\end{align}

**Discussion: What happened to the prompt fission spectrum, $\chi_g$ in the above equations?**

**Discussion: If we neglect upscattering, which term or terms in the above two equations will disappear?**

### Criticality calculation

For criticality calculations, one might normalize the prompt fission spectrum with k as we have done before. For simplicity, one can move the scattering term to the left hand side, as if to say "diffusion, group removal, and scattering are balanced at criticality."

\begin{align}
-\nabla D_g\nabla\phi_g+ \Sigma_{R,g}\phi_g - \sum_{g'\ne G}^{g'=G}\Sigma_{s}^{g'\rightarrow g}\phi_{g'} &= \frac{1}{k}\chi_g\sum_{g'=1}^{g'=G}(\nu\Sigma_f)_{g'}\phi_{g'}\\
\end{align}

This change propagates to the explicit equations for two groups thus:

\begin{align}
- D_1\nabla^2\phi_1 + \Sigma_{R,1}\phi_1 - \Sigma_{s}^{2\rightarrow 1}\phi_{2} &= \frac{1}{k}\chi_1\left[(\nu\Sigma_f)_{1}\phi_{1} + (\nu\Sigma_f)_{2}\phi_{2}\right]\\
\end{align}

\begin{align}
- D_2\nabla^2\phi_2+ \Sigma_{R,2}\phi_2 - \Sigma_{s}^{1\rightarrow 2}\phi_{1} &= 0\\
\end{align}

### Addition of chemical shim

Together let us consider the impact of a chemical shim, some absorber introduced to the coolant.

**Discussion: In the two group equations, which parameter will change?**

In the thermal group, group 2, the removal cross section will change, because it involves absorption. The amount of chemical shim will impact the absorption cross section thus:

\begin{align}
\Sigma_{a',2} &= \Sigma_{a,2} + \Sigma_{shim,2} \\
\Sigma_{shim, 2} &= \Sigma_{a',2} - \Sigma_{a,2}\\
&= \rho_{shim}\frac{N_{avo}}{A_{shim}}\sigma_{a,shim,2}\\
\end{align}

## Wrap-up

- Describe criticality in terms of the life cycle of a fission neutron.
- Differentiate concepts such as neutron flux, reaction rates, cross sections, reactivity, multiplication factor.
- Calculate the multiplication factor using the 6 factor formula.
- Calculate the dynamic impact of reactivity feedback on criticality.
- Recognize the impact of moderator material choice on reactor behavior.
- Analyze reactor behavior.
- Calculate the impact of geometry on criticality.

## References

This section was developed to complement pages YY-ZZ of [1].

[1] N. Tsoulfanidis, The Nuclear Fuel Cycle. La Grange Park, Illinois, USA: American Nuclear Society, 2013.


```python
import sys
sys.path.append('..')
import torch
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import LabelEncoder, MinMaxScaler
from sympy import simplify_logic

from lens.utils.relu_nn import get_reduced_model, prune_features
from lens import logic
from lens.utils.base import collect_parameters

torch.manual_seed(0)
np.random.seed(0)
```

```python
gene_expression_matrix = pd.read_csv('reduced_w_1/data.csv', index_col=None, header=None)
labels = pd.read_csv('reduced_w_1/tempLabels_W-1.csv', index_col=None, header=None)
genes = pd.read_csv('reduced_w_1/features.csv', index_col=None, header=None)
```

```python
gene_expression_matrix
```

      0
      1
      2
      3
      4

      0
      3.320000
      3.320000
      3.32000
      6.941536
      6.590419

      1
      4.232978
      3.320000
      3.32000
      7.279548
      6.476784

      2
      3.320000
      4.200609
      3.32000
      7.741600
      4.643134

      3
      3.320000
      3.320000
      3.32000
      7.276600
      5.953452

      4
      3.320000
      3.320000
      3.32000
      7.224628
      6.555227

      ...
      ...
      ...
      ...
      ...
      ...

      56
      3.320000
      3.320000
      3.32000
      7.660182
      6.128603

      57
      3.320000
      3.700430
      3.45131
      7.809826
      6.153968

      58
      3.320000
      3.320000
      3.32000
      7.580588
      6.134398

      59
      4.174319
      3.320000
      3.32000
      7.016004
      7.124143

      60
      3.699251
      3.320000
      3.32000
      7.568044
      6.236546

61 rows × 5 columns

```python
labels
```

      0

      0
      diagnosis: healthy control

      1
      diagnosis: healthy control

      2
      diagnosis: healthy control

      3
      diagnosis: healthy control

      4
      diagnosis: healthy control

      ...
      ...

      56
      omalizumab responder status: Responder

      57
      omalizumab responder status: Responder

      58
      omalizumab responder status: Responder

      59
      omalizumab responder status: Responder

      60
      omalizumab responder status: Responder

61 rows × 1 columns

```python
encoder = LabelEncoder()
labels_encoded = encoder.fit_transform(labels.values)
labels_encoded_noncontrols = labels_encoded[labels_encoded!=0] - 1

data_controls = gene_expression_matrix[labels_encoded==0]
data = gene_expression_matrix[labels_encoded!=0]

gene_signature = data_controls.mean(axis=0)
data_scaled = data - gene_signature

scaler = MinMaxScaler((0, 1))
scaler.fit(data_scaled)
data_normalized = scaler.transform(data_scaled)

x_train = torch.FloatTensor(data_normalized)
y_train = torch.FloatTensor(labels_encoded_noncontrols).unsqueeze(1)
print(x_train.shape)
print(y_train.shape)
```

    torch.Size([40, 5])
    torch.Size([40, 1])

    c:\users\pietr\anaconda3\envs\deep-logic\lib\site-packages\sklearn\utils\validation.py:63: DataConversionWarning: A column-vector y was passed when a 1d array was expected. Please change the shape of y to (n_samples, ), for example using ravel().
      return f(*args, **kwargs)

```python
torch.manual_seed(0)
np.random.seed(0)
device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
x_train = x_train.to(device)
y_train = y_train.to(device)

layers = [
    torch.nn.Linear(x_train.size(1), 10, bias=True),
    torch.nn.LeakyReLU(),
    torch.nn.Linear(10, 5, bias=True),
    torch.nn.LeakyReLU(),
    torch.nn.Linear(5, 1, bias=True),
    torch.nn.Sigmoid(),
]
model = torch.nn.Sequential(*layers).to(device)

optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
model.train()
need_pruning = True
for epoch in range(1, 3001):
    # forward pass
    optimizer.zero_grad()
    y_pred = model(x_train)
    # Compute Loss
    loss = torch.nn.functional.binary_cross_entropy(y_pred, y_train)

    for module in model.children():
        if isinstance(module, torch.nn.Linear):
            loss += 0.0 * torch.norm(module.weight, 1)

    # backward pass
    loss.backward()
    optimizer.step()

    # compute accuracy
    if epoch % 1000 == 0:
        y_pred_d = (y_pred > 0.5)
        accuracy = (y_pred_d.eq(y_train).sum(dim=1) == y_train.size(1)).sum().item() / y_train.size(0)
        print(f'Epoch {epoch}: train accuracy: {accuracy:.4f}')

    if epoch > 8000 and need_pruning and epoch % 3000 == 0:
        prune_features(model, 1, device)
        need_pruning = True
```

    Epoch 1000: train accuracy: 1.0000
    Epoch 2000: train accuracy: 1.0000
    Epoch 3000: train accuracy: 1.0000

## Local explanations

```python
np.set_printoptions(precision=2, suppress=True)
outputs = []
for i, (xin, yin) in enumerate(zip(x_train, y_train)):
    model_reduced = get_reduced_model(model, xin.to(device), bias=False).to(device)
    for module in model_reduced.children():
        if isinstance(module, torch.nn.Linear):
            wa = module.weight.cpu().detach().numpy()
            break
    output = model_reduced(xin)

    pred_class = torch.argmax(output)
    true_class = torch.argmax(y_train[i])

    # generate local explanation only if the prediction is correct
    if pred_class.eq(true_class):
        local_explanation = logic.relu_nn.explain_local(model.to(device), x_train, y_train, xin, yin, device=device)
        print(f'Input {(i+1)}')
        print(f'\tx={xin.cpu().detach().numpy()}')
        print(f'\ty={y_train[i].cpu().detach().numpy()}')
        print(f'\ty={output.cpu().detach().numpy()}')
        #print(f'\tw={wa}')
        print(f'\tExplanation: {local_explanation}')
        print()
    outputs.append(output)
    if i > 1:
        break
```

    Input 1
    	x=[1.   0.   0.06 0.55 0.07]
    	y=[0.]
    	y=[0.]
    	Explanation: feature0000000000 & ~feature0000000001 & ~feature0000000002 & feature0000000003 & ~feature0000000004

    Input 2
    	x=[0.13 0.89 0.35 0.49 0.47]
    	y=[0.]
    	y=[0.]
    	Explanation: ~feature0000000000 & feature0000000001 & ~feature0000000002 & ~feature0000000003 & ~feature0000000004

    Input 3
    	x=[0.72 0.38 0.   0.69 0.  ]
    	y=[0.]
    	y=[0.]
    	Explanation: feature0000000000 & ~feature0000000001 & ~feature0000000002 & feature0000000003 & ~feature0000000004

# Combine local explanations

```python
global_explanation, predictions, counter = logic.combine_local_explanations(model, x=x_train, y=y_train,
                                                                          target_class=0,
                                                                          topk_explanations=10, device=device)

ynp = y_train.cpu().detach().numpy()[:, 0]
accuracy = np.sum(predictions == ynp) / len(ynp)
print(f'Accuracy of when using the formula "{global_explanation}": {accuracy:.4f}')
```

    Accuracy of when using the formula "(~feature0000000000 & ~feature0000000001 & ~feature0000000002) | (feature0000000000 & feature0000000004 & ~feature0000000002 & ~feature0000000003) | (feature0000000003 & feature0000000004 & ~feature0000000000 & ~feature0000000001) | (feature0000000003 & feature0000000004 & ~feature0000000000 & ~feature0000000002) | (feature0000000003 & ~feature0000000001 & ~feature0000000002 & ~feature0000000004) | (~feature0000000000 & ~feature0000000002 & ~feature0000000003 & ~feature0000000004)": 0.7750

```python
global_explanation = logic.relu_nn.explain_global(model, n_classes=1, target_class=0, device=device)
explanation = logic.relu_nn.explain_global(model, n_classes=1, target_class=0, device=device)
if explanation not in ['False', 'True', 'The formula is too complex!']:
    accuracy, _ = logic.relu_nn.test_explanation(explanation, target_class=0, x=x_train.cpu(), y=y_train.cpu())
    print(f'Class {0} - Global explanation: "{global_explanation}" - Accuracy: {accuracy:.4f}')
```

    Class 0 - Global explanation: "(feature0000000004 & ~feature0000000000 & ~feature0000000002) | (~feature0000000000 & ~feature0000000001 & ~feature0000000002) | (~feature0000000000 & ~feature0000000002 & ~feature0000000003) | (feature0000000004 & ~feature0000000001 & ~feature0000000002 & ~feature0000000003)" - Accuracy: 0.9250

```python
w, b = collect_parameters(model, device)
feature_weights = w[0]
feature_used_bool = np.sum(np.abs(feature_weights), axis=0) > 0
feature_used = np.nonzero(feature_used_bool)[0]
genes.iloc[feature_used]
```

      0

      0
      ILMN_3286286

      1
      ILMN_1775520

      2
      ILMN_1656849

      3
      ILMN_1781198

      4
      ILMN_1665457

ILMN_3286286, ILMN_1775520, ILMN_1656849, ILMN_1781198, ILMN_1665457

```python
sum(y_train == 0).item() / len(y_train)
```

    0.25


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

## Boosting Trees

To understand additive modeling using trees, recall the
Gram-Schmidt orthogonalization procedure for vectors.  The
purpose of this orthogonalization procedure is to create an
orthogonal set of vectors starting with a given vector
$\mathbf{u}_1$. We have already discussed the projection
operator in the section [ch:prob:sec:projection](#ch:prob:sec:projection).  The
Gram-Schmidt orthogonalization procedure starts with a
vector $\mathbf{v}_1$, which we define as the following:

$$
\mathbf{u}_1 = \mathbf{v}_1
$$

 with the corresponding projection operator $proj_{\mathbf{u}_1}$. The
next step in the procedure is to remove the residual of  $\mathbf{u}_1$
from $\mathbf{v}_2$, as in the following:

$$
\mathbf{u}_2 = \mathbf{v}_2 -  proj_{\mathbf{u}_1}(\mathbf{v}_2)
$$

 This procedure continues for $\mathbf{v}_3$ as in the following:

$$
\mathbf{u}_3 = \mathbf{v}_3 - proj_{\mathbf{u}_1}(\mathbf{v}_3) - proj_{\mathbf{u}_2}(\mathbf{v}_3)
$$

 and so on. The important aspect of this procedure is that new
incoming vectors (i.e., $\mathbf{v}_k$) are stripped of any pre-existing
components already present in the set of $\lbrace\mathbf{u}_1,\mathbf{u}_2,\ldots,\mathbf{u}_M \rbrace$.

Note that this procedure is sequential. That is, the *order*
of the incoming $\mathbf{v}_i$ matters [^rotation]. Thus,
any new vector can be expressed using the so-constructed
$\lbrace\mathbf{u}_1,\mathbf{u}_2,\ldots,\mathbf{u}_M
\rbrace$ basis set, as in the following:

[^rotation]: At least up to a rotation of the resulting
orthonormal basis.

$$
\mathbf{x} = \sum \alpha_i \mathbf{u}_i
$$

 The idea behind additive trees is to reproduce this procedure for
trees instead of vectors. There are many natural topological and algebraic
properties that we lack for the general problem, however. For example, we already
have well established methods for measuring distances between vectors for
the Gram-Schmidt procedure outlined above (namely, the $L_2$
distance), which we lack here.  Thus, we need the concept of *loss function*,
which is a way of measuring how well the process is working out at each
sequential step. This loss function is parameterized by the training data and
by the classification function under consideration: $ L_{\mathbf{y}}(f(x)) $.
For example, if we want a classifier ($f$) that selects the label $y_i$
based upon the input data $\mathbf{x}_i$ ($f :\mathbf{x}_i \rightarrow y_i)$,
then the squared error loss function would be the following:

$$
L_{\mathbf{y}}(f(x))  = \sum_i (y_i - f(x_i))^2
$$

 We represent the classifier in terms of a set of basis trees:

$$
f(x) = \sum_k \alpha_k u_{\mathbf{x}}(\theta_k)
$$

 The general algorithm for forward stagewise  additive modeling is
the following:

   * Initialize $f(x)=0$

   * For $m=1$ to $m=M$, compute the following:

$$
(\beta_m,\gamma_m) = \argmin_{\beta,\gamma} \sum_i  L(y_i,f_{m-1}(x_i)+\beta b(x_i;\gamma))
$$

* Set $f_m(x) = f_{m-1}(x) +\beta_m b(x;\gamma_m) $

The key point is that the residuals from the prior step are used to fit the
basis function for the subsequent iteration.  That is, the following
equation is being sequentially approximated.

$$
f_m(x) - f_{m-1}(x) =\beta_m b(x_i;\gamma_m)
$$

 Let's see how this works for decision trees and the exponential loss
function.

$$
L(x,f(x)) = \exp(-y f(x))
$$

 Recall that for the classification problem, $y\in \lbrace -1,1 \rbrace$. For
AdaBoost, the basis functions are the individual classifiers, $G_m(x)\mapsto \lbrace -1,1 \rbrace$
The key step in the algorithm is the minimization step for
the objective function

$$
J(\beta,G) = \sum_i \exp(y_i(f_{m-1}(x_i)+\beta G(x_i)))
$$

$$
(\beta_m,G_m) = \argmin_{\beta,G} \sum_i \exp(y_i(f_{m-1}(x_i)+\beta G(x_i)))
$$

 Now, because of the exponential, we can factor out the following:

$$
w_i^{(m)} = \exp(y_i f_{m-1}(x_i))
$$

 as a weight on each data element and re-write the objective function as the following:

$$
J(\beta,G) = \sum_i w_i^{(m)} \exp(y_i \beta G(x_i))
$$

 The important observation here is that $y_i G(x_i)\mapsto 1$ if
the tree classifies $x_i$ correctly and $y_i G(x_i)\mapsto -1$ otherwise.
Thus, the above sum has terms like the following:

$$
J(\beta,G) = \sum_{y_i\ne G(x_i)} w_i^{(m)} \exp(-\beta) + \sum_{y_i= G(x_i)} w_i^{(m)}\exp(\beta)
$$

 For $\beta>0$, this means that  the best $G(x)$ is
the one that incorrectly classifies for the largest weights. Thus,
the minimizer is the following:

$$
G_m = \argmin_G \sum_i w_i^{(m)} I(y_i \ne G(x_i))
$$

 where $I$ is the indicator function (i.e.,
$I(\texttt{True})=1,I(\texttt{False})=0$).

 For $\beta>0$, we can re-write the objective function
as the following:

$$
J= (\exp(\beta)-\exp(-\beta))\sum_i w_i^{(m)} I(y_i \ne G(x_i)) + \exp(-\beta) \sum_i w_i^{(m)}
$$

 and substitute $\theta=\exp(-\beta)$ so that

$$
\begin{equation}
\frac{J}{\sum_i w_i^{(m)}} = \left(\frac{1}{\theta} - \theta\right)\epsilon_m+\theta
\label{eq:bt001} \tag{1}
\end{equation}
$$

 where

$$
\epsilon_m = \frac{\sum_i w_i^{(m)}I(y_i\ne G(x_i))}{\sum_i w_i^{(m)}}
$$

  is the error rate of the classifier with $0\le \epsilon_m\le 1$. Now,
finding $\beta$ is a straight-forward calculus minimization exercise on the
right side of Equation ([1](#eq:bt001)), which gives the following:

$$
\beta_m  = \frac{1}{2}\log \frac{1-\epsilon_m}{\epsilon_m}
$$

 Importantly, $\beta_m$ can become negative if
$\epsilon_m<\frac{1}{2}$, which would would violate our assumptions on $\beta$.
This is captured in the requirement that the base learner be better than just
random guessing, which would correspond to $\epsilon_m>\frac{1}{2}$.
Practically speaking, this means that boosting cannot fix a base learner that
is no better than a random guess. Formally speaking, this is known as the
*empirical weak learning assumption* [[schapire2012boosting]](#schapire2012boosting).

Now we can move to the iterative weight update. Recall that

$$
w_i^{(m+1)} = \exp(y_i f_{m}(x_i)) = w_i^{(m)}\exp(y_i \beta_m G_m(x_i))
$$

 which we can rewrite as the following:

$$
w_i^{(m+1)} = w_i^{(m)}\exp(\beta_m)\exp(-2\beta_m I(G_m(x_i)=y_i))
$$

 This means that the data elements that are incorrectly classified have their
corresponding weights increased by $\exp(\beta_m)$ and those that are correctly
classified have their corresponding weights reduced by $\exp(-\beta_m)$.
The reason for the choice of the exponential loss function comes from the
following:

$$
f^{*}(x) = \argmin_{f(x)} \mathbb{E}_{Y\vert x}(\exp(-Y f(x))) = \frac{1}{2}\log \frac{\mathbb{P}(Y=1\vert x)}{\mathbb{P}(Y=\minus 1\vert x)}
$$

 This means that boosting is approximating a $f(x)$ that is actually
half the log-odds of the conditional class probabilities.  This can be
rearranged as the following

$$
\mathbb{P}(Y=1\vert x) = \frac{1}{1+\exp(\minus 2f^{*}(x))}
$$

 The important benefit of this general formulation for boosting, as a sequence
of additive approximations,  is that it opens the door to other choices of loss
function, especially loss functions that are based on robust statistics that
can account for errors in the training data (c.f. Hastie).

### Gradient Boosting

Given a differentiable loss function, the optimization process can be
formulated using numerical gradients. The fundamental idea is to treat the
$f(x_i)$ as a scalar parameter to be optimized over. Generally speaking,
we can think of the following loss function,

$$
L(f) = \sum_{i=1}^N L(y_i,f(x_i))
$$

 as a vectorized quantity

$$
\mathbf{f} = \lbrace f(x_1),f(x_2),\ldots, f(x_N) \rbrace
$$

 so that the optimization is over this vector

$$
\hat{\mathbf{f}} = \argmin_{\mathbf{f}} L(\mathbf{f})
$$

 With this general formulation we can use numerical optimization methods
to solve for the optimal $\mathbf{f}$ as a sum of component vectors
as in the following:

$$
\mathbf{f}_M = \sum_{m=0}^M \mathbf{h}_m
$$

 Note that this leaves aside the prior assumption that $f$
is parameterized as a sum of individual decision trees.

$$
g_{i,m} = \left[ \frac{\partial L(y_i,f(x_i))}{\partial f(x_i)} \right]_{f(x_i)=f_{m-1}(x_i)}
$$