# 2019 MolSSI Summer School QM project: semiempirical model of Argon

## 1. Introduction

In this project, we will simulate a cluster of Argon atoms using quantum mechanics (QM). First-principles (a.k.a. ab initio) QM simulations are complicated and expensive, and a quick implementation would rely on a substantial amount of pre-existing software infrastructure (e.g. PySCF or Psi4). Instead, we will implement a much simpler semiempirical QM simulation that has been designed and parameterized to reproduce first-principles QM data using a minimal model. We can then limit our external dependencies to the standard numerical functionality of Python:

```python
# import NumPy
```

As is typically the case in quantum chemistry, we will input a set of atomic coordinates $\vec{r}_i$ and compute the ground state energy of Argon atoms with those coordinates. All physical quantities in this project will be in Hartree atomic units, where the bohr is the unit of length and the hartree is the unit of energy.

```python
# write down a set of atomic coordinates
```

More complicated and featureful software would be able to compute other properties besides the ground state energy like atomic forces. It would also have more convenient options for specifying inputs and returning outputs.

Note that this QM project is primarily meant as a programming exercise. It contains a theoretical specification of a model, and we will implement the components of the model in software as they are specified. If you have a strong background in quantum chemistry, then you should find the theory reasonably familiar. However, it is only really necessary for you to understand each mathematical expression one-at-a-time to effectively implement this software.

## 2. Model Hamiltonian

As is standard in quantum chemistry, we will assume that the total energy of our system is defined to be the ground state energy of a quantum many-body Hamiltonian $\hat{H}$. In second quantization notation, we can write it as

$$ \hat{H} = E_{\mathrm{ion}} + \sum_{p,q} \sum_{\sigma \in \{ \uparrow , \downarrow \} } h_{p,q} \hat{a}_{p,\sigma}^{\dagger} \hat{a}_{q,\sigma} + \tfrac{1}{2}\sum_{p,q,r,s} \sum_{\sigma,\sigma' \in \{ \uparrow , \downarrow \} } V_{p,q,r,s} \hat{a}_{p,\sigma}^{\dagger} \hat{a}_{r,\sigma'}^{\dagger} \hat{a}_{s,\sigma'} \hat{a}_{q,\sigma} , $$

where $\hat{a}_{p,\sigma}^{\dagger}$ and $\hat{a}_{p,\sigma}$ are the electron raising and lowering operators for an atomic orbital index $p$ and spin $\sigma$. We will not be using $\hat{H}$ itself in our calculations, but we will make use of the coefficient tensors $h_{p,q}$ and $V_{p,q,r,s}$. In first-principles calculations, each element of $h_{p,q}$ and $V_{p,q,r,s}$ would require the evaluation of a complicated integral. In our semiempirical model, we will set most of them to zero and assign a simple analytical form to the rest of them. The notation being used here is mostly consistent with modern quantum chemistry notation, but some objects, particularly $V_{p,q,r,s}$, have multiple conventions in practice.

### A. Model design & parameters

This semiempirical model combines some standard concepts and methods used in physics and chemistry. First, it will use a minimal number of electronic degrees of freedom. Because Argon is a noble gas, it interacts primarily through London dispersion forces that are mediated by quantum dipole fluctuations. The lowest energy dipole transition is from the occupied $3p$ states to the unoccupied $4s$ state, and we will include these 4 atomic orbitals per atom. Similarly, we will use a multipole expansion to simplify electronic excitations and retain only the monopole and dipole terms, which also restricts electronic polarization to 4 degrees of freedom per atom. We will use $\{s, p_x, p_y, p_z\}$ to label both atomic orbitals and multipole moments on each atom. The nuclear charge of Argon is 18, but our model combines the nucleus and the 12 neglected electrons ($1s^2$, $2s^2$, $2p^6$, and $3s^2$) into an ionic point charge with $Z = 6$.

```python
# specify basic properties of the model
```

The index of an atomic orbital specifies which atom it is located on and what type it is. We will often extract these individual pieces of information using $\mathrm{atom}(p)$ to denote the atom's index and $\mathrm{orb}(p)$ to denote the orbital type. This is the first of many instances in this project where we could either represent something as a pre-tabulate list or a function. We will always make the simpler choice, in this case functions:

```python
# perform basic book-keeping operations
```

We will discuss the model parameters in more detail as they are used, but it is a good idea to first collect them all in a common data structure, a Python dictionary, for convenient access throughout the notebook:

```python
# REMINDER: atomic units w/ energies in hartree, distances in bohr
model_parameters = { 'r_hop' : 3.1810226927827516, # hopping length scale
                     't_ss' : 0.03365982238611262, # s-s hopping energy scale
                     't_sp' : -0.029154833035109226, # s-p hopping energy scale
                     't_pp1' : -0.0804163845390335, # 1st p-p hopping energy scale
                     't_pp2' : -0.01393611496959445, # 2nd p-p hopping energy scale
                     'r_pseudo' : 2.60342991362958, # pseudopotential length scale
                     'v_pseudo' : 0.022972992186364977, # pseudopotential energy scale
                     'dipole' : 2.781629275106456, # dipole strength of s-p transition
                     'energy_s' : 3.1659446174413004, # onsite energy of s orbital
                     'energy_p' : -2.3926873325346554, # onsite energy of p orbital
                     'coulomb_s' : 0.3603533286088998, # Coulomb self-energy of monopole
                     'coulomb_p' : -0.003267991835806299 } # Coulomb self-energy of dipole
```

There are no parameters related to orbital overlap because all atomic orbitals are assumed to be orthogonal. The parameter values have been pre-optimized for this project, but the fitting process and reference data are both listed at the end of the project if you'd like to learn more about them.

### B. Slater-Koster tight-binding model

We will describe the kinetic energy of electrons using a simplified [Slater-Koster tight-binding method](https://en.wikipedia.org/wiki/Tight_binding). Because of the symmetry of atomic orbitals and the translational invariance of the kinetic energy operator, there are 4 distinct, distance-dependent "hopping" energies that characterize the interatomic kinetic energy between s and p orbitals:

All other atomic orientations can be related to these cases by a change of coordinates. While it is compatible with very general functional forms, we will use a Gaussian form to simplify the model and its implementation. The distance-dependence of this simple version is controlled by a single hopping length scale $r_{\mathrm{hop}}$ and the strength of each type of hopping energy,

$$ t_{o,o'}(\vec{r}) = \exp(1-r^2/r_{\mathrm{hop}}^2) \times \begin{cases}
 t_{ss} , & o = o' = s \\
 [\vec{o}' \cdot (\vec{r}/r_{\mathrm{hop}})] t_{sp}, & o = s \ \& \ o' \in \{p_x, p_y, p_z\} \\
 -[\vec{o} \cdot (\vec{r}/r_{\mathrm{hop}})] t_{sp} , & o' = s \ \& \ o \in \{p_x, p_y, p_z\} \\
 (r^2/r_{\mathrm{SK}}^2)\,(\vec{o} \cdot \vec{o}')  t_{pp2} - [\vec{o} \cdot (\vec{r}/r_{\mathrm{SK}})] [\vec{o}' \cdot (\vec{r}/r_{\mathrm{SK}})] (t_{pp1} + t_{pp2}), & o,o' \in \{p_x, p_y, p_z\}
 \end{cases} $$

where $o$ and $o'$ are the orbital types of the 1st and 2nd atoms and $\vec{r}$ is a vector pointing from the 2nd atom to the 1st atom. We are assigning direction vectors to the p orbitals, $\vec{p}_x \equiv (1,0,0)$, $\vec{p}_y \equiv (0,1,0)$, and $\vec{p}_z \equiv (0,0,1)$, to simplify the notation. This project has multiple case-based formulas, and we will implement them using a code structure similar to each formula:

```python
# implement the hopping energy function
```

### C. Coulomb interaction

For the purpose of electrostatics, we will describe all inter-atomic Coulomb interactions with point charges. We need access to both the interaction kernel and its derivatives to define the multipole expansion,

$$ V_{o,o'}(\vec{r}) = \begin{cases}
 1/r , & o = o' = s \\
 (\vec{o}' \cdot \vec{r}) / r^3, & o = s \ \& \ o' \in \{p_x, p_y, p_z\} \\
 -(\vec{o} \cdot \vec{r}) / r^3 , & o' = s \ \& \ o \in \{p_x, p_y, p_z\} \\
 (\vec{o} \cdot \vec{o}') / r^3 - 3 (\vec{o} \cdot \vec{r}) (\vec{o}' \cdot \vec{r}) / r^5, & o,o' \in \{p_x, p_y, p_z\}
 \end{cases} $$

The Coulomb energy has the same case-based structure and angular dependences as the hopping energy:

```python
# implement the Coulomb energy function
```

The semiempirical model approximations strongly distort the physics of inter-atomic Pauli repulsion, and we compensate for these errors with a short-range ionic pseudopotential, which is a common tool in physics for building effective models of ionic cores:

$$ V_o^{\mathrm{pseudo}}(\vec{r}) = V_{\mathrm{pseudo}} \exp(1 - r^2/r_{\mathrm{pseudo}}^2) \times \begin{cases}
1, & o = s \\
-2 \vec{o}\cdot\vec{r}/r_{\mathrm{pseudo}}, & o \in \{p_x, p_y, p_z\} \end{cases} . $$

Note that the vector here points from the atom that the pseudopotential is centered on to the orbital that is interacting with it:

```python
# implement the pseudopotential energy function
```

These interaction kernels enable us to define and calculate the ion-ion energy in $\hat{H}$,

$$ E_{\mathrm{ion}} = Z^2 \sum_{i < j} V_{s,s}(\vec{r}_i - \vec{r}_j) $$

```python
# implement the ionic energy
```

the vector of electron-ion interactions (omitting on-site Coulomb terms),

$$ V^{\mathrm{ion}}_p = \sum_{i \neq \mathrm{atom}(p)}
   V_{\mathrm{orb}(p)}^{\mathrm{pseudo}}(\vec{r}_{\mathrm{atom}(p)} - \vec{r}_i)
   - Z V_{\mathrm{orb}(p),s}(\vec{r}_{\mathrm{atom}(p)} - \vec{r}_i)$$

```python
# implement the potential vector
```

and the matrix of electron-electron interaction matrix elements,

$$ V^{\mathrm{ee}}_{p,q} = \begin{cases}
 V_{\mathrm{orb}(p),\mathrm{orb}(q)}(\vec{r}_{\mathrm{atom}(p)} - \vec{r}_{\mathrm{atom}(q)}) , & \mathrm{atom}(p) \neq \mathrm{atom}(q) \\
 V^{\mathrm{self}}_{\mathrm{orb}(p)} \delta_{p,q} , & \mathrm{atom}(p) = \mathrm{atom}(q)
\end{cases} . $$

On-site Coulomb interactions between electrons are described by electronic self-energy parameters $V_o^{\mathrm{self}}$ for multipole moment $o$.

```python
# implement the interaction matrix
```

### D. Multipole decomposition

To define $V_{p,q,r,s}$ based on the Coulomb matrix elements $V_{p,q}^{\mathrm{ee}}$, we need to define a mapping from products of atomic orbitals to a linear combination of terms in the multipole expansion of electronic charge. Because the atomic orbitals are normalized, their monopole coefficient with themselves is 1 (i.e. they have unit charge). Because of orbital orthogonality, there is no monopole term for either intra-atomic or inter-atomic transitions between atomic orbitals. We will ignore dipole transitions between atoms, which corresponds to the [neglect of diatomic differential overlap](https://en.wikipedia.org/wiki/NDDO) (NDDO) approximation that is commonly used in semiempirical quantum chemistry. All that remains is the intra-atomic s-p transition, and we will use a model parameter, $D$, to define its dipole strength. These transformation rules between atomic orbitals and multipole moments can be summarized pictorially,

or mathematically as a 3-index tensor $\chi_{p,q,r}$ where $p$ and $q$ are the atomic orbital indices and $r$ is the multipole moment index,

$$ \chi_{p, q, r} = \begin{cases} 1, & \mathrm{orb}(p) = \mathrm{orb}(q) \ \& \ \mathrm{orb}(r) = s \ \& \ \mathrm{atom}(p) = \mathrm{atom}(q) = \mathrm{atom}(r) \\
 D , & \mathrm{orb}(q) = \mathrm{orb}(r) \in \{p_x, p_y, p_z\} \ \& \ \mathrm{orb}(p) = s \ \& \ \mathrm{atom}(p) = \mathrm{atom}(q) = \mathrm{atom}(r) \\
 D , & \mathrm{orb}(p) = \mathrm{orb}(r) \in \{p_x, p_y, p_z\} \ \& \ \mathrm{orb}(q) = s \ \& \ \mathrm{atom}(p) = \mathrm{atom}(q) = \mathrm{atom}(r) \\
 0, & \mathrm{otherwise} \end{cases} . $$

```python
# implement the chi tensor, give direct access to its on-atom value
```

The multipole expansion plays the same role as an auxiliary basis set in modern resolution-of-identity (RI) methods that are used to accelerate large quantum chemistry simulations. The primary purpose of these methods is to decompose $V_{p,q,r,s}$ into a low-rank factored form, which is

$$ V_{p,q,r,s} = \sum_{t,u} \chi_{p,q,t} V_{t,u}^{\mathrm{ee}} \chi_{r,s,u} $$

in our semiempirical model. Unlike the previous vectors and matrices that we have constructed, the $\chi$ and $V$ tensors are sparse, meaning that most of their entries are zero. Without a more clever implementation, it can be computationally slow and wasteful to store and compute with these zero values. Modern quantum chemistry methods contain many ways of identifying and utilizing sparsity. For example, we could compute tensor elements of $\chi$ on-the-fly rather than storing the full tensor. This is analogous to "integral direct" methods in quantum chemistry. For simplicity, we will still precompute and store $\chi$, which fails to utilize its sparsity. However, we will avoid the explicit construction of $V_{p,q,r,s}$ by utilizing its factored form.

### E. 1-body Hamiltonian

The 1-body Hamiltonian coefficients $h_{p,q}$ combine many of the components that we have already discussed and implemented along with the on-site orbital energies, $E_s$ and $E_p$, which are the final two parameters from the semiempirical model,

$$ h_{p,q} = \begin{cases}
 t_{\mathrm{orb}(p),\mathrm{orb}(q)}(\vec{r}_{\mathrm{atom}(p)} - \vec{r}_{\mathrm{atom}(q)})
  , & \mathrm{atom}(p) \neq \mathrm{atom}(q) \\
 E_{\mathrm{orb}(p)} \delta_{\mathrm{orb}(p),\mathrm{orb}(q)} + \sum_{r} \chi_{p,q,r} V_{r}^{\mathrm{ion}} , & \mathrm{atom}(p) = \mathrm{atom}(q)
  \end{cases}. $$

```python
# assemble the Hamiltonian matrix
```

We have now fully specified the many-body Hamiltonian $\hat{H}$, and we can now move on to approximating and calculating some of its physical properties.

## 3. Hartree-Fock theory

Even with a simple model for $\hat{H}$, we cannot calculate its ground state energy exactly for more than a few Argon atoms. Instead, we will use the [Hartree-Fock approximation](https://en.wikipedia.org/wiki/Hartree–Fock_method), which restricts the ground-state wavefunction to a single Slater determinant. We will find the Slater determinant with the lowest total energy, but a more general wavefunction will usually have an even lower energy. In the next section, we will use many-body perturbation theory to improve our estimate of the total energy.

The central objects of Hartree-Fock theory are the 1-electron density matrix $\rho_{p,q}$ and the Fock matrix $f_{p,q}$. These two matrices depend on each other, which defines a nonlinear set of equations that we must solve iteratively. Iteratively solving these equations is usually referred to as the self-consistent field (SCF) cycle. For this to converge, we must start from a reasonable initial guess for $\rho_{p,q}$. We will initialize it to the density matrix for isolated Argon atoms,

$$ \rho_{p,q}^{\mathrm{atom}} = \begin{cases}
   1, & p = q \ \& \ \mathrm{orb}(p) \in \{ p_x, p_y, p_z \} \\
   0, & \mathrm{otherwise}
\end{cases} $$

```python
# construct an initial atomic guess for the SCF cycle
```

Because of spin symmetry, we use the same $\rho_{p,q}$ for each electron spin type, which reduces the sum over spin types in $\hat{H}$ to degeneracy pre-factors. Thus, half of the electrons are spin-up and half are spin-down.

The Fock matrix is defined by the density matrix,

$$ \begin{align} f_{p,q} & = h_{p,q} + \sum_{r,s} ( 2 V_{p,q,r,s} - V_{r,q,p,s} )\rho_{r,s} \\
                         & = h_{p,q} + \sum_{r,s,t,u} ( 2 \chi_{p,q,t} \chi_{r,s,u} - \chi_{r,q,t} \chi_{p,s,u} )
                             V_{t,u}^{\mathrm{ee}} \rho_{r,s} ,
   \end{align} $$

which is just a sum of two tensor contractions between the $V$ tensor and the density matrix. Because the $V$ tensor has low rank, we can save a lot of computational effort by using its factored form to construct the Fock matrix:

```python
# construct the Fock matrix using einsum
```

This is our first example of using "einsum" in NumPy to trivialize the implementation of a tensor equation using the [Einstein summation convention](https://en.wikipedia.org/wiki/Einstein_notation) and some extra conventions for the order of indices (alphabetical order).

The Fock matrix defines the matrix of molecular orbitals $\phi_{i,j}$ as its eigenvectors,

$$ \sum_{q} f_{p,q} \phi_{q,r} = E_r \phi_{p,r}, $$

and the density matrix is defined by the occupied orbitals (we occupy the lowest-energy orbitals with all of the electrons available),

$$ \rho_{p,q} = \sum_{i \in \mathrm{occ}} \phi_{p,i} \phi_{q,i}. $$

```python
# construct the density matrix using NumPy linear algebra
```

To calculate a $\rho_{p,q}$ and $f_{p,q}$ that are consistent with each other, we mix in a fraction of the new density matrix with the old density matrix and re-calculate them until convergence. A very simple pseudocode for one loop of this cycle is

$$ \begin{align} f_{\mathrm{new}} &= f(\rho_{\mathrm{old}}) \\
                 \rho_{\mathrm{new}} &= w_{\mathrm{mix}} \rho(f_{\mathrm{new}})
                 + (1 - w_{\mathrm{mix}}) \rho_{\mathrm{old}} \end{align} $$

This is a crude strategy for an SCF cycle, but it works for weakly interacting atoms like Argon:

```python
# implement a self-contained SCF cycle
```

Once we have a converged density matrix, the Hartree-Fock total energy is defined as

$$ \begin{align}
  E_{\mathrm{HF}} &= E_{\mathrm{ion}} + E_{\mathrm{SCF}} \\
  E_{\mathrm{SCF}} &= 2 \sum_{p,q} h_{p,q} \rho_{p,q}
  + \sum_{p,q,r,s} V_{p,q,r,s} ( 2 \rho_{p,q} \rho_{r,s} - \rho_{p,s} \rho_{r,q} ) = \sum_{p,q} (h_{p,q} + f_{p,q}) \rho_{p,q}
  \end{align} $$

```python
# calculate the electronic contribution to the total energy
```

The bottleneck of this Hartree-Fock implementation is the tensor contraction in the formation of the Fock matrix. Because we are not exploiting the sparsity of $\chi$, it scales as $O(n^4)$ operations for $n$ atoms because of 3 free tensor indices and 1 summed tensor index in the contractions with the dense $\chi$ tensor.

## 4. 2nd-order Moller-Plesset (MP2) perturbation theory

The Hartree-Fock approximation does not describe the London dispersion interaction between Argon atoms. We must use many-body perturbation theory to improve our approximation to the ground state of $\hat{H}$ and include this physical effect. Thankfully, we only need to use the first correction beyond Hartree-Fock theory, which is [second-order Moller-Plesset (MP2) perturbation theory](https://en.wikipedia.org/wiki/Møller–Plesset_perturbation_theory). It is simple, but computationally expensive. The computational bottleneck is the transformation of the tensor $V_{p,q,r,s}$ from the atomic orbital basis to the molecular orbital basis,

$$ \tilde{V}_{a,i,b,j} = \sum_{p,q,r,s} \phi_{p,a} \phi_{q,i} \phi_{r,b} \phi_{s,j} V_{p,q,r,s}. $$

where we are restricting some indices to occupied orbitals ($i$ and $j$) and some indices to the unoccupied "virtual" orbitals ($a$ and $b$). This use of specific orbital labels to denote a restriction of indices is standard notation in quantum chemistry. To make this easier to program, we can partition the molecular orbitals and their energies into occupied and virtual:

```python
# partition the orbitals into occupied and virtual
```

We use the factored form of $V_{p,q,r,s}$ to reduce our intermediate memory usage, but we do need to explicitly store $\tilde{V}_{a,i,b,j}$,

$$ \begin{align}
   \tilde{\chi}_{a,i,p} &= \sum_{q,r} \phi_{q,a} \phi_{r,i} \chi_{q,r,p} \\
   \tilde{V}_{a,i,b,j} &= \sum_{p,q} \tilde{\chi}_{a,i,p} V_{p,q}^{\mathrm{ee}} \tilde{\chi}_{b,j,q}
   \end{align} $$

```python
# transform the interaction tensor from atomic orbitals to molecular orbitals
```

In this simple implementation, we are not taking full advantage of the sparisty of the initial $\chi$ tensor. Unlike the $V$ tensor, the $\tilde{V}$ tensor is constructed explicitly and stored in memory. The MP2 correction to the total energy is then just a large summation over previously computed and stored quantities,

$$ E_{\mathrm{MP2}} = - \sum_{a,b \in \mathrm{virt}} \sum_{i,j \in \mathrm{occ}} \frac{2 \tilde{V}_{a,i,b,j}^2 - \tilde{V}_{a,i,b,j} \tilde{V}_{a,j,b,i}}{E_a + E_b - E_i - E_j} . $$

```python
# calculate the MP2 correction to the total energy
```

The bottleneck of MP2 calculations is the formation of $\tilde{V}$ from $\tilde{\chi}$ and $V^{\mathrm{ee}}$, which scales as $O(n^5)$ operations for $n$ atoms because of 4 free tensor indices and 1 summed tensor index once the contraction has been optimally arranged into intermediate operations. The 4 nested for loops that accumulate the MP2 energy correction have a lower $O(n^4)$ scaling of cost but with a very large prefactor that will usually cause it to dominate the overall cost.

## (Extra Credit) 5. Alternative Fock matrix construction

The previous implementation of Fock matrix construction,

$$ f_{p,q} = h_{p,q} + \sum_{r,s,t,u} ( 2 \chi_{p,q,t} \chi_{r,s,u} - \chi_{r,q,t} \chi_{p,s,u} )
                             V_{t,u}^{\mathrm{ee}} \rho_{r,s}, $$

did not utilize the sparsity of the $\chi$ tensor. As a result, its computational cost scaling is not as low as it could be, and it will have poor performance for large system sizes. We can improve the scaling by making use of the fact that $\chi_{p,q,r}$ is only non-zero when all of its indices are on the same atom:

```python
def calculate_fock_matrix_fast(hamiltonian_matrix, interaction_matrix, density_matrix, model_parameters):
    '''Returns the Fock matrix defined by the input Hamiltonian, interaction, & density matrices.'''
    ndof = np.size(hamiltonian_matrix,0)
    fock_matrix = hamiltonian_matrix.copy()
    # Hartree potential term
    for p in range(ndof):
        for orb_q in orbital_types:
            q = ao_index(atom(p), orb_q) # p & q on same atom
            for orb_t in orbital_types:
                t = ao_index(atom(p), orb_t) # p & t on same atom
                chi_pqt = chi_on_atom(orb(p), orb_q, orb_t, model_parameters)
                for r in range(ndof):
                    for orb_s in orbital_types:
                        s = ao_index(atom(r), orb_s) # r & s on same atom
                        for orb_u in orbital_types:
                            u = ao_index(atom(r), orb_u) # r & u on same atom
                            chi_rsu = chi_on_atom(orb(r), orb_s, orb_u, model_parameters)
                            fock_matrix[p,q] += 2.0 * chi_pqt * chi_rsu * interaction_matrix[t,u] * density_matrix[r,s]
    # Fock exchange term
    for p in range(ndof):
        for orb_s in orbital_types:
            s = ao_index(atom(p), orb_s) # p & s on same atom
            for orb_u in orbital_types:
                u = ao_index(atom(p), orb_u) # p & u on same atom
                chi_psu = chi_on_atom(orb(p), orb_s, orb_u, model_parameters)
                for q in range(ndof):
                    for orb_r in orbital_types:
                        r = ao_index(atom(q), orb_r) # q & r on same atom
                        for orb_t in orbital_types:
                            t = ao_index(atom(q), orb_t) # q & t on same atom
                            chi_rqt = chi_on_atom(orb_r, orb(q), orb_t, model_parameters)
                            fock_matrix[p,q] -= chi_rqt * chi_psu * interaction_matrix[t,u] * density_matrix[r,s]
    return fock_matrix

fock_matrix1 = calculate_fock_matrix(hamiltonian_matrix, interaction_matrix, density_matrix, chi_tensor)
fock_matrix2 = calculate_fock_matrix_fast(hamiltonian_matrix, interaction_matrix, density_matrix, model_parameters)
print('difference between fock matrix implementations =', np.linalg.norm(fock_matrix1 - fock_matrix2))
```

This algorithm is much more complicated to implement and it is doing something that should be avoided at all costs in Python: deeply nested for loops. While this algorithm has good scaling, we will soon observe a very large cost prefactor in its performance. We can compare the performance of these algorithms by setting up a sequence of Argon clusters of increasing size:

```python
from timeit import default_timer as timer

def build_fcc_cluster(radius, lattice_constant):
    vec1 = np.array([lattice_constant, lattice_constant, 0.0])
    vec2 = np.array([lattice_constant, 0.0, lattice_constant])
    vec3 = np.array([0.0, lattice_constant, lattice_constant])
    max_index = int(radius/np.linalg.norm(vec1))
    atomic_coordinates = np.array([ i*vec1 + j*vec2 + k*vec3 for i in range(-max_index, max_index+1)
                                                             for j in range(-max_index, max_index+1)
                                                             for k in range(-max_index, max_index+1)
                                                             if np.linalg.norm(i*vec1 + j*vec2 + k*vec3) <= radius ])
    return atomic_coordinates

for radius in np.arange(10.0,40.0,10.0):
    coord = build_fcc_cluster(radius, 9.9)

    time1 = timer()
    density_matrix = calculate_atomic_density_matrix(coord)
    hamiltonian_matrix = calculate_hamiltonian_matrix(coord, model_parameters)
    interaction_matrix = calculate_interaction_matrix(coord, model_parameters)
    chi_tensor = calculate_chi_tensor(coord, model_parameters)
    time2 = timer()
    print(len(coord))
    print('base matrix construction time =',time2-time1)
    fock_matrix = calculate_fock_matrix(hamiltonian_matrix, interaction_matrix, density_matrix, chi_tensor)
    time3 = timer()
    print('Fock matrix construction time ("slow") =',time3-time2)
    fock_matrix = calculate_fock_matrix_fast(hamiltonian_matrix, interaction_matrix, density_matrix, model_parameters)
    time4 = timer()
    print('Fock matrix construction time ("fast") =',time4-time3)
```

We could improve the implementation of Fock matrix construction in Python by organizing much of the work as dense matrix-matrix multiplications on small blocks of the matrix, which involves more sophisticated bookkeeping. Another way to improve its implementation is simply to port it as-is to C or C++, which have very low performance overheads in nested for loops. Thus an implementation that superficially looks very similar can have dramatically improved performance.

## (Extra Credit) 6. Semiempirical model parameterization

For a semiempirical model to be useful, it must be parameterized to fit a set of reference data, usually from experiments or high-accuracy quantum chemistry simulation data. The parameters in this project have already been fit to data, which was carried out using the fitting procedure implemented in this section. Data fitting is an optimization problem, and we can make use of pre-existing Python optimization tools by encapsulating the fitting process into a function that inputs a list of parameters and outputs a fitting error that we want to minimize. We begin with our reference data, which is the set of occupied orbital energies, Hartree-Fock binding energies, and MP2 binding energies from all-electron calculations of Argon dimers at various separation distances:

```python
# distance, HF binding energy, MP2 binding energy, 6 occupied HF orbital energies
reference_dimer = [ [ 5.67, 0.0064861, -0.0037137, -0.620241, -0.595697, -0.595697, -0.584812, -0.584812, -0.561277 ],
                    [ 6.24, 0.0022351, -0.0020972, -0.608293, -0.593456, -0.593456, -0.587742, -0.587742, -0.573604 ],
                    [ 6.62, 0.0010879, -0.0014491, -0.603106, -0.592621, -0.592621, -0.588897, -0.588897, -0.578874 ],
                    [ 6.99, 0.0005255, -0.0010120, -0.599442, -0.592082, -0.592082, -0.589651, -0.589651, -0.582576 ],
                    [ 7.18, 0.0003642, -0.0008494, -0.598041, -0.591886, -0.591886, -0.589922, -0.589922, -0.583988 ],
                    [ 7.56, 0.0001737, -0.0006037, -0.595889, -0.591599, -0.591599, -0.590315, -0.590315, -0.586154 ],
                    [ 7.94, 0.0000818, -0.0004342, -0.594388, -0.591408, -0.591408, -0.590569, -0.590569, -0.587662 ],
                    [ 8.32, 0.0000377, -0.0003159, -0.593344, -0.591281, -0.591281, -0.590731, -0.590731, -0.588708 ],
                    [ 9.26, 0.0000047, -0.0001502, -0.591934, -0.591118, -0.591118, -0.590927, -0.590927, -0.590120 ],
                    [ 10.21, 0.000000, -0.0000757, -0.591378, -0.591059, -0.591059, -0.590993, -0.590993, -0.590675 ] ]
```

We then write a function that encapsulates all of the setup and solving from earlier in the notebook to generate model predictions of the reference data:

```python
def calculate_reference_data(atomic_coordinates, model_parameters):
    '''Returns the occupied orbital, HF, & MP2 energies for the input list of atomic coordinates'''
    ndof = len(atomic_coordinates)*orbitals_per_atom

    energy_ion = calculate_energy_ion(atomic_coordinates)
    density_matrix = calculate_atomic_density_matrix(atomic_coordinates)

    hamiltonian_matrix = calculate_hamiltonian_matrix(atomic_coordinates, model_parameters)
    interaction_matrix = calculate_interaction_matrix(atomic_coordinates, model_parameters)
    chi_tensor = calculate_chi_tensor(atomic_coordinates, model_parameters)

    density_matrix, fock_matrix = scf_cycle(hamiltonian_matrix, interaction_matrix, density_matrix, chi_tensor)

    occupied_energy, virtual_energy, occupied_matrix, virtual_matrix = partition_orbitals(fock_matrix)
    energy_scf = calculate_energy_scf(hamiltonian_matrix, fock_matrix, density_matrix)
    energy_hf = energy_ion + energy_scf
    energy_mp2 = calculate_energy_mp2(fock_matrix, interaction_matrix, chi_tensor)

    return occupied_energy, energy_hf, energy_mp2

print(calculate_reference_data(np.array([[0.0,0.0,0.0]]),model_parameters))
print(calculate_reference_data(np.array([[0.0,0.0,0.0],[6.99,0.0,0.0]]),model_parameters))
print(calculate_reference_data(np.array([[0.0,0.0,0.0],[20.0,0.0,0.0]]),model_parameters))
```

We use this function inside yet another function that loops over the reference data, performs simulations on the reference geometries, and returns the root-mean-squared (RMS) deviation between the reference data and model predictions:

```python
def model_error(reference_dimer, model_parameters):
    '''Returns the RMS error between the model & reference data for the input set of model parameters'''
    atom_occupied, atom_hf, atom_mp2 = calculate_reference_data(np.array([[0,0,0]]), model_parameters)
    rms_error = 0.0

    for data_list in reference_dimer:
        mol_coord = np.array([ [0,0,0], [data_list[0],0,0] ])
        mol_occupied, mol_hf, mol_mp2 = calculate_reference_data(mol_coord, model_parameters)
        mol_hf -= 2.0*atom_hf
        mol_mp2 -= 2.0*atom_mp2
        rms_error += (mol_hf - data_list[1])**2 + (mol_mp2 - data_list[2])**2
        rms_error += np.linalg.norm(mol_occupied - data_list[3:])**2

#    print(np.sqrt(rms_error)/len(reference_dimer))
    return np.sqrt(rms_error)/len(reference_dimer)

print("rms error =", model_error(reference_dimer,model_parameters))
```

Finally, we can use the SciPy optimizer to calculate the optimal model parameters:

```python
import scipy.optimize

def objective_wrapper(param_list, reference_dimer, model_parameters, lock):
    '''Wraps model_error so that the model parameters are in a list'''
    new_parameters = {}
    i = 0
    for key in sorted(model_parameters.keys()):
        if lock != None and key in lock:
            new_parameters.update( { key : model_parameters[key] } )
        else:
            new_parameters.update( { key : param_list[i] } )
            i += 1
    return model_error(reference_dimer, new_parameters)

def fit_model(reference_dimer, model_parameters, lock=None):
    '''Returns optimized model parameters that best fit the reference data'''

    param_list = []
    for key in sorted(model_parameters.keys()):
        if lock != None and key in lock:
            pass
        else:
            param_list.append( model_parameters[key] )
    min_opt = { 'maxiter' : 2000 }
    result = scipy.optimize.minimize(objective_wrapper, param_list,
                                     (reference_dimer, model_parameters, lock),
                                     method='Nelder-Mead', options=min_opt)

    opt_parameters = {}
    i = 0
    for key in sorted(model_parameters.keys()):
        if lock != None and key in lock:
            opt_parameters.update( { key : model_parameters[key] } )
        else:
            opt_parameters.update( { key : result.x[i] } )
            i += 1
    return opt_parameters

new_parameters = fit_model(reference_dimer, model_parameters)
print("new rms error =", model_error(reference_dimer,new_parameters))
print(new_parameters)
```

Is this a good semiempirical model? Not really. It was designed to be simple in form and capture the correct qualitative behavior, but simplicity and accuracy are usually conflicting design principles. The model is not flexible enough to fit the data to very high accuracy, but its parameters also cannot be uniquely specified by the reference data because we aren't probing the system enough. We can identify this problem by examining the eigenvalues of the Hessian matrix for the objective function that we are minimizing to fit the model:

```python
def model_hessian(reference_dimer, model_parameters, lock=None):
    param_list = []
    for key in sorted(model_parameters.keys()):
        if lock != None and key in lock:
            pass
        else:
            param_list.append( model_parameters[key] )
    num_param = len(param_list)
    hessian_matrix = np.zeros((num_param,num_param))
    dx = 0.01
    for i in range(num_param):
        for j in range(num_param):
            param_list[i] += dx
            param_list[j] += dx
            hes_pp = objective_wrapper(param_list, reference_dimer, model_parameters, lock)
            param_list[i] -= dx
            param_list[j] -= dx

            param_list[i] -= dx
            param_list[j] -= dx
            hes_mm = objective_wrapper(param_list, reference_dimer, model_parameters, lock)
            param_list[i] += dx
            param_list[j] += dx

            param_list[i] += dx
            param_list[j] -= dx
            hes_pm = objective_wrapper(param_list, reference_dimer, model_parameters, lock)
            param_list[i] -= dx
            param_list[j] += dx

            param_list[i] -= dx
            param_list[j] += dx
            hes_mp = objective_wrapper(param_list, reference_dimer, model_parameters, lock)
            param_list[i] += dx
            param_list[j] -= dx

            hessian_matrix[i,j] = (hes_pp - hes_mp - hes_pm + hes_mm)/(4*dx**2)
    return hessian_matrix

hessian_matrix = model_hessian(reference_dimer, new_parameters)
vals, vecs = np.linalg.eigh(hessian_matrix)
print('model Hessian eigenvalues:\n',vals)
```

Small eigenvalues correspond to directions in parameter space where we can change parameters without changing how well the model fits the data. This problem is not specific to our simple QM project - semiempirical model building has persistent problems with model indeterminacy as they add more parameters and details to improve accuracy. Because these models have historically be used to reproduce a limited set of observables (e.g. heat of formation), they've been limited in what reference data they can use for model fitting.

We can use this same model and fitting process for other noble gases such as Neon by applying it to a different set of reference data:

```python
neon_ref_data = [
[ 5.29301 , 0.000356767 , -0.000275745 , -0.857733 , -0.851311 , -0.851311 , -0.849117 , -0.849117 , -0.843045 ],
[ 5.48204 , 0.000222991 , -0.000229153 , -0.856225 , -0.851119 , -0.851119 , -0.849443 , -0.849443 , -0.844581 ],
[ 5.67108 , 0.000138635 , -0.000190295 , -0.855025 , -0.850968 , -0.850968 , -0.849687 , -0.849687 , -0.845800 ],
[ 5.86011 , 8.54899e-05 , -0.000157984 , -0.854072 , -0.850849 , -0.850849 , -0.849869 , -0.849869 , -0.846765 ],
[ 6.04915 , 5.21333e-05 , -0.000131269 , -0.853315 , -0.850757 , -0.850757 , -0.850006 , -0.850006 , -0.847529 ],
[ 6.23819 , 3.13588e-05 , -0.000109318 , -0.852714 , -0.850684 , -0.850684 , -0.850108 , -0.850108 , -0.848135 ],
[ 6.42722 , 1.85686e-05 , -9.13551e-05 , -0.852237 , -0.850626 , -0.850626 , -0.850185 , -0.850185 , -0.848614 ],
[ 6.61626 , 1.07945e-05 , -7.66555e-05 , -0.851859 , -0.850582 , -0.850582 , -0.850244 , -0.850244 , -0.848993 ],
[ 6.80529 , 6.11193e-06 , -6.45702e-05 , -0.851559 , -0.850547 , -0.850547 , -0.850287 , -0.850287 , -0.849293 ],
[ 6.99433 , 3.28677e-06 , -5.45556e-05 , -0.851322 , -0.850520 , -0.850520 , -0.850321 , -0.850321 , -0.849531 ],
[ 7.18336 , 1.55256e-06 , -4.61875e-05 , -0.851134 , -0.850499 , -0.850499 , -0.850346 , -0.850346 , -0.849719 ],
[ 7.3724 , 4.57149e-07 , -3.91539e-05 , -0.850986 , -0.850483 , -0.850483 , -0.850366 , -0.850366 , -0.849868 ],
[ 7.56144 , -2.50044e-07 , -3.3231e-05 , -0.850869 , -0.850471 , -0.850471 , -0.850380 , -0.850380 , -0.849986 ],
[ 7.75047 , -7.01547e-07 , -2.82527e-05 , -0.850776 , -0.850461 , -0.850461 , -0.850392 , -0.850392 , -0.850079 ],
[ 7.93951 , -9.693e-07 , -2.40853e-05 , -0.850703 , -0.850454 , -0.850454 , -0.850400 , -0.850400 , -0.850153 ],
[ 8.12854 , -1.09914e-06 , -2.06105e-05 , -0.850645 , -0.850448 , -0.850448 , -0.850407 , -0.850407 , -0.850211 ],
[ 8.31758 , -1.12749e-06 , -1.7719e-05 , -0.850600 , -0.850444 , -0.850444 , -0.850412 , -0.850412 , -0.850257 ],
[ 8.50662 , -1.08659e-06 , -1.53105e-05 , -0.850564 , -0.850440 , -0.850440 , -0.850416 , -0.850416 , -0.850294 ],
[ 8.69565 , -1.00439e-06 , -1.32963e-05 , -0.850536 , -0.850438 , -0.850438 , -0.850419 , -0.850419 , -0.850322 ],
[ 8.88469 , -9.0295e-07 , -1.16011e-05 , -0.850513 , -0.850436 , -0.850436 , -0.850421 , -0.850421 , -0.850345 ],
[ 9.07372 , -7.97673e-07 , -1.01637e-05 , -0.850495 , -0.850434 , -0.850434 , -0.850423 , -0.850423 , -0.850363 ],
[ 9.26276 , -6.977e-07 , -8.93627e-06 , -0.850482 , -0.850433 , -0.850433 , -0.850425 , -0.850425 , -0.850377 ],
[ 9.4518 , -6.07201e-07 , -7.88179e-06 , -0.850470 , -0.850432 , -0.850432 , -0.850426 , -0.850426 , -0.850389 ],
[ 9.64083 , -5.27071e-07 , -6.97177e-06 , -0.850462 , -0.850432 , -0.850432 , -0.850427 , -0.850427 , -0.850398 ],
[ 9.82987 , -4.56455e-07 , -6.18389e-06 , -0.850455 , -0.850431 , -0.850431 , -0.850428 , -0.850428 , -0.850405 ],
[ 10.0189 , -3.93899e-07 , -5.50014e-06 , -0.850450 , -0.850431 , -0.850431 , -0.850428 , -0.850428 , -0.850410 ],
[ 10.2079 , -3.38041e-07 , -4.90551e-06 , -0.850445 , -0.850431 , -0.850431 , -0.850429 , -0.850429 , -0.850415 ],
[ 10.397 , -2.87888e-07 , -4.38722e-06 , -0.850442 , -0.850431 , -0.850431 , -0.850429 , -0.850429 , -0.850418 ],
[ 10.586 , -2.42876e-07 , -3.93422e-06 , -0.850439 , -0.850431 , -0.850431 , -0.850429 , -0.850429 , -0.850421 ],
[ 10.775 , -2.02724e-07 , -3.53699e-06 , -0.850437 , -0.850430 , -0.850430 , -0.850429 , -0.850429 , -0.850423 ]
]

neon_param = fit_model(neon_ref_data, model_parameters)
print("neon rms error =", model_error(neon_ref_data,neon_param))
print(neon_param)

#completed output of Neon parameter fitting saved for posterity:
neon_param = {'coulomb_p': -0.010255409806855187,
              'coulomb_s': 0.4536486561938202,
              'dipole': 1.6692376991516769,
              'energy_p': -3.1186533988406335,
              'energy_s': 11.334912902362603,
              'r_hop': 2.739689713337267,
              'r_pseudo': 1.1800779720963734,
              't_pp1': -0.029546671673199854,
              't_pp2': -0.0041958662271044875,
              't_sp': 0.000450562836426027,
              't_ss': 0.0289251941290921,
              'v_pseudo': -0.015945813280635074}
```

```python

```


```python
#remove cell visibility
from IPython.display import HTML
tag = HTML('''
Toggle cell visibility <a href="javascript:code_toggle()">here</a>.''')
display(tag)
```

Toggle cell visibility here.

## Krmiljenje tempomata vozila

Dinamiko hitrosti vozila, lahko modeliramo (gl. poglavje *Modeliranje dinamike hitrosti vozila* v spremljajočem učbeniku tečaja ICCT za celotno izpeljavo) na naslednji način:

$$
\begin{cases}
\dot{x}=\underbrace{\begin{bmatrix}-\frac{b}{m}&\frac{\tau_{\text{max}}\eta}{mr}\\0&-\frac{1}{2}\end{bmatrix}}_{A}x+\underbrace{\begin{bmatrix}0\\\frac{1}{2}\end{bmatrix}}_{B}t_r \\
y = \underbrace{\begin{bmatrix}3.6&0\end{bmatrix}}_{C}x
\end{cases}
$$

kjer so $y$ hitrost vozila (v km/h), $m$ masa vozila ($1000$ kg), $\tau_{\text{max}}$ maksimalni navor motorja (150 Nm), $r$ radij koles (25 cm) (s konstantnim prestavnim razmerjem $\eta$: 4:1 motor:kolo), $b$ koeficient zraćega upora ($60$ Ns/m), in $\tau_{\%}$ predstavlja lego stopalke za plin (vrednost mora biti med 0 in 1). Sistem je vodljiv in spoznaven.

Cilj je načrtovati krmilni sistem, ki krmili hitrost vozila na način, da deluje prek stopalke za plin na motor, ter izpolnjuje naslednje zahteve:
- brez odstopka v stacionarnem stanju v odzivu na zahtevano spremembo hitrosti v obliki koračne funkcije,
- brez prenihaja, ali pa vsaj s prenihajem manjšim od 1%,
- čas ustalitve krajši od 4 s (dosežena vrednost izhoda naj se razlikuje od tiste v stacionarnem stanju za 5%).

### Načrtovanje regulatorja
Pomembno je poudariti, da ni možno zagotoviti zahteve glede časa ustalitve za vse možne referenčne vrednosti hitrosti. To lahko razložimo s pomočjo fizikalnega ozadja problema: zračni upor je precej večji pri višjih hitrostih, zato vozilo pri teh hitrostih ne more pospeševati tako, kot pospešuje pri nižjih hitrostih. Dodatno lahko pokažemo, da če je zračni upor zanemarljiv  ($b=0$), je maksimalna sprememba hitrosti pri $\tau_{\%} = 1$ enaka 43.2 km/h v 5 s.
Iz napisanega sledi, da bomo krmilni sistem zasnovali tako, da bo izpolnil prvi dve zahtevi in bo njegovo delovanje čim hitrejše.

#### Načrtovanje krmilnika povratne zveze stanj

Za doseg ničelnega odstopka v stacionarnem stanju v odzivu na koračno funkcijo, razširimo sistem z dodatno spremenljivko stanja $x_3 = v-v_{\text{ref}}$, kjer je $v_{\text{ref}}$ referenčni vhod v km/h. Razširjen sistem lahko zapišemo z naslednjo enačbo:

$$
\begin{cases}
\dot{x_a}=\underbrace{\begin{bmatrix}-\frac{b}{m}&\frac{\tau_{\text{max}}\eta}{mr}&0\\0&-\frac{1}{2}&0\\3.6&0&0\end{bmatrix}}_{A_a}x_a+\underbrace{\begin{bmatrix}0\\\frac{1}{2}\\0\end{bmatrix}}_{B_a}t_r+\underbrace{\begin{bmatrix}0\\0\\-1\end{bmatrix}}_{B_{\text{ref}}}v_{\text{ref}} \\
y_a = \underbrace{\begin{bmatrix}3.6&0&0\\0&0&1\end{bmatrix}}_{C_a}x_a
\end{cases}
$$

Opazimo lahko, da je sistem še vedno vodljiv.

Možna rešitev je, da nastavimo lastne vrednosti matrike na vrednosti $-0.06$, $-2.43-2.41i$ in $-2.43+2.41i$ z $K_a=\begin{bmatrix}9.75&8.73&0.15\end{bmatrix}$.

#### Načrtovanje spoznavalnika

Spoznavalnik razvijemo na podlagi začetnega sistema, z namenom, da ocenimo $x_1$ in $x_2$. Pola dinamika napake ($A-LC$) ležita v $-10$ rad/s z $L=\begin{bmatrix}5.4&10.45\end{bmatrix}^T$.

Sistem s krmilnikom je simuliran spodaj. Možno ga je simulirati tudi z nasičenjem vhoda.

```python
%matplotlib inline
import control as control
import numpy
import sympy as sym
from IPython.display import display, Markdown
import ipywidgets as widgets
import matplotlib.pyplot as plt

#print a matrix latex-like
def bmatrix(a):
     """Returns a LaTeX bmatrix - by Damir Arbula (ICCT project)

     :a: numpy array
     :returns: LaTeX bmatrix as a string
     """
     if len(a.shape) > 2:
         raise ValueError('bmatrix can at most display two dimensions')
     lines = str(a).replace('[', '').replace(']', '').splitlines()
     rv = [r'\begin{bmatrix}']
     rv += ['  ' + ' & '.join(l.split()) + r'\\' for l in lines]
     rv +=  [r'\end{bmatrix}']
     return '\n'.join(rv)

# Display formatted matrix:
def vmatrix(a):
    if len(a.shape) > 2:
         raise ValueError('bmatrix can at most display two dimensions')
    lines = str(a).replace('[', '').replace(']', '').splitlines()
    rv = [r'\begin{vmatrix}']
    rv += ['  ' + ' & '.join(l.split()) + r'\\' for l in lines]
    rv +=  [r'\end{vmatrix}']
    return '\n'.join(rv)

#matrixWidget is a matrix looking widget built with a VBox of HBox(es) that returns a numPy array as value !
class matrixWidget(widgets.VBox):
    def updateM(self,change):
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.M_[irow,icol] = self.children[irow].children[icol].value
                #print(self.M_[irow,icol])
        self.value = self.M_

    def dummychangecallback(self,change):
        pass

    def __init__(self,n,m):
        self.n = n
        self.m = m
        self.M_ = numpy.matrix(numpy.zeros((self.n,self.m)))
        self.value = self.M_
        widgets.VBox.__init__(self,
                             children = [
                                 widgets.HBox(children =
                                              [widgets.FloatText(value=0.0, layout=widgets.Layout(width='90px')) for i in range(m)]
                                             )
                                 for j in range(n)
                             ])

        #fill in widgets and tell interact to call updateM each time a children changes value
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].value = self.M_[irow,icol]
                self.children[irow].children[icol].observe(self.updateM, names='value')
        #value = Unicode('example@example.com', help="The email value.").tag(sync=True)
        self.observe(self.updateM, names='value', type= 'All')

    def setM(self, newM):
        #disable callbacks, change values, and reenable
        self.unobserve(self.updateM, names='value', type= 'All')
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].unobserve(self.updateM, names='value')
        self.M_ = newM
        self.value = self.M_
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].value = self.M_[irow,icol]
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].observe(self.updateM, names='value')
        self.observe(self.updateM, names='value', type= 'All')

                #self.children[irow].children[icol].observe(self.updateM, names='value')

#overlaod class for state space systems that DO NOT remove "useless" states (what "professor" of automatic control would do this?)
class sss(control.StateSpace):
    def __init__(self,*args):
        #call base class init constructor
        control.StateSpace.__init__(self,*args)
    #disable function below in base class
    def _remove_useless_states(self):
        pass
```

```python
# Preparatory cell

Aa = numpy.matrix([[-3/50,2.4,0],[0,-0.5,0],[3.6,0,0]])
Ba = numpy.matrix('0; 0.5; 0')
A = numpy.matrix([[-3/50, 2.4],[0,-0.5]])
B = numpy.matrix('0; 0.5')
C = numpy.matrix('3.6 0')
X0 = numpy.matrix('0.; 0.')
K = numpy.matrix([9.75,8.73,0.15])
L = numpy.matrix([[5.4],[10.45]])

# Aw = matrixWidget(3,3)
# Aw.setM(A)
# Bw = matrixWidget(3,1)
# Bw.setM(B)
# Cw = matrixWidget(1,3)
# Cw.setM(C)
X0w = matrixWidget(2,1)
X0w.setM(X0)
Kw = matrixWidget(1,3)
Kw.setM(K)
Lw = matrixWidget(2,1)
Lw.setM(L)

eig1c = matrixWidget(1,1)
eig2c = matrixWidget(2,1)
eig3c = matrixWidget(1,1)
eig1c.setM(numpy.matrix([-0.06]))
eig2c.setM(numpy.matrix([[-2.43],[-2.41]]))
eig3c.setM(numpy.matrix([-0.06]))

eig1o = matrixWidget(1,1)
eig2o = matrixWidget(2,1)
eig1o.setM(numpy.matrix([-10.]))
eig2o.setM(numpy.matrix([[-10.],[0.]]))
```

```python
# Misc

#create dummy widget
DW = widgets.FloatText(layout=widgets.Layout(width='0px', height='0px'))

#create button widget
START = widgets.Button(
    description='Test',
    disabled=False,
    button_style='', # 'success', 'info', 'warning', 'danger' or ''
    tooltip='Test',
    icon='check'
)

def on_start_button_clicked(b):
    #This is a workaround to have intreactive_output call the callback:
    #   force the value of the dummy widget to change
    if DW.value> 0 :
        DW.value = -1
    else:
        DW.value = 1
    pass
START.on_click(on_start_button_clicked)

# Define type of method
selm = widgets.Dropdown(
    options= ['Nastavi K in L', 'Nastavi lastne vrednosti'],
    value= 'Nastavi lastne vrednosti',
    description='',
    disabled=False
)

# Define the number of complex eigenvalues
sele = widgets.Dropdown(
    options= ['brez kompleksnih lastnih vrednosti', 'dve kompleksni lastni vrednosti'],
    value= 'dve kompleksni lastni vrednosti',
    description='Kompleksne lastne vrednosti:',
    style = {'description_width': 'initial'},
    disabled=False
)

#define type of ipout
selu = widgets.Dropdown(
    options=['impulzna funkcija', 'koračna funkcija', 'sinusoidna funkcija', 'kvadratni val'],
    value='koračna funkcija',
    description='Vhod:',
    style = {'description_width': 'initial'},
    disabled=False
)
# Define the values of the input
u = widgets.FloatSlider(
    value=30.,
    min=1.,
    max=150.0,
    step=1,
    description='Referenca:',
    disabled=False,
    continuous_update=False,
    orientation='horizontal',
    readout=True,
    readout_format='.1f',
)
period = widgets.FloatSlider(
    value=30,
    min=1,
    max=80,
    step=1,
    description='Perioda: ',
    disabled=False,
    continuous_update=False,
    orientation='horizontal',
    readout=True,
    readout_format='.2f',
)
time = widgets.BoundedIntText(
    value=120,
    min=1,
    max=200,
    step=1,
    description='Čas:',
    disabled=False
)
sim = widgets.Checkbox(
    value=False,
    description='Nasičenje',
    disabled=False,
    indent=False
)
```

```python
# Support functions

def eigen_choice(sele):
    if sele == 'brez kompleksnih lastnih vrednosti':
        eig1c.children[0].children[0].disabled = False
        eig2c.children[1].children[0].disabled = True
        eig1o.children[0].children[0].disabled = False
        eig2o.children[1].children[0].disabled = True
        eig = 0
    if sele == 'dve kompleksni lastni vrednosti':
        eig1c.children[0].children[0].disabled = True
        eig2c.children[1].children[0].disabled = False
        eig1o.children[0].children[0].disabled = True
        eig2o.children[1].children[0].disabled = False
        eig = 2
    return eig

def method_choice(selm):
    if selm == 'Nastavi K in L':
        method = 1
        sele.disabled = True
    if selm == 'Nastavi lastne vrednosti':
        method = 2
        sele.disabled = False
    return method

def simulation(sys_G, sys_K, sys_int, x0, x0k, time, Ts, ref, lim):
    '''Function that simulate a siso feedback system with a saturation in the actuator and integral action.
       Input to sys_K [u_sys_G; y_sys_G].
       sys_int must have k_3 as gain in output and B = [1, -1] associated to y and ref.'''
    if len(time) != len(ref):
        print('Napaka: T in ref morata biti enake velikosti!')
        return

    # Initialization and preparation...
    K_temp = control.append(sys_K,sys_int)
    K = sss(K_temp.A,
            K_temp.B*sym.Matrix([[1,0,0],[0,1,0],[0,1,0],[0,0,1]]),
            sym.Matrix([1,1]).T*K_temp.C,
            sym.Matrix([1,1]).T*K_temp.D*sym.Matrix([[1,0,0],[0,1,0],[0,1,0],[0,0,1]]))
    sys_temp = control.append(sys_G,K)
    sys_temp1 = sss(sys_temp.A,
                    sys_temp.B*sym.Matrix([[1,0,0],[1,0,0],[0,1,0],[0,0,1]]),
                    sys_temp.C,
                    sys_temp.D*sym.Matrix([[1,0,0],[1,0,0],[0,1,0],[0,0,1]]))
    #sys = control.connect(sys_temp1,[[2,1]],[1,3],[1,2])
    sys = sss(sys_temp1.A+sys_temp1.B[:,1]*sys_temp1.C[0,:],
              sym.Matrix(sys_temp1.B[:,0]).row_join(sym.Matrix(sys_temp1.B[:,2])),
              sys_temp1.C,
              [[0,0],[0,0]])
    sys_d = control.c2d(sys,Ts)
    A, B, C, D = sys_d.A, sys_d.B, sys_d.C, sys_d.D

    # Initializing system's variables
    x = [x0.col_join(x0k).col_join(sym.Matrix([0])) for t in range(0,len(time))]
    yinit = C*x[0]
    y = [yinit for t in range(0,len(time))]
    Input = [sym.Matrix([yinit[1],ref[t]]) for t in range(0,len(time))]

    for i in range(1,len(time)):
        Input[i-1][0] = -y[i-1][1]
        if Input[i-1][0] > lim:
            Input[i-1][0] = lim
        elif Input[i-1][0] < 0:
            Input[i-1][0] = 0.

        x[i] = A*x[i-1] + B*Input[i-1]
        y[i] = C*x[i-1] + D*Input[i-1]

    if Input[-1][0] > lim:
            Input[-1][0] = lim
    elif Input[-1][0] < 0:
            Input[-1][0] = 0.

    y  = [y[t][0] for t in range(len(y))]
    U  = [Input[t][0] for t in range(len(Input))]
    x  = [[x[i][t] for i in range(len(x))] for t in range(A.shape[0])]
    return (y, U, x)

def simulation_ideal(sys_G, sys_K, sys_int):
    K_temp = control.append(sys_K,sys_int)
    Ka = sss(K_temp.A,
            K_temp.B*sym.Matrix([[1,0,0],[0,1,0],[0,1,0],[0,0,1]]),
            sym.Matrix([1,1]).T*K_temp.C,
            sym.Matrix([1,1]).T*K_temp.D*sym.Matrix([[1,0,0],[0,1,0],[0,1,0],[0,0,1]]))
    sys_temp = control.append(sys_G,Ka)
    sys_temp1 = sss(sys_temp.A,
                    sys_temp.B*sym.Matrix([[1,0,0],[1,0,0],[0,1,0],[0,0,1]]),
                    sys_temp.C,
                    sys_temp.D*sym.Matrix([[1,0,0],[1,0,0],[0,1,0],[0,0,1]]))
    #sys = control.connect(sys_temp1,[[2,1],[1,-2]],[3],[1,2])
    sys = sss(sys_temp1.A+sys_temp1.B[:,1]*sys_temp1.C[0,:]-sys_temp1.B[:,0]*sys_temp1.C[1,:],
              sys_temp1.B[:,2],
              sys_temp1.C,
              [[0],[0]])
    return sys
```

```python
sols = numpy.linalg.eig(A)
x0 = sym.Matrix([0,0])
lim = 1.

def main_callback(X0w, K, L, eig1c, eig2c, eig3c, eig1o, eig2o, u, period, selm, sele, selu, sim, time, DW):
    eige = eigen_choice(sele)
    method = method_choice(selm)

    if method == 1:
        solc = numpy.linalg.eig(Aa-Ba*K)
        solo = numpy.linalg.eig(A-L*C)
    if method == 2:
        if eige == 0:
            K = control.acker(Aa, Ba, [eig1c[0,0], eig2c[0,0], eig3c[0,0]])
            Kw.setM(K)
            L = control.acker(A.T, C.T, [eig1o[0,0], eig2o[0,0]]).T
            Lw.setM(L)
        if eige == 2:
            K = control.acker(Aa, Ba, [eig3c[0,0],
                                     numpy.complex(eig2c[0,0],eig2c[1,0]),
                                     numpy.complex(eig2c[0,0],-eig2c[1,0])])
            Kw.setM(K)
            L = control.acker(A.T, C.T, [numpy.complex(eig2o[0,0],eig2o[1,0]),
                                         numpy.complex(eig2o[0,0],-eig2o[1,0])]).T
            Lw.setM(L)

    # Simulation without saturation
    sys_G   = sss(A,B,C,0)
    sys_K   = sss(A-L*C,sym.Matrix(B).row_join(sym.Matrix(L)),K[0][0,:2],[0,0])
    sys_int = sss(0,sym.Matrix([1,-1]).T,K[0][0,2],[0,0])
    x0 = sym.Matrix([0.,0.])
    sys = simulation_ideal(sys_G, sys_K, sys_int)
    solc = sym.Matrix(sys.A).eigenvals(multiple=True,rational=False)
    solc = numpy.matrix(solc,dtype=numpy.complex128)
    print('Lastni vrednosti sistema sta:', round(sols[0][0],2),'and', round(sols[0][1],2))
    print('Lastne vrednosti krmiljenega zaprtozančnega sistema so:',
          round(solc[0,0],2),',',
          round(solc[0,1],2),',',
          round(solc[0,2],2),',',
          round(solc[0,3],2),'in',
          round(solc[0,4],2))

    X0w1 = [float(x0[0,0]),float(x0[1,0]),float(X0w[0,0]),float(X0w[1,0]),0.]
    T, Td = numpy.linspace(0,time,1000,retstep=True)

    if selu == 'impulzna funkcija': #selu
        U = [0 for t in range(0,len(T))]
        U[0] = u
        if sim == False:
            T, yout, xout = control.forced_response(sys,T,U,X0w1)
    if selu == 'koračna funkcija':
        U = [u for t in range(0,len(T))]
        if sim == False:
            T, yout, xout = control.forced_response(sys,T,U,X0w1)
    if selu == 'sinusoidna funkcija':
        U = u*numpy.sin(2*numpy.pi/period*T)+u
        if sim == False:
            T, yout, xout = control.forced_response(sys,T,U,X0w1)
    if selu == 'kvadratni val':
        U = u*numpy.sign(numpy.sin(2*numpy.pi/period*T))+u
        if sim == False:
            T, yout, xout = control.forced_response(sys,T,U,X0w1)

    if sim:
        y_sat, U_sat, x_sat = simulation(sys_G, sys_K, sys_int, x0, sym.Matrix(X0w), T, Td, U, lim)

    fig = plt.figure(num='Simulacija', figsize=(14,12))
    fig.add_subplot(224)
    plt.title('Odziv sistema')
    plt.ylabel('Izhod')
    if sim:
        plt.plot(T,y_sat,T,U,'r--')
        plt.legend(['$y_{sat}$','Referenca'])
    else:
        plt.plot(T,yout[0],T,U,'r--')
        plt.legend(['$y$','Referenca'])
    plt.xlabel('$t$ [s]')
    plt.axvline(x=0,color='black',linewidth=0.8)
    plt.axhline(y=0,color='black',linewidth=0.8)
    plt.grid()

    fig.add_subplot(221)
    plt.title('Odziv prvega stanja')
    plt.ylabel('$x_1$')
    if sim:
        plt.plot(T,x_sat[0],T,x_sat[2])
        plt.legend(['$x_{1sat}$','$x_{1est}$'])
    else:
        plt.plot(T,xout[0],T,xout[2])
        plt.legend(['$x_{1}$','$x_{1est}$'])
    plt.xlabel('$t$ [s]')
    plt.axvline(x=0,color='black',linewidth=0.8)
    plt.axhline(y=0,color='black',linewidth=0.8)
    plt.grid()

    fig.add_subplot(222)
    plt.title('Odziv drugega stanja')
    plt.ylabel('$x_2$')
    if sim:
        plt.plot(T,x_sat[1],T,x_sat[3])
        plt.legend(['$x_{2sat}$','$x_{2est}$'])
    else:
        plt.plot(T,xout[1],T,xout[3])
        plt.legend(['$x_{2}$','$x_{2est}$'])
    plt.xlabel('$t$ [s]')
    plt.axvline(x=0,color='black',linewidth=0.8)
    plt.axhline(y=0,color='black',linewidth=0.8)
    plt.grid()

    fig.add_subplot(223)
    plt.title('Vhod')
    plt.ylabel('Vhod')
    if sim:
        plt.plot(T[:-1],U_sat[:-1],T,[1 for t in range(len(T))],'r--')
        plt.legend(['dejanska vrednost','maks. vrednost'])
    else:
        plt.plot(T,-yout[1],T,[1 for t in range(len(T))],'r--')
        plt.legend(['dejanska vrednost','maks. vrednost'])
    plt.xlabel('$t$ [s]')
    plt.axvline(x=0,color='black',linewidth=0.8)
    plt.axhline(y=0,color='black',linewidth=0.8)
    plt.grid()
    if selu == 'step':
        if sim:
            print('Maksimalen prenihaj znaša:',round((max(y_sat)-U[0])/U[0]*100,3),'percent')
        else:
            print('Maksimalen prenihaj znaša:',round((max(yout[0])-U[0])/U[0]*100,3),'percent')

alltogether = widgets.VBox([widgets.HBox([selm,
                                          sele,
                                          selu]),
                            widgets.Label(' ',border=3),
                            widgets.HBox([widgets.Label('K:',border=3), Kw,
                                          widgets.Label(' ',border=3),
                                          widgets.Label(' ',border=3),
                                          widgets.Label('Lastne vrednosti:',border=3),
                                          eig1c,
                                          eig2c,
                                          eig3c,
                                          widgets.Label(' ',border=3),
                                          widgets.Label(' ',border=3),
                                          widgets.Label('X0 est.:',border=3), X0w]),
                            widgets.Label(' ',border=3),
                            widgets.HBox([widgets.Label('L:',border=3), Lw,
                                          widgets.Label(' ',border=3),
                                          widgets.Label(' ',border=3),
                                          widgets.Label('Lastne vrednosti:',border=3),
                                          eig1o,
                                          eig2o,
                                          widgets.Label(' ',border=3),
                                          time]),
                            widgets.Label(' ',border=3),
                            widgets.HBox([u,
                                          period,
                                          widgets.Label(' ',border=3),widgets.Label(' ',border=3),sim]),
                            widgets.HBox([widgets.Label('Klikni za K, L ali lastne vrednosti ->',border=3),
                            START])])
out = widgets.interactive_output(main_callback, {'X0w':X0w, 'K':Kw, 'L':Lw,
                                                 'eig1c':eig1c, 'eig2c':eig2c, 'eig3c':eig3c,
                                                 'eig1o':eig1o, 'eig2o':eig2o,
                                                 'u':u, 'period':period, 'selm':selm, 'sele':sele, 'selu':selu,
                                                 'sim':sim, 'time':time, 'DW':DW})
out.layout.height = '840px'
display(out, alltogether)
```

    Output(layout=Layout(height='840px'))

    VBox(children=(HBox(children=(Dropdown(index=1, options=('Nastavi K in L', 'Nastavi lastne vrednosti'), value=…


# Split Monopole `GiRaFFEfood` Initial Data for `GiRaFFE`

## Author: Patrick Nelson

### NRPy+ Source Code for this module: [GiRaFFEfood_NRPy/GiRaFFEfood_NRPy_Split_Monopole.py](../../edit/in_progress/GiRaFFEfood_NRPy/GiRaFFEfood_NRPy_Split_Monopole.py)

**Notebook Status:**  In-Progress

**Validation Notes:** This tutorial notebook has been confirmed to be self-consistent with its corresponding NRPy+ module, as documented [below](#code_validation1). The initial data has validated against the original `GiRaFFE`, as documented [here](Tutorial-Start_to_Finish_UnitTest-GiRaFFEfood_NRPy.ipynb).

## Introduction:
We need to "feed" our giraffe with initial data to evolve. There are several different choices of initial data we can use here; here, we will only be implementing the "Split Monopole" initial data, given by Table 3 in [the original paper](https://arxiv.org/pdf/1704.00599.pdf). This solution is based on the Blandford-Znajek force-free monopole; it is an approximation for the case of small spin with the solution inverted in the lower hemisphere. The vector potential is
\begin{align}
A_r &= -\frac{aC}{8}\cos\theta \left( 1 + \frac{4M}{r} \right), \\
A_\theta &= 0, \\
A_\phi &= M^2 C [1-\cos \theta + a^2 f(r) \cos \theta \sin^2 \theta],
\end{align}
and the electric field is
\begin{align}
E_r &= -\frac{C a^3}{8\alpha M^3} f'(r) \cos \theta \sin^2 \theta \\
E_\theta &= -\frac{Ca}{8\alpha}[\sin \theta + a^2 f(r) \sin \theta (2 \cos^2 \theta-\sin^2 \theta) ] - \beta^r \sqrt{\gamma} \frac{a C}{8 r^2}\left( 1+\frac{4M}{r}\right) \\
E_\phi &= \frac{\beta^r}{\alpha M} Ca^2 f'(r) \cos \theta \sin^2 \theta,
\end{align}
where
\begin{align}
f(r) =& \ \frac{r^2(2r-3M)}{8M^3} L \left(\frac{2M}{r}\right) \\
&+ \frac{M^2+3Mr-6r^2}{12M^2} \ln \frac{r}{2M} \\
&+ \frac{11}{72} + \frac{M}{3r} + \frac{r}{2M} - \frac{r^2}{M^2}, \\
L(x) =& \ {\rm Li}_2(x) + \frac{1}{2} \ln x \ln (1-x).
\end{align}
The function ${\rm Li}_2(x)$ is known as the dilogarithm function, defined as
$$ {\rm Li}_2(x) = -\int_{0}^{1} \frac{\ln(1-tx)}{t} dt = \sum_{k=1}^{\infty} \frac{x^k}{k^2}. $$
Now, to use this initial data scheme, we need to transform the above into the quantities actually tracked by `GiRaFFE` and HydroBase: $A_i$, $B^i$, $\tilde{S}_i$, $v^i$, and $\Phi$. Of these quantities, `GiRaFFEfood` will only set $A_i$, $v^i$, and $\Phi=0$, then call a separate function to calculate  $\tilde{S}_i$; `GiRaFFE` itself will call a function to set $B^i$ before the time-evolution begins. This can be done with eqs. 16 and 18, here given in that same order:
\begin{align}
v_{(n)}^i &= \frac{\epsilon^{ijk} E_j B_k}{B^2} \\
B^i &= \frac{[ijk]}{\sqrt{\gamma}} \partial_j A_k \\
\end{align}
In the simulations, $B^i$ will be calculated numerically from $A_i$; however, it will be useful to analytically calculate $B^i$ to use calculating the initial $v^i$.

This module requires the use of the NRPy+ [Shifted Kerr-Schild initial data module](../Tutorial-ADM_Initial_Data-ShiftedKerrSchild.ipynb)

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

This notebook is organized as follows

1. [Step 1](#initializenrpy): Import core NRPy+ modules and set NRPy+ parameters
1. [Step 2](#aux_func): Write helpful auxiliary functions
1. [Step 3](#set_a_i): Set the vector $A_i$
1. [Step 4](#set_vi): Calculate $v^i$ from $B_i$ and $E_i$
1. [Step 5](#code_validation1): Code Validation against `GiRaFFEfood_NRPy.GiRaFFEfood_NRPy` NRPy+ Module
1. [Step 6](#latex_pdf_output): Output this notebook to $\LaTeX$-formatted PDF file

# Step 1: Import core NRPy+ modules and set NRPy+ parameters \[Back to [top](#toc)\]
$$\label{initializenrpy}$$

Here, we will import the NRPy+ core modules after adding NRPy+ to the directory path.

```python
# Step 0: Add NRPy's directory to the path
# https://stackoverflow.com/questions/16780014/import-file-from-parent-directory
import os,sys
nrpy_dir_path = os.path.join("..")
if nrpy_dir_path not in sys.path:
    sys.path.append(nrpy_dir_path)

# Step 0.a: Import the NRPy+ core modules and set the reference metric to Cartesian
from outputC import nrpyAbs
import NRPy_param_funcs as par   # NRPy+: Parameter interface
import indexedexp as ixp         # NRPy+: Symbolic indexed expression (e.g., tensors, vectors, etc.) support
import sympy as sp               # SymPy: The Python computer algebra package upon which NRPy+ depends
import GiRaFFEfood_NRPy.GiRaFFEfood_NRPy_Common_Functions as gfcf # Some useful functions for GiRaFFE initial data.
import reference_metric as rfm   # NRPy+: Reference metric support
import Min_Max_and_Piecewise_Expressions as noif
par.set_parval_from_str("reference_metric::CoordSystem","Cartesian")
rfm.reference_metric()
# Step 1a: Set commonly used parameters.
thismodule = "GiRaFFEfood_NRPy_Split_Monopole"

# The solution depends on a constant C
C_SM = par.Cparameters("REAL",thismodule,["C_SM"], 1.0)
import GiRaFFEfood_NRPy.GiRaFFEfood_NRPy as gf # Import this now to set parameters
```

# Step 2: Write helpful auxiliary functions \[Back to [top](#toc)\]
$$\label{aux_func}$$

We begin by coding the function $f(r)$ with the inputs $r$ and mass $M$. We avoid calling the function simply `f` out of an abundance of caution, as we do not want to risk overwriting an identically named function elsewhere.
\begin{align}
f(r) =& \ \frac{r^2(2r-3M)}{8M^3} L \left(\frac{2M}{r}\right) \\
&+ \frac{M^2+3Mr-6r^2}{12M^2} \ln \frac{r}{2M} \\
&+ \frac{11}{72} + \frac{M}{3r} + \frac{r}{2M} - \frac{r^2}{2M^2}, \\
\end{align}
where $L(x) = {\rm Li}_2 (x) + \frac{1}{2} \ln x \ln (1-x)$ and ${\rm Li}_2 (x)$ is known as the dilogarithm function. We will use the C library `gsl` to handle this special function. In order to do so, we must tell NRPy+ that `nrpyDilog` will be our code word that means "use the `gsl` dilogarithm function, `gsl_sf_dilog`". This is done by simply creating a new sympy function using `Function()` and then adding the name-value pair to the dictionary `custom_functions_for_SymPy_ccode`.

```python
nrpyDilog = sp.Function('nrpyDilog')
from outputC import custom_functions_for_SymPy_ccode
custom_functions_for_SymPy_ccode["nrpyDilog"] = "gsl_sf_dilog"

def f_of_r(r,M):
    if par.parval_from_str("drop_fr"):
        return sp.sympify(0)
    x = sp.sympify(2)*M/r
    L = sp.sympify(0) + \
        noif.coord_greater_bound(x,sp.sympify(0))*noif.coord_less_bound(x,sp.sympify(1))*nrpyDilog(x)\
       +sp.Rational(1,2)*sp.log(noif.coord_greater_bound(x,sp.sympify(0))*x + noif.coord_leq_bound(x,sp.sympify(1)))\
       *sp.log(noif.coord_less_bound(x,sp.sympify(1))*(sp.sympify(1)-x) + noif.coord_geq_bound(x,sp.sympify(1)))
    f = r*r*(sp.sympify(2)*r-sp.sympify(3)*M)*sp.Rational(1,8)/(M**3)*L\
      +(M*M+sp.sympify(3)*M*r-sp.sympify(6)*r*r)*sp.Rational(1,12)/(M*M)*sp.log(r*sp.Rational(1,2)/M)\
      +sp.Rational(11,72) + M*sp.Rational(1,3)/r + r*sp.Rational(1,2)/M - r*r*sp.Rational(1,2)/(M*M)
    return f
```

We will also need the derivative of $f(r)$:
\begin{align}
f'(r) =& \ \frac{2r(2r-3M)+2r^2}{8M^3} L \left(\frac{2M}{r}\right) + \frac{r^2(2r-3M)}{8M^3} L' \left(\frac{2M}{r}\right) \left( -\frac{2M}{r^2} \right) \\
&+ \frac{3M-12r}{12M^2} \ln \frac{r}{2M} + \frac{M^2+3Mr-6r^2}{12M^2} \frac{2M}{r} \frac{1}{2M} \\
&- \frac{M}{3r^2} + \frac{1}{2M} - \frac{2r}{M^2}. \\
\end{align}

Because $$\frac{\partial {\rm Li}_2 (x)}{\partial x} = \frac{{\rm Li}_1 (x)}{x} = \frac{-\ln (1-x)}{x},$$
we know that
\begin{align}
L'(x) &= \frac{-\ln (1-x)}{x} + \frac{\ln (1-x)}{2x} - \frac{\ln (x)}{2-2x} \\
      &= -\frac{1}{2} \left( \frac{\ln (1-x)}{x} + \frac{\ln(x)}{1-x} \right).
\end{align}
We simplify this some.
\begin{align}
f'(r) =& \ \frac{3r^2-3Mr}{4M^3} L \left(\frac{2M}{r}\right) - \frac{2r-3M}{4M^2} L' \left(\frac{2M}{r}\right)\\
&+ \frac{3M-12r}{12M^2} \ln \frac{r}{2M} + \frac{M^2+3Mr-6r^2}{12rM^2} \\
&- \frac{M}{3r^2} + \frac{1}{2M} - \frac{r}{M^2}. \\
\end{align}

```python
def fp_of_r(r,M):
    if par.parval_from_str("drop_fr"):
        return sp.sympify(0)
    x   = sp.sympify(2)*M/r
    L = sp.sympify(0) + \
        noif.coord_greater_bound(x,sp.sympify(0))*noif.coord_less_bound(x,sp.sympify(1))*nrpyDilog(x)\
       +sp.Rational(1,2)*sp.log(noif.coord_greater_bound(x,sp.sympify(0))*x + noif.coord_leq_bound(x,sp.sympify(1)))\
       *sp.log(noif.coord_less_bound(x,sp.sympify(1))*(sp.sympify(1)-x) + noif.coord_geq_bound(x,sp.sympify(1)))
    Lp  = sp.sympify(0) + noif.coord_greater_bound(x,sp.sympify(0))*noif.coord_less_bound(x,sp.sympify(1)) * -sp.Rational(1,2) *\
         (sp.log(noif.coord_less_bound(x,sp.sympify(1))*(sp.sympify(1)-x) + noif.coord_geq_bound(x,sp.sympify(1)))/(x+sp.sympify(1e-100))\
         +sp.log(noif.coord_greater_bound(x,sp.sympify(0))*x + noif.coord_leq_bound(x,sp.sympify(1)))/(sp.sympify(1)-x+sp.sympify(1e-100)))
    fp  = sp.sympify(3)*r*(r-M)*sp.Rational(1,4)/(M**3)*L + (sp.sympify(2)*r-sp.sympify(3)*M)*sp.Rational(1,4)/(M*M)*Lp\
         +(sp.sympify(3)*M-12*r)*sp.Rational(1,12)/(M*M)*sp.log(r*sp.Rational(1,2)/M) + (M*M+sp.sympify(3)*M*r-sp.sympify(6)*r*r)*sp.Rational(1,12)/(r*M*M)\
         -M*sp.Rational(1,3)/(r*r) + sp.Rational(1,2)/M - r/(M*M)
    return fp

```

# Step 3: Set the vector $A_i$ \[Back to [top](#toc)\]
$$\label{set_a_i}$$

Now, we will code the components of the vector potential $A_i$ in spherical coordinates and a spherical basis. The outputs from these functions can then be easily converted to other coordinate systems by giving the spherical coordinates as inputs in terms of the desired coordinates (e.g., if we want to use Cartesian coordinates, we pass $r = \sqrt{x^2+y^2+z^2}$ and so on). They can also by transformed into any other basis using the appropriate Jacobian matrix.

We will code each component as its own function to more easily apply the appropriate staggering.
\begin{align}
A_r &= -\frac{aC}{8} \left| \cos \theta \right| \left( 1 + \frac{4M}{r} \right)
\sqrt{1 + \frac{2M}{r}}, \\
\end{align}

```python
def Ar_SM(r,theta,phi, **params):
    M = params["M"]
    a = params["a"]
    # A_r = -aC/8 * cos \theta ( 1 + 4M/r ) \sqrt{1 + 2M/r}
    return -a*C_SM*sp.Rational(1,8)*nrpyAbs(sp.cos(theta))*(sp.sympify(1)+sp.sympify(4)*M/r)*sp.sqrt(sp.sympify(1)+sp.sympify(2)*M/r)
```

\begin{align}
A_\theta &= 0, \\
\end{align}

```python
def Ath_SM(r,theta,phi, **params):
    # A_\theta = 0
    return sp.sympify(0)
```

\begin{align}
A_\phi &= M^2 C [1- \left| \cos \theta \right| + a^2 f(r) \cos \theta \sin^2 \theta]
\end{align}

```python
def Aph_SM(r,theta,phi, **params):
    M = params["M"]
    a = params["a"]
    # A_\phi = M^2 C [1-\cos \theta + a^2 f(r) cos \theta sin^2 \theta]
    return M*M*C_SM*(sp.sympify(1)-nrpyAbs(sp.cos(theta))+a*a*f_of_r(r,M)*sp.cos(theta)*sp.sin(theta)**2)
```

# Step 4: Calculate $v^i$ from $B_i$ and $E_i$ \[Back to [top](#toc)\]
$$\label{set_vi}$$

Next, we will code up the Valencia velocity, which will require us to first code the electric and magnetic fields. The magnetic field is simply $$B^i = \frac{[ijk]}{\sqrt{\gamma}} \partial_j A_k,$$ which gives
\begin{align}
B^r &= \frac{C \alpha M^2}{r^2} + \frac{C \alpha a^2 M^2}{2r^4} \left[ -2\cos \theta + \left(\frac{r}{M}\right)^2 (1+3 \cos 2\theta) f(r) \right], \\
B^\theta &= - \frac{C \alpha a^2}{r^2} \sin \theta \cos \theta f'(r), \\
B^\phi &= -\frac{C \alpha a M}{8r^2} \left( 1 + \frac{4M}{r}\right) .
\end{align}

The electric field is
\begin{align}
E_r &= -\frac{C a^3}{8\alpha M^3} f'(r) \cos \theta \sin^2 \theta \\
E_\theta &= -\frac{Ca}{8\alpha}[\sin \theta + a^2 f(r) \sin \theta (2 \cos^2 \theta-\sin^2 \theta) ] - \beta^r \sqrt{\gamma} \frac{a C}{8 r^2}\left( 1+\frac{4M}{r}\right) \\
E_\phi &= \frac{\beta^r}{\alpha M} Ca^2 f'(r) \cos \theta \sin^2 \theta,
\end{align}

We can then calculate the the velocity as $$v_{(n)}^i = \frac{\epsilon^{ijk} E_j B_k}{B^2}.$$

```python
def ValenciavU_func_SM(**params):
    M = params["M"]
    a = params["a"]
    alpha = params["alpha"]
    betaU = params["betaU"] # Note that this must use a spherical basis!
    gammaDD = params["gammaDD"] # Note that this must use a Cartesian basis!
    sqrtgammaDET = params["sqrtgammaDET"] # This must be spherical
    KerrSchild_radial_shift = params["KerrSchild_radial_shift"]
    r     = rfm.xxSph[0] + KerrSchild_radial_shift # We are setting the data up in Shifted Kerr-Schild coordinates
    theta = rfm.xxSph[1]
    phi   = rfm.xxSph[2]
    z     = rfm.xx_to_Cart[2]

    split_C_SM = noif.coord_geq_bound(z,sp.sympify(0))*C_SM - noif.coord_less_bound(z,sp.sympify(0))*C_SM

    BsphU = ixp.zerorank1()
    BsphU[0] = split_C_SM*alpha*M*M/(r*r) + \
               split_C_SM*alpha*a*a*M*M*sp.Rational(1,2)/(r**4)*(-sp.sympify(2)*sp.cos(theta) + (r/M)**2*(sp.sympify(1)+sp.sympify(3)*sp.cos(sp.sympify(2)*theta))*f_of_r(r,M))
    BsphU[1] = -split_C_SM*alpha*a*a/(r*r) * sp.sin(theta)*sp.cos(theta)*fp_of_r(r,M)
    BsphU[2] = -split_C_SM*alpha*a*M*sp.Rational(1,8)/(r*r)*(sp.sympify(1)+sp.sympify(4)*M/r)

    EsphD = ixp.zerorank1()
    EsphD[0] = -split_C_SM*a**3/(sp.sympify(8)*alpha*M**3)*fp_of_r(r,M)*sp.cos(theta)*sp.sin(theta)**2
    EsphD[1] = -split_C_SM*a*sp.Rational(1,8)/alpha*(sp.sin(theta) + a*a*f_of_r(r,M)*sp.sin(theta)*(sp.sympify(2)*sp.cos(theta)**2-sp.sin(theta)**2)) - \
               betaU[0]*sqrtgammaDET*a*split_C_SM*sp.Rational(1,8)/(r*r)*(sp.sympify(1)+sp.sympify(4)*M/r)
    EsphD[2] = betaU[0]/(alpha*M)*split_C_SM*a*a*fp_of_r(r,M)*sp.cos(theta)*sp.sin(theta)**2

    ED = gfcf.change_basis_spherical_to_Cartesian_D(EsphD)
    BU = gfcf.change_basis_spherical_to_Cartesian_U(BsphU)

    return gfcf.compute_ValenciavU_from_ED_and_BU(ED, BU, gammaDD)
```

# Step 5: Code Validation against `GiRaFFEfood_NRPy.GiRaFFEfood_NRPy` NRPy+ module  \[Back to [top](#toc)\]
$$\label{code_validation1}$$

Here, as a code validation check, we verify agreement in the SymPy expressions for the `GiRaFFE` Exact Wald initial data equations  we intend to use between
1. this tutorial and
2. the NRPy+ [GiRaFFEfood_NRPy.GiRaFFEfood_NRPy-Split_Monopole](../edit/GiRaFFEfood_NRPy/GiRaFFEfood_NRPy_Split_Monopole.py) module.

```python
import BSSN.ShiftedKerrSchild as sks
sks.ShiftedKerrSchild(True)
import reference_metric as rfm   # NRPy+: Reference metric support
par.set_parval_from_str("reference_metric::CoordSystem","Cartesian")
rfm.reference_metric()

# Use the Jacobian matrix to transform the vectors to Cartesian coordinates.
drrefmetric__dx_0UDmatrix = sp.Matrix([[sp.diff(rfm.xxSph[0],rfm.xx[0]), sp.diff(rfm.xxSph[0],rfm.xx[1]), sp.diff(rfm.xxSph[0],rfm.xx[2])],
                                       [sp.diff(rfm.xxSph[1],rfm.xx[0]), sp.diff(rfm.xxSph[1],rfm.xx[1]), sp.diff(rfm.xxSph[1],rfm.xx[2])],
                                       [sp.diff(rfm.xxSph[2],rfm.xx[0]), sp.diff(rfm.xxSph[2],rfm.xx[1]), sp.diff(rfm.xxSph[2],rfm.xx[2])]])
dx__drrefmetric_0UDmatrix = drrefmetric__dx_0UDmatrix.inv()
gammaSphDD = ixp.zerorank2()
for i in range(3):
    for j in range(3):
        gammaSphDD[i][j] += sks.gammaSphDD[i][j].subs(sks.r,rfm.xxSph[0]).subs(sks.th,rfm.xxSph[1])

gammaDD = ixp.zerorank2()
for i in range(3):
    for j in range(3):
        for k in range(3):
            for l in range(3):
                gammaDD[i][j] += drrefmetric__dx_0UDmatrix[(k,i)]*drrefmetric__dx_0UDmatrix[(l,j)]*gammaSphDD[k][l]
unused_gammaUU,gammaDET = ixp.symm_matrix_inverter3x3(gammaDD)
sqrtgammaDET = sp.sqrt(gammaDET)

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

A_smD = gfcf.Axyz_func_spherical(Ar_SM,Ath_SM,Aph_SM,stagger_enable = True,M=sks.M,a=sks.a,KerrSchild_radial_shift=sks.r0)
Valenciav_smD = ValenciavU_func_SM(M=sks.M,a=sks.a,KerrSchild_radial_shift=sks.r0,alpha=alpha,betaU=betaU,gammaDD=gammaDD,sqrtgammaDET=sqrtgammaDET)
gf.GiRaFFEfood_NRPy_generate_initial_data(ID_type = "SplitMonopole", stagger_enable = True,M=sks.M,a=sks.a,KerrSchild_radial_shift=sks.r0,alpha=alpha,betaU=betaU,gammaDD=gammaDD,sqrtgammaDET=sqrtgammaDET)

def consistency_check(quantity1,quantity2,string):
    if quantity1-quantity2==0:
        print(string+" is in agreement!")
    else:
        print(string+" does not agree!")
        sys.exit(1)

for i in range(3):
    consistency_check(Valenciav_smD[i],gf.ValenciavU[i],"ValenciavU"+str(i))
    consistency_check(A_smD[i],gf.AD[i],"AD"+str(i))
```

    ValenciavU0 is in agreement!
    AD0 is in agreement!
    ValenciavU1 is in agreement!
    AD1 is in agreement!
    ValenciavU2 is in agreement!
    AD2 is in agreement!

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

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

```python
import cmdline_helper as cmd    # NRPy+: Multi-platform Python command-line interface
cmd.output_Jupyter_notebook_to_LaTeXed_PDF("Tutorial-GiRaFFEfood_NRPy-Split_Monopole",location_of_template_file=os.path.join(".."))
```

    Created Tutorial-GiRaFFEfood_NRPy-Split_Monopole.tex, and compiled LaTeX
        file to PDF file Tutorial-GiRaFFEfood_NRPy-Split_Monopole.pdf