# Mie Scattering Function

**Scott Prahl**

**April 2021**

*If miepython is not installed, uncomment the following cell (i.e., delete the #) and run (shift-enter)*

```python
#!pip install --user miepython
```

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

try:
    import miepython

except ModuleNotFoundError:
    print('miepython not installed. To install, uncomment and run the cell above.')
    print('Once installation is successful, rerun this cell again.')
```

    miepython not installed. To install, uncomment and run the cell above.
    Once installation is successful, rerun this cell again.

Mie scattering describes the special case of the interaction of light passing through a non-absorbing medium with a single embedded spherical object.  The sphere itself can be non-absorbing, moderately absorbing, or perfectly absorbing.

## Goals for this notebook:

* show how to plot the phase function
* explain the units for the scattering phase function
* describe normalization of the phase function
* show a few examples from classic Mie texts

## Geometry

Specifically, the scattering function $p(\theta_i,\phi_i,\theta_o,\phi_o)$ describes the amount of light scattered by a particle for light incident at an angle $(\theta_i,\phi_i)$ and exiting the particle (in the far field) at an angle $(\theta_o,\phi_o)$.  For simplicity, the scattering function is often assumed to be rotationally symmetric (it is, obviously, for spherical scatterers) and that the angle that the light is scattered into only depends the $\theta=\theta_o-\theta_i$.  In this case, the scattering function can be written as $p(\theta)$.  Finally, the angle is often replaced by $\mu=\cos\theta$ and therefore the phase function becomes just $p(\mu)$.

The figure below shows the basic idea.  An incoming monochromatic plane wave hits a sphere and produces *in the far field* two separate monochromatic waves — a slightly attenuated unscattered planar wave and an outgoing spherical wave.

Obviously. the scattered light will be cylindrically symmetric about the ray passing through the center of the sphere.

```python
t = np.linspace(0,2*np.pi,100)
xx = np.cos(t)
yy = np.sin(t)
fig,ax=plt.subplots(figsize=(10,8))
plt.axes().set_aspect('equal')
plt.plot(xx,yy)
plt.plot([-5,7],[0,0],'--k')

plt.annotate('incoming irradiance', xy=(-4.5,-2.3),ha='left',color='blue',fontsize=14)
for i in range(6):
    y0 = i -2.5
    plt.annotate('',xy=(-1.5,y0),xytext=(-5,y0),arrowprops=dict(arrowstyle="->",color='blue'))

plt.annotate('unscattered irradiance', xy=(3,-2.3),ha='left',color='blue',fontsize=14)
for i in range(6):
    y0 = i -2.5
    plt.annotate('',xy=(7,y0),xytext=(3,y0),arrowprops=dict(arrowstyle="->",color='blue',ls=':'))

plt.annotate('scattered\nspherical\nwave', xy=(0,1.5),ha='left',color='red',fontsize=16)
plt.annotate('',xy=(2.5,2.5),xytext=(0,0),arrowprops=dict(arrowstyle="->",color='red'))
plt.annotate(r'$\theta$',xy=(2,0.7),color='red',fontsize=14)
plt.annotate('',xy=(2,2),xytext=(2.7,0),arrowprops=dict(connectionstyle="arc3,rad=0.2", arrowstyle="<->",color='red'))

plt.xlim(-5,7)
plt.ylim(-3,3)
plt.axis('off')
plt.show()
```

## Scattered Wave

```python
fig,ax=plt.subplots(figsize=(10,8))
plt.axes().set_aspect('equal')
plt.scatter([0],[0],s=30)

m = 1.5
x = np.pi/3
theta = np.linspace(-180,180,180)
mu = np.cos(theta/180*np.pi)
scat = 15 * miepython.i_unpolarized(m,x,mu)

plt.plot(scat*np.cos(theta/180*np.pi),scat*np.sin(theta/180*np.pi))
for i in range(12):
    ii = i*15
    xx = scat[ii]*np.cos(theta[ii]/180*np.pi)
    yy = scat[ii]*np.sin(theta[ii]/180*np.pi)
#    print(xx,yy)
    plt.annotate('',xy=(xx,yy),xytext=(0,0),arrowprops=dict(arrowstyle="->",color='red'))

plt.annotate('incident irradiance', xy=(-4.5,-2.3),ha='left',color='blue',fontsize=14)
for i in range(6):
    y0 = i -2.5
    plt.annotate('',xy=(-1.5,y0),xytext=(-5,y0),arrowprops=dict(arrowstyle="->",color='blue'))

plt.annotate('unscattered irradiance', xy=(3,-2.3),ha='left',color='blue',fontsize=14)
for i in range(6):
    y0 = i -2.5
    plt.annotate('',xy=(7,y0),xytext=(3,y0),arrowprops=dict(arrowstyle="->",color='blue',ls=':'))

plt.annotate('scattered\nspherical wave', xy=(0,1.5),ha='left',color='red',fontsize=16)

plt.xlim(-5,7)
plt.ylim(-3,3)
#plt.axis('off')
plt.show()
```

## Normalization of the scattered light

So the scattering function or phase function has at least three reasonable normalizations that involve integrating over all $4\pi$ steradians.  Below $d\Omega=\sin\theta d\theta\,d\phi$ is a differential solid angle

$$
\begin{align}
\int_{4\pi} p(\theta,\phi) \,d\Omega &= 1\\[2mm]
\int_{4\pi} p(\theta,\phi) \,d\Omega &= 4\pi \\[2mm]
\int_{4\pi} p(\theta,\phi) \,d\Omega &= a  \qquad\qquad \mbox{Used by miepython}\\[2mm]
\end{align}
$$

where $a$ is the single scattering albedo,

$$
a = \frac{\sigma_s}{\sigma_s+\sigma_a}
$$

and $\sigma_s$ is the scattering cross section, and $\sigma_a$ is the absorption cross section.

*The choice of normalization was made because it accounts for light lost through absorption by the sphere.*

If the incident light has units of watts, then the values from the scattering function $p(\theta,\phi)$ have units of radiant intensity or W/sr.

For example, a circular detector with radius $r_d$ at a distance $R$  will subtend an angle

$$
\Omega = \frac{\pi r_d^2}{R^2}
$$

(assuming $r_d\ll R$). Now if $P_0$ of light is scattered by a sphere then the scattered power on the detector will be

$$
P_d = P_0 \cdot \Omega \cdot p(\theta,\phi)
$$

## Examples

### Unpolarized Scattering Function

If unpolarized light hits the sphere, then there are no polarization effects to worry about.  It is pretty easy to generate a plot to show how scattering changes with angle.

```python
m = 1.5
x = np.pi/3
theta = np.linspace(-180,180,180)
mu = np.cos(theta/180*np.pi)
scat = miepython.i_unpolarized(m,x,mu)

fig,ax = plt.subplots(1,2,figsize=(12,5))
ax=plt.subplot(121, projection='polar')
ax.plot(theta/180*np.pi,scat)
ax.set_rticks([0.05, 0.1,0.15])
ax.set_title("m=1.5, Sphere Diameter = $\lambda$/3")

plt.subplot(122)
plt.plot(theta,scat)
plt.xlabel('Exit Angle [degrees]')
plt.ylabel('Unpolarized Scattered light [1/sr]')
plt.title('m=1.5, Sphere Diameter = $\lambda$/3')
plt.ylim(0.00,0.2)

plt.show()
```

A similar calculation but using `ez_intensities()`

```python
m = 1.33
lambda0 = 632.8 # nm
d = 200        # nm

theta = np.linspace(-180,180,180)
mu = np.cos(theta/180*np.pi)

Ipar, Iper = miepython.ez_intensities(m, d, lambda0, mu)

fig,ax = plt.subplots(1,2,figsize=(12,5))
ax=plt.subplot(121, projection='polar')
ax.plot(theta/180*np.pi,Ipar)
ax.plot(theta/180*np.pi,Iper)

ax.set_rticks([0.05, 0.1, 0.15, 0.20])
plt.title("m=%.2f, Sphere Diameter = %.0f nm, $\lambda$=%.1f nm" % (m, d, lambda0))

plt.subplot(122)
plt.plot(theta,Ipar)
plt.plot(theta,Iper)

plt.xlabel('Exit Angle [degrees]')
plt.ylabel('Unpolarized Scattered light [1/sr]')
plt.title("m=%.2f, Sphere Diameter = %.0f nm, $\lambda$=%.1f nm" % (m, d, lambda0))
plt.ylim(0.00,0.2)

plt.show()
```

### Rayleigh Scattering

Classic Rayleigh scattering treats small particles with natural (unpolarized) light.
The solid black line denotes the total scattered intensity. The red dashed line is light scattered that is polarized perpendicular to the plane of the graph and the blue dotted line is for light parallel to the plane of the graph. (Compare with van de Hult, Figure 10)

```python
m = 1.3
x = 0.01
theta = np.linspace(-180,180,180)
mu = np.cos(theta/180*np.pi)
ipar = miepython.i_par(m,x,mu)/2
iper = miepython.i_per(m,x,mu)/2
iun  = miepython.i_unpolarized(m,x,mu)

fig,ax = plt.subplots(1,2,figsize=(12,5))
ax=plt.subplot(121, projection='polar')
ax.plot(theta/180*np.pi,iper,'r--')
ax.plot(theta/180*np.pi,ipar,'b:')
ax.plot(theta/180*np.pi,iun,'k')

ax.set_rticks([0.05, 0.1,0.15])
plt.title('m=%.2f, Sphere Parameter = %.2f' %(m,x))

plt.subplot(122)
plt.plot(theta,iper,'r--')
plt.plot(theta,ipar,'b:')
plt.plot(theta,iun,'k')

plt.xlabel('Exit Angle [degrees]')
plt.ylabel('Normalized Scattered light [1/sr]')
plt.title('m=%.2f, Sphere Parameter = %.2f' %(m,x))
plt.ylim(0.00,0.125)
plt.text(130,0.02,r"$0.5I_{per}$",color="blue", fontsize=16)
plt.text(120,0.062,r"$0.5I_{par}$",color="red", fontsize=16)
plt.text(30,0.11,r"$I_{unpolarized}$",color="black", fontsize=16)

plt.show()
```

## Differential Scattering Cross Section

Sometimes one would like the scattering function normalized so that the integral over all $4\pi$ steradians to be the scattering cross section

$$
\sigma_{sca} = \frac{\pi d^2}{4} Q_{sca}
$$

The *differential scattering cross section* \frac{d\sigma_{sca}}{d\Omega}

$$
\sigma_{sca} = \int_{4\pi} \frac{d\sigma_{sca}}{d\Omega}\,d\Omega
$$

Since the unpolarized scattering normalized so its integral is the single scattering albedo, this means that

$$
\frac{Q_{sca}}{Q_{ext}} = \int_{4\pi} p(\mu) \sin\theta\,d\theta d\phi
$$

and therefore the differential scattering cross section can be obtained `miepython` using

$$
\frac{d\sigma_{sca}}{d\Omega} = \frac{\pi d^2 Q_{ext}}{4} \cdot p(\theta,\phi)
$$

Note that this is $Q_{ext}$ and *not* $Q_{sca}$ because of the choice of normalization!

For example, here is a replica of [figure 4](http://plaza.ufl.edu/dwhahn/Rayleigh%20and%20Mie%20Light%20Scattering.pdf)

```python
m = 1.4-0j
lambda0 = 532e-9  # m
theta = np.linspace(0,180,1000)
mu = np.cos(theta* np.pi/180)

d = 1700e-9        # m
x = 2 * np.pi/lambda0 * d/2
geometric_cross_section = np.pi * d**2/4 * 1e4  # cm**2
qext, qsca, qback, g = miepython.mie(m,x)
sigma_sca = geometric_cross_section * qext * miepython.i_unpolarized(m,x,mu)
plt.semilogy(theta, sigma_sca*1e-3, color='blue')
plt.text(15, sigma_sca[0]*3e-4, "%.0fnm\n(x10$^{-3}$)" % (d*1e9), color='blue')

d = 170e-9         # m
x = 2 * np.pi/lambda0 * d/2
geometric_cross_section = np.pi * d**2/4 * 1e4  # cm**2
qext, qsca, qback, g = miepython.mie(m,x)
sigma_sca = geometric_cross_section * qext * miepython.i_unpolarized(m,x,mu)
plt.semilogy(theta, sigma_sca, color='red')
plt.text(110, sigma_sca[-1]/2, "%.0fnm" % (d*1e9), color='red')

d = 17e-9                                                       # m
x = 2 * np.pi/lambda0 * d/2
geometric_cross_section = np.pi * d**2/4 * 1e4                 # cm**2
qext, qsca, qback, g = miepython.mie(m,x)
sigma_sca = geometric_cross_section * qext * miepython.i_unpolarized(m,x,mu)
plt.semilogy(theta, sigma_sca*1e6, color='green')
plt.text(130, sigma_sca[-1]*1e6, "(x10$^6$)\n%.0fnm" % (d*1e9), color='green')

plt.title("Refractive index m=1.4, $\lambda$=532nm")
plt.xlabel("Scattering Angle (degrees)")
plt.ylabel("Diff. Scattering Cross Section (cm$^2$/sr)")
plt.grid(True)
plt.show()
```

## Normalization revisited

### Evenly spaced $\mu=\cos\theta$

Start with uniformly distributed scattering angles that are evenly spaced over the cosine of the scattered angle.

#### Verifying normalization numerically

Specifically, to ensure proper normalization, the integral of the scattering function over all solid angles must be unity

$$
a = \int_0^{2\pi}\int_0^\pi \, p(\theta,\phi)\,\sin\theta\,d\theta\,d\phi
$$

or with a change of variables $\mu=\cos\theta$ and using the symmetry to the integral in $\phi$

$$
a = 2\pi \int_{-1}^1 \, p(\mu)\,d\mu
$$

This integral can be done numerically by simply summing all the rectangles

$$
a = 2\pi \sum_{i=0}^N p(\mu_i)\,\Delta\mu_i
$$

and if all the rectanges have the same width

$$
a = 2\pi\Delta\mu \sum_{i=0}^N p(\mu_i)
$$

#### Case 1. n=1.5, x=1

The total integral `total=` in the title should match the albedo `a=`.

For this non-strongly peaked scattering function, the simple integration remains close to the expected value.

```python
m = 1.5
x = 1
mu = np.linspace(-1,1,501)
intensity = miepython.i_unpolarized(m,x,mu)
qext, qsca, qback, g = miepython.mie(m,x)
a = qsca/qext

#integrate over all angles
dmu = mu[1] - mu[0]
total = 2 * np.pi * dmu * np.sum(intensity)

plt.plot(mu,intensity)
plt.xlabel(r'$\cos(\theta)$')
plt.ylabel('Unpolarized Scattering Intensity [1/sr]')
plt.title('m=%.3f%+.3fj, x=%.2f, a=%.3f, total=%.3f'%(m.real,m.imag,x,a, total))
plt.show()
```

#### Case 2: m=1.5-1.5j, x=1

Aagin the total integral `total=` in the title should match the albedo `a=`.

For this non-strongly peaked scattering function, the simple integration remains close to the expected value.

```python
m = 1.5 - 1.5j
x = 1
mu = np.linspace(-1,1,501)
intensity = miepython.i_unpolarized(m,x,mu)
qext, qsca, qback, g = miepython.mie(m,x)
a = qsca/qext

#integrate over all angles
dmu = mu[1] - mu[0]
total = 2 * np.pi * dmu * np.sum(intensity)

plt.plot(mu,intensity)
plt.xlabel(r'$\cos(\theta)$')
plt.ylabel('Unpolarized Scattering Intensity [1/sr]')
plt.title('m=%.3f%+.3fj, x=%.2f, a=%.3f, total=%.3f'%(m.real,m.imag,x,a, total))
plt.show()
```

## Normalization, evenly spaced $\theta$

The total integral total in the title should match the albedo $a$.

For this non-strongly peaked scattering function, even spacing in $\theta$ improves the accuracy of the integration.

```python
m = 1.5-1.5j
x = 1
theta = np.linspace(0,180,361)*np.pi/180
mu = np.cos(theta)

intensity = miepython.i_unpolarized(m,x,mu)
qext, qsca, qback, g = miepython.mie(m,x)
a = qsca/qext

#integrate over all angles
dtheta = theta[1]-theta[0]
total = 2 * np.pi * dtheta * np.sum(intensity* np.sin(theta))

plt.plot(mu,intensity)
plt.xlabel(r'$\cos(\theta)$')
plt.ylabel('Unpolarized Scattering Intensity [1/sr]')
plt.title('m=%.3f%+.3fj, x=%.2f, a=%.3f, total=%.3f'%(m.real,m.imag,x,a, total))
plt.show()

```

## Comparison to Wiscombe's Mie Program

Wiscombe normalizes as

$$
\int_{4\pi} p(\theta,\phi) \,d\Omega = \pi x^2  Q_{sca}
$$

where $p(\theta)$ is the scattered light.

Once corrected for differences in phase function normalization, Wiscombe's test cases match those from `miepython` exactly.

### Wiscombe's Test Case 14

```python
"""
MIEV0 Test Case 14:  Refractive index:  real    1.500  imag  -1.000E+00,  Mie size parameter =     1.000
  Angle    Cosine           S-sub-1                     S-sub-2               Intensity  Deg of Polzn
   0.00  1.000000   5.84080E-01   1.90515E-01   5.84080E-01   1.90515E-01   3.77446E-01        0.0000
  30.00  0.866025   5.65702E-01   1.87200E-01   5.00161E-01   1.45611E-01   3.13213E-01       -0.1336
  60.00  0.500000   5.17525E-01   1.78443E-01   2.87964E-01   4.10540E-02   1.92141E-01       -0.5597
  90.00  0.000000   4.56340E-01   1.67167E-01   3.62285E-02  -6.18265E-02   1.20663E-01       -0.9574
"""

x=1.0
m=1.5-1.0j
mu=np.cos(np.linspace(0,90,4) * np.pi/180)

qext, qsca, qback, g = miepython.mie(m,x)
albedo = qsca/qext
unpolar = miepython.i_unpolarized(m,x,mu) # normalized to a
unpolar /= albedo                  # normalized to 1

unpolar_miev = np.array([3.77446E-01,3.13213E-01,1.92141E-01,1.20663E-01])
unpolar_miev /= np.pi * qsca * x**2                         # normalized to 1
ratio = unpolar_miev/unpolar

print("MIEV0 Test Case 14:  m=1.500-1.000j,  Mie size parameter =     1.000")
print()
print("             %9.1f°%9.1f°%9.1f°%9.1f°"%(0,30,60,90))
print("MIEV0        %9.5f %9.5f %9.5f %9.5f"%(unpolar_miev[0],unpolar_miev[1],unpolar_miev[2],unpolar_miev[3]))
print("miepython    %9.5f %9.5f %9.5f %9.5f"%(unpolar[0],unpolar[1],unpolar[2],unpolar[3]))
print("ratio        %9.5f %9.5f %9.5f %9.5f"%(ratio[0],ratio[1],ratio[2],ratio[3]))
```

### Wiscombe's Test Case 10

```python
"""
MIEV0 Test Case 10:  Refractive index:  real    1.330  imag  -1.000E-05,  Mie size parameter =   100.000
  Angle    Cosine           S-sub-1                     S-sub-2               Intensity  Deg of Polzn
   0.00  1.000000   5.25330E+03  -1.24319E+02   5.25330E+03  -1.24319E+02   2.76126E+07        0.0000
  30.00  0.866025  -5.53457E+01  -2.97188E+01  -8.46720E+01  -1.99947E+01   5.75775E+03        0.3146
  60.00  0.500000   1.71049E+01  -1.52010E+01   3.31076E+01  -2.70979E+00   8.13553E+02        0.3563
  90.00  0.000000  -3.65576E+00   8.76986E+00  -6.55051E+00  -4.67537E+00   7.75217E+01       -0.1645
"""

x=100.0
m=1.33-1e-5j
mu=np.cos(np.linspace(0,90,4) * np.pi/180)

qext, qsca, qback, g = miepython.mie(m,x)
albedo = qsca/qext
unpolar = miepython.i_unpolarized(m,x,mu) # normalized to a
unpolar /= albedo                  # normalized to 1

unpolar_miev = np.array([2.76126E+07,5.75775E+03,8.13553E+02,7.75217E+01])
unpolar_miev /= np.pi * qsca * x**2                         # normalized to 1
ratio = unpolar_miev/unpolar

print("MIEV0 Test Case 10:  m=1.330-0.00001j,  Mie size parameter =     100.000")
print()
print("             %9.1f°%9.1f°%9.1f°%9.1f°"%(0,30,60,90))
print("MIEV0        %9.5f %9.5f %9.5f %9.5f"%(unpolar_miev[0],unpolar_miev[1],unpolar_miev[2],unpolar_miev[3]))
print("miepython    %9.5f %9.5f %9.5f %9.5f"%(unpolar[0],unpolar[1],unpolar[2],unpolar[3]))
print("ratio        %9.5f %9.5f %9.5f %9.5f"%(ratio[0],ratio[1],ratio[2],ratio[3]))
```

### Wiscombe's Test Case 7

```python
"""
MIEV0 Test Case  7:  Refractive index:  real    0.750  imag   0.000E+00,  Mie size parameter =    10.000
  Angle    Cosine           S-sub-1                     S-sub-2               Intensity  Deg of Polzn
   0.00  1.000000   5.58066E+01  -9.75810E+00   5.58066E+01  -9.75810E+00   3.20960E+03        0.0000
  30.00  0.866025  -7.67288E+00   1.08732E+01  -1.09292E+01   9.62967E+00   1.94639E+02        0.0901
  60.00  0.500000   3.58789E+00  -1.75618E+00   3.42741E+00   8.08269E-02   1.38554E+01       -0.1517
  90.00  0.000000  -1.78590E+00  -5.23283E-02  -5.14875E-01  -7.02729E-01   1.97556E+00       -0.6158
"""

x=10.0
m=0.75
mu=np.cos(np.linspace(0,90,4) * np.pi/180)

qext, qsca, qback, g = miepython.mie(m,x)
albedo = qsca/qext
unpolar = miepython.i_unpolarized(m,x,mu) # normalized to a
unpolar /= albedo                  # normalized to 1

unpolar_miev = np.array([3.20960E+03,1.94639E+02,1.38554E+01,1.97556E+00])
unpolar_miev /= np.pi * qsca * x**2                          # normalized to 1
ratio = unpolar_miev/unpolar

print("MIEV0 Test Case 7:  m=0.75,  Mie size parameter =     10.000")
print()
print("             %9.1f°%9.1f°%9.1f°%9.1f°"%(0,30,60,90))
print("MIEV0        %9.5f %9.5f %9.5f %9.5f"%(unpolar_miev[0],unpolar_miev[1],unpolar_miev[2],unpolar_miev[3]))
print("miepython    %9.5f %9.5f %9.5f %9.5f"%(unpolar[0],unpolar[1],unpolar[2],unpolar[3]))
print("ratio        %9.5f %9.5f %9.5f %9.5f"%(ratio[0],ratio[1],ratio[2],ratio[3]))
```

## Comparison to Bohren & Huffmans's Mie Program

Bohren & Huffman normalizes as

$$
\int_{4\pi} p(\theta,\phi) \,d\Omega = 4 \pi x^2  Q_{sca}
$$

### Bohren & Huffmans's Test Case 14

```python
"""
BHMie Test Case 14, Refractive index = 1.5000-1.0000j, Size parameter = 1.0000

Angle  Cosine              S1                        S2
0.00   1.0000  -8.38663e-01 -8.64763e-01  -8.38663e-01 -8.64763e-01
0.52   0.8660  -8.19225e-01 -8.61719e-01  -7.21779e-01 -7.27856e-01
1.05   0.5000  -7.68157e-01 -8.53697e-01  -4.19454e-01 -3.72965e-01
1.57   0.0000  -7.03034e-01 -8.43425e-01  -4.44461e-02  6.94424e-02
"""

x=1.0
m=1.5-1j
mu=np.cos(np.linspace(0,90,4) * np.pi/180)

qext, qsca, qback, g = miepython.mie(m,x)
albedo = qsca/qext
unpolar = miepython.i_unpolarized(m,x,mu) # normalized to a
unpolar /= albedo                  # normalized to 1

s1_bh = np.empty(4,dtype=np.complex)
s1_bh[0] = -8.38663e-01 - 8.64763e-01*1j
s1_bh[1] = -8.19225e-01 - 8.61719e-01*1j
s1_bh[2] = -7.68157e-01 - 8.53697e-01*1j
s1_bh[3] = -7.03034e-01 - 8.43425e-01*1j

s2_bh = np.empty(4,dtype=np.complex)
s2_bh[0] = -8.38663e-01 - 8.64763e-01*1j
s2_bh[1] = -7.21779e-01 - 7.27856e-01*1j
s2_bh[2] = -4.19454e-01 - 3.72965e-01*1j
s2_bh[3] = -4.44461e-02 + 6.94424e-02*1j

# BHMie seems to normalize their intensities to 4 * pi * x**2 * Qsca
unpolar_bh = (abs(s1_bh)**2+abs(s2_bh)**2)/2
unpolar_bh /= np.pi * qsca * 4 * x**2                          # normalized to 1
ratio = unpolar_bh/unpolar

print("BHMie Test Case 14:  m=1.5000-1.0000j, Size parameter = 1.0000")
print()
print("             %9.1f°%9.1f°%9.1f°%9.1f°"%(0,30,60,90))
print("BHMIE        %9.5f %9.5f %9.5f %9.5f"%(unpolar_bh[0],unpolar_bh[1],unpolar_bh[2],unpolar_bh[3]))
print("miepython    %9.5f %9.5f %9.5f %9.5f"%(unpolar[0],unpolar[1],unpolar[2],unpolar[3]))
print("ratio        %9.5f %9.5f %9.5f %9.5f"%(ratio[0],ratio[1],ratio[2],ratio[3]))
print()
print("Note that this test is identical to MIEV0 Test Case 14 above.")
print()
print("Wiscombe's code is much more robust than Bohren's so I attribute errors all to Bohren")
```

### Bohren & Huffman, water droplets

Tiny water droplet (0.26 microns) in clouds has pretty strong forward scattering!  A graph of this is figure 4.9 in Bohren and Huffman's *Absorption and Scattering of Light by Small Particles*.

A bizarre scaling factor of $16\pi$ is needed to make the `miepython` results match those in the figure 4.9.

```python
x=3
m=1.33-1e-8j

theta = np.linspace(0,180,181)
mu = np.cos(theta*np.pi/180)

scaling_factor = 16*np.pi
iper = scaling_factor*miepython.i_per(m,x,mu)
ipar = scaling_factor*miepython.i_par(m,x,mu)

P = (iper-ipar)/(iper+ipar)

plt.subplots(2,1,figsize=(8,8))
plt.subplot(2,1,1)
plt.semilogy(theta,ipar,label='$i_{par}$')
plt.semilogy(theta,iper,label='$i_{per}$')
plt.xlim(0,180)
plt.xticks(range(0,181,30))
plt.ylabel('i$_{par}$ and i$_{per}$')
plt.legend()
plt.title('Figure 4.9 from Bohren & Huffman')
plt.subplot(2,1,2)
plt.plot(theta,P)
plt.ylim(-1,1)
plt.xticks(range(0,181,30))
plt.xlim(0,180)
plt.ylabel('Polarization')
plt.plot([0,180],[0,0],':k')
plt.xlabel('Angle (Degrees)')
plt.show()
```

## van de Hulst Comparison

This graph (see figure 29 in *Light Scattering by Small Particles*) was obviously constructed by hand.  In this graph, van de Hulst worked hard to get as much information as possible

```python
x=5
m=10000

theta = np.linspace(0,180,361)
mu = np.cos(theta*np.pi/180)

fig, ax = plt.subplots(figsize=(8,8))

x=10
s1,s2 = miepython.mie_S1_S2(m,x,mu)
sone = 2.5*abs(s1)
stwo = 2.5*abs(s2)
plt.plot(theta,sone,'b')
plt.plot(theta,stwo,'--r')
plt.annotate('x=%.1f  '%x,xy=(theta[-1],sone[-1]),ha='right',va='bottom')

x=5
s1,s2 = miepython.mie_S1_S2(m,x,mu)
sone = 2.5*abs(s1) + 1
stwo = 2.5*abs(s2) + 1
plt.plot(theta,sone,'b')
plt.plot(theta,stwo,'--r')
plt.annotate('x=%.1f  '%x,xy=(theta[-1],sone[-1]),ha='right',va='bottom')

x=3
s1,s2 = miepython.mie_S1_S2(m,x,mu)
sone = 2.5*abs(s1) + 2
stwo = 2.5*abs(s2) + 2
plt.plot(theta,sone,'b')
plt.plot(theta,stwo,'--r')
plt.annotate('x=%.1f  '%x,xy=(theta[-1],sone[-1]),ha='right',va='bottom')

x=1
s1,s2 = miepython.mie_S1_S2(m,x,mu)
sone = 2.5*abs(s1) + 3
stwo = 2.5*abs(s2) + 3
plt.plot(theta,sone,'b')
plt.plot(theta,stwo,'--r')
plt.annotate('x=%.1f  '%x,xy=(theta[-1],sone[-1]),ha='right',va='bottom')

x=0.5
s1,s2 = miepython.mie_S1_S2(m,x,mu)
sone = 2.5*abs(s1) + 4
stwo = 2.5*abs(s2) + 4
plt.plot(theta,sone,'b')
plt.plot(theta,stwo,'--r')
plt.annotate('x=%.1f  '%x,xy=(theta[-1],sone[-1]),ha='right',va='bottom')

plt.xlim(0,180)
plt.ylim(0,5.5)
plt.xticks(range(0,181,30))
plt.yticks(np.arange(0,5.51,0.5))
plt.title('Figure 29 from van de Hulst, Non-Absorbing Spheres')
plt.xlabel('Angle (Degrees)')
ax.set_yticklabels(['0','1/2','0','1/2','0','1/2','0','1/2','0','1/2','5',' '])
plt.grid(True)
plt.show()
```

## Comparisons with Kerker, Angular Gain

Another interesting graph is figure 4.51 from [*The Scattering of Light* by Kerker](https://www.sciencedirect.com/book/9780124045507/the-scattering-of-light-and-other-electromagnetic-radiation).

The angular gain is
$$
G_1 = \frac{4}{x^2} |S_1(\theta)|^2
\qquad\mbox{and}\qquad
G_2 = \frac{4}{x^2} |S_2(\theta)|^2
$$

```python
## Kerker, Angular Gain

x=1
m=10000

theta = np.linspace(0,180,361)
mu = np.cos(theta*np.pi/180)

fig, ax = plt.subplots(figsize=(8,8))

s1,s2 = miepython.mie_S1_S2(m,x,mu)

G1 = 4*abs(s1)**2/x**2
G2 = 4*abs(s2)**2/x**2

plt.plot(theta,G1,'b')
plt.plot(theta,G2,'--r')
plt.annotate('$G_1$',xy=(50,0.36),color='blue',fontsize=14)
plt.annotate('$G_2$',xy=(135,0.46),color='red',fontsize=14)

plt.xlim(0,180)
plt.xticks(range(0,181,30))
plt.title('Figure 4.51 from Kerker, Non-Absorbing Spheres, x=1')
plt.xlabel('Angle (Degrees)')
plt.ylabel('Angular Gain')
plt.show()
```

```python

```


# Non-linear equations: introduction

This notebook is based on Chapter 1 of

> Süli, Endre and Mayers, David F. _An introduction to numerical analysis_. Cambridge University Press, Cambridge, 2003.
<https://doi.org/10.1017/CBO9780511801181>
(ebook in [Helka](https://helka.helsinki.fi/permalink/358UOH_INST/1h3k2rg/alma9926836783506253))

In addition, some examples are taken from Chapters 1 and 2 of

> Scott, L. Ridgway.
_Numerical analysis_. Princeton University Press, Princeton, NJ, 2011.

The equation $x^2 = 2$ has two solutions $x = \pm \sqrt{2}$, but how do we compute an approximation to $\sqrt{2}$ as a floating-point number?

This problem has a long tradition, see the Babylonian clay tablet [YBC 7289](https://en.wikipedia.org/wiki/YBC_7289).
Perhaps the first algorithm used for approximating $\sqrt{q}$, with $q > 0$, is the [Babylonian method](https://en.wikipedia.org/wiki/Methods_of_computing_square_roots#Babylonian_method):

1. Start with an initial guess $x_0$
2. Set
$$
x_{n+1} = \frac 1 2 (x_n + \frac q {x_n})
$$
3. Repeat step 2 until the desired accuracy is achieved

```python
def babylonian_method(x, q):
    return 0.5*(x + q/x)
```

```python
import numpy as np
max_steps = 5
xs = np.zeros(max_steps)
x = 1.5
for n in range(max_steps):
    x = babylonian_method(x, 2)
    xs[n] = x
xs
```

```python
xtrue = np.sqrt(2)
errs = np.abs(xs - xtrue)

import pandas as pd
df = pd.DataFrame({
    'x': xs,
    'error': errs,
    })
df.index = range(1, max_steps+1)
df.index.name = 'n'
df.style.format({'error': '{:.1e}'})
```

# Fixed-point iteration

The Babylonian method is a fixed point iteration of the form

$$
x_{n+1} = f(x_n), \qquad n=0,1,\dots
$$

If the sequence $x_n$ converges to a point $\xi$ and $f$ is continuous, then $\xi$ is a fixed point of $f$, that is, $\xi = f(\xi)$. Indeed

$$
\xi = \lim_{n \to \infty} x_{n+1} = \lim_{n \to \infty} f(x_n) = f(\xi).
$$

Note that while the Gaussian elimination terminates after a finite number of steps, a fixed point iteration may require infinite number of steps to converge.

In the case of the Babylonian method

$$
f(x) = \frac 1 2 (x + \frac q {x})
$$

and
$$
\xi = f(\xi)
\quad\iff\quad
\xi = \frac q {\xi}
\quad\iff\quad
\xi^2 = q
\quad\iff\quad
\xi = \pm \sqrt{q}.
$$

One may wonder if the simpler choice $f(x) = \frac q {x}$ would work as well.
After all $\xi = f(\xi)$ is equivalent to $\xi = \pm \sqrt{q}$ also for this $f$.

```python
def non_method(x, q):
    return q / x

max_steps = 5
xs = np.zeros(max_steps)
x = 1.5 # initial guess
for n in range(max_steps):
    x = non_method(x, 2)
    xs[n] = x
xs
```

Consider a closed interval $I = [a,b]$ with $a < b$.
A function $f : I \to \mathbb R$ is a _contraction_ if there is $0 < L < 1$ such that

\begin{equation}\tag{1}
|f(x) - f(y)| \le L |x - y|, \qquad x,y \in I.
\end{equation}

The [mean value theorem](https://en.wikipedia.org/wiki/Mean_value_theorem) implies that (1) holds with $L = \max_{x \in I} |f'(x)|$ whenever $f$ is differentiable.

## Theorem: Banach fixed-point

> Suppose that $f : I \to I$ is a contraction. Then there is a unique fixed point $\xi \in I$ of $f$, and the sequence $x_{n+1} = f(x_{n})$ converges to $\xi$ for any initial guess $x_0 \in I$.

For a proof, see Theorem 1.3 of [the book](#thebook).

## Theorem: local convergence

> Let $\xi$ be a fixed point of $f : \mathbb R \to \mathbb R$ and suppose that $f$ is continuously differentiable near $\xi$. If $|f'(\xi)| < 1$, then there is an open interval $I$ containing $\xi$ such that the sequence $x_{n+1} = f(x_{n})$ converges to $\xi$ for any initial guess $x_0 \in I$.

_Proof_. The continuity of $f'$ near $\xi$, together with $|f'(\xi)| < 1$, implies that there are $\epsilon > 0$ and $0 <\delta < 1$ such that

$$
|f'(x)| \le \delta, \qquad x \in (\xi - \epsilon, \xi + \epsilon) =: I.
$$

The mean value theorem implies

$$
|f(x) - f(y)| \le L |x - y|, \qquad x,y \in I,
$$

with $L = \delta$. In particular,

$$
|f(x) - \xi| = |f(x) - f(\xi)| \le \delta |x - \xi| < \delta \epsilon < \epsilon, \qquad x \in I.
$$

Hence $f$ maps $I$ to itself and we can apply the Banach fixed-point theorem. $\blacksquare$

In the case of the Babylonian method

$$
f(x) = \frac 1 2 (x + \frac q {x})
\quad \text{and} \quad
f'(x) = \frac 1 2 (1 - \frac q {x^2}).
$$

Thus $f'(\xi) = 0$ at the fixed point $\xi = \sqrt{q}$,
and the method converges locally (that is, for an initial guess near $\xi$).

On the other hand, in the case of the "non-method"

$$
f(x) = \frac q {x},
\quad \text{and} \quad
f'(x) = - \frac q {x^2}.
$$

Thus $f'(\xi) = -1$ at $\xi = \sqrt{q}$,
and $f$ is **not** a contraction on any open interval containing $\xi$.

## Definition: order of convergence

> Suppose that a sequence $x_n$ converges to $\xi$ in $\mathbb R$.
If there are $p > 1$ and $\mu > 0$ such that
>
>\begin{align}
\tag{1}
\lim_{n \to \infty} \frac{|x_{n+1} - \xi|}{|x_{n} - \xi|^p} = \mu,
\end{align}
>
> then $x_n$ is said to converge with order $p$.
If (1) holds with $p = 1$ and $0 < \mu < 1$,
then $x_n$ is said to converge linearly.
Finally, if (1) holds with $p = 1$ and $\mu = 1$,
then $x_n$ is said to converge sublinearly.

## Example: different orders of convergence

Let $\lambda \in (0,1)$. Then

* sequence $x_n = \lambda^n$ converges linearly to zero,
* sequence $x_n = \lambda^{2^n}$ converges quadratically (that is, with order 2) to zero,
* sequence $x_n = 1/n$ converges sublinearly to zero,
* sequence $x_n = \lambda^{n!}$ converges superpolynomially to zero in the sense that for all $p > 1$

$$
\frac{x_{n+1}}{x_n^p} \to 0.
$$

```python
from matplotlib import pyplot as plt

lam = 0.5
def f(n):
    return lam**n

N = 10
ns = np.arange(1, N+1)
plt.semilogy(ns, f(ns));

```

## Theorem: local _linear_ convergence

> Let $f$, $\xi$ and $I$ be as in the local convergence theorem.
Suppose that $f'(\xi) \ne 0$. Then the sequence $x_{n+1} = f(x_{n})$ converges linearly to $\xi$ for any initial guess $x_0 \in I$.

_Proof_. The _local convergence_ theorem implies that $x_n \to \xi$. The mean value theorem implies that there is $\tilde x_n$ between $\xi$ and $x_n$ such that

$$
f(x_n) - f(\xi) = f'(\tilde x_n)(x_n - \xi).
$$

As $x_n \to \xi$, also $\tilde x_n \to \xi$. Recalling that $f'$ is continuous near $\xi$, we have

$$
\frac{|x_{n+1} - \xi|}{|x_{n} - \xi|}
=
\frac{|f(x_n) - f(\xi)|}{|x_{n} - \xi|}
=
|f'(\tilde x_n)| \to |f'(\xi)| =: \mu.
$$

To conclude, we observe that $0 < \mu < 1$.
$\blacksquare$

## Theorem: local _higher-order_ convergence

> Let $f$, $\xi$ and $I$ be as in the local convergence theorem.
Suppose that $f$ has continuous derivatives up to order $p \ge 2$ near $\xi$,
and that $f'(\xi) = \dots = f^{(p - 1)}(\xi) = 0$
and $f^{(p)}(\xi) \ne 0$. Then the sequence $x_{n+1} = f(x_{n})$ converges with order $p$ to $\xi$ for any initial guess $x_0 \in I$.

_Proof_. The _local convergence_ theorem implies that $x_n \to \xi$.
[Taylor's theorem](https://en.wikipedia.org/wiki/Taylor's_theorem#Explicit_formulas_for_the_remainder), with Lagrange form of the remainder, says that there is $\tilde x_n$ between $\xi$ and $x_n$ such that

$$
f(x_n) - f(\xi) = \frac{f^{(p)}(\tilde x_n)}{p!}(x_n - \xi)^p.
$$

As $x_n \to \xi$, also $\tilde x_n \to \xi$. Recalling that $f^{(p)}$ is continuous near $\xi$, we have

$$
\frac{|x_{n+1} - \xi|}{|x_{n} - \xi|^p}
=
\frac{|f(x_n) - f(\xi)|}{|x_{n} - \xi|^p}
=
\frac{|f^{(p)}(\tilde x_n)|}{p!} \to \frac{|f^{(p)}(\xi)|}{p!} \ne 0.
$$
$\blacksquare$

# Relaxation and Newton's method

The problem to solve $\phi(x) = 0$ can be rewritten as the problem to find a fixed point $x = f(x)$.

Indeed, these two problems are equivalent if
$f(x) = x - \phi(x)$.

## Theorem: relaxation

> Let $\phi : \mathbb R \to \mathbb R$ be continuously differentiable near a point $\xi \in \mathbb R$.
Suppose that $\phi(\xi) = 0$ and $\phi'(\xi) > 0$. Then there
are an open interval $I$ containing $\xi$ and $\lambda > 0$ such that the relaxation iteration
>
> $$
x_{n+1} = x_n - \lambda \phi(x_n), \quad n=0,1\dots
$$
>
> converges to $\xi$ for any initial guess $x_0 \in I$.

If instead $\phi(\xi) = 0$ and $\phi'(\xi) < 0$, then we can apply the theorem to $-\phi$.

_Proof_. The function $f(x) = x - \lambda \phi(x)$ satisfies

$$
-1 < f'(\xi) = 1 - \lambda \phi'(\xi) < 1
$$

for small $\lambda > 0$, and we can apply the _local convergence_ theorem.
$\blacksquare$

Note that the choice $\lambda = 1/\phi'(\xi)$ leads to $f'(\xi) = 0$
and gives a method with at least quadratic convergence.
Of course, we typically don't know $\xi$ (we are solving for it), and hence can not make this optimal choice in practice.

Let's apply relaxation to the "non-method" $f(x) = q / x$. In this case

$$
\phi(x) = x - \frac q x, \quad \phi'(x) = 1 + \frac q {x^2}.
$$

In particular, $\phi'(\xi) = 2$ at $\xi = \sqrt{q}$.
Taking $\lambda = 1/\phi'(\xi) = 1/2$ gives the Babylonian method

$$
x_{n+1} = x_n - \frac 1 2 \phi(x_n) = \frac 1 2 (x_n + \frac q {x_n}).
$$

Newton's method can be viewed as a generalization of the relaxation iteration where we let $\lambda$ to be non-constant, and take $\lambda = 1/\phi'(x_n)$:

$$
x_{n+1} = x_n - \frac {\phi(x_n)}{\phi'(x_n)}, \quad n=0,1\dots
$$

Put differently, we take $\phi'(x_n)$ as a proxy of $\phi'(\xi)$. Another, way to arrive to Newton's method is to replace $\phi(x)$ in the equation $\phi(x) = 0$
by its first order Taylor polynomial at $x = x_n$, that is,

$$
0 = \phi(x) \approx \phi(x_n) + \phi'(x_n) (x - x_n).
$$

Then solving for $x$ gives $x = x_{n+1}$ with $x_{n+1}$ as above.

Let us apply Newton's method to the equation $x^2 = q$ and take $\phi(x) = x^2 - q$.
Then

$$
x_{n+1} = x_n - \frac {x_n^2 - q} {2 x_n} = \frac 1 2 (x_n + \frac q {x_n}),
$$

the Babylonian method once again.

## Theorem: convergence of Newton's method

>Suppose that $\phi : \mathbb R \to \mathbb R$ has continuous derivatives up to order 3 near a point $\xi \in \mathbb R$, and that $\phi(\xi) = 0$ and $\phi'(\xi) \ne 0$. Then there
is an open interval $I$ containing $\xi$ such that Newton's method
>
>$$
x_{n+1} = x_n - \frac {\phi(x_n)}{\phi'(x_n)}, \quad n=0,1\dots
$$
>
>converges at least quadratically to $\xi$ for any initial guess $x_0 \in I$.

_Proof_. The function

$$
f(x) =  x - \frac {\phi(x)}{\phi'(x)}
$$

satisfies $f'(\xi) = 0$, and we can apply the _local higher-order convergence_ theorem.
$\blacksquare$

A slightly sharper proof shows that the assumptions can be weakened to $\phi$ having continuous derivatives up to order 2 near $\xi$, see Theorem 1.8 of the [the book](#thebook).

## Example: Kepler's equation

In orbital mechanics, [Kepler's equation](https://en.wikipedia.org/wiki/Kepler%27s_equation) relates various geometric properties of the orbit of a body subject to a central force. It reads

$$
M = E - e \sin E
$$

where $M$ is the mean anomaly, $E$ is the eccentric anomaly, and $e$ is the eccentricity.

The first published use by Newton of his eponymous method in an iterative form, and applied to a nonpolynomial equation, is in the second and third editions of
his _Philosophiae Naturalis Principia Mathematica_, where it is applied to Kepler's equation. For more details, see Section 6 of

>Ypma, Tjalling J.
_Historical development of the Newton-Raphson method_.
SIAM Rev. 37 (1995), no. 4, 531–551.
<https://doi.org/10.1137/1037125>

Let us consider the following calculation that is reproduced from p. 148 of

> Duffett-Smith, Peter and Zwart, Jonathan. _Practical Astronomy with your Calculator or Spreadsheet_. Cambridge University Press, Cambridge, UK, 2011.
(ebook in [Helka](https://helka.helsinki.fi/permalink/358UOH_INST/1h3k2rg/alma9932214283506253))

```python
# Parameters from the book by Duffett-Smith and Zwart
M = 6.108598
E0 = 5.31
e = 0.9673 # eccentricity of the orbit of Halley is given on p. 145

# Solve Kepler's equation  E - e sin(E) - M = 0

def f(E):
    return E - e*np.sin(E) - M
def fprime(E):
    return 1 - e*np.cos(E)

def newton_demo(f, x0, fprime, max_steps = 5):
    '''Newton's method with a fixed number of steps'''
    x = x0
    for n in range(max_steps):
        x = x - f(x)/fprime(x)
    return x

E = newton_demo(f, E0, fprime)
print(f'newton_demo: {E = }')

import scipy.optimize as opt
E = opt.newton(f, E0, fprime)
print(f'opt.newton: {E  = }')

```

## Example: global behavior of Newton's method

Consider

$$
f(x) = x \exp(- x^2).
$$

The only solution to $f(x) = 0$ is $x = 0$, and $f'(0) = 1$. Hence Newton's method converges starting from an initial guess $x_0$ close enough to the origin. But it does not converge if the initial guess is not good enough.

```python
def f(x):
    return x * np.exp(-x**2)

xs = np.linspace(-4, 4)
plt.plot(xs, f(xs));

x0 = 1
plt.plot([x0, x0], [-0.5, 0.5], 'r');

```

```python
def fprime(x):
    return (1 - 2*x**2) * np.exp(-x**2)

x = newton_demo(f, x0, fprime, max_steps=100)
print(f'{x0 = }, {x = }')
```

# On the optimization sub-package of SciPy

We have already seen [newton](https://docs.scipy.org/doc/scipy/reference/reference/generated/scipy.optimize.newton.html), the implementation of Newton's method in SciPy. Calling `newton` without giving the derivative makes SciPy to use the secant method, described in Section 1.5 of [the book](#thebook). The bisection method is given by [bisect](https://docs.scipy.org/doc/scipy/reference/reference/generated/scipy.optimize.bisect.html) and it is described in Section 1.6 of [the book](#thebook).

Most of the methods in the optimization sub-package are outside the scope of this course. Some of them are described in the optimization course at UH. For more information on the sub-package see the [tutorial](https://docs.scipy.org/doc/scipy/reference/tutorial/optimize.html).

```python
# Compare Newton, secant and bisection
def f(x):
    return np.exp(x) - x - 2
def fprime(x):
    return np.exp(x) - 1
x0, x1 = 1, 3

x, rres = opt.newton(f, x0, fprime, full_output=True)
print(f"     Newton's method converged to {x} in {rres.iterations} steps")
x, rres = opt.newton(f, x0, x1=x1, full_output=True)
print(f'   The secant method converged to {x} in {rres.iterations} steps')
x, rres = opt.bisect(f, x0, x1, full_output=True)
print(f"The bisection method converged to {x} in {rres.iterations} steps")
```

```python

```


# Clase 3b: Perfil de Yukovski

_Aunque no te lo creas, __con lo que hemos visto hasta ahora eres capaz de hacer grandes cosas__. Vale sí, un perfil de Yukovski no es gran cosa aerodinámicamente, pero si lo hacemos en Python... Echa un vistazo a la figura ¿no está mal, no? algo así intentaremos conseguir al final de esta clase._

_Como no se trata de aprender (o reaprender) aerodinámica, te daremos las funciones matemáticas y los pasos a seguir así como la estructura del programa. Tú sólo tienes que preocuparte de programar cada bloque. Puedes leer en detalle todo lo relativo a la aerodinámica en el libro  Aerodinámica básica de Meseguer Ruiz, J., Sanz Andrés, A. (Editorial Garceta)._

## 1. Importamos paquetes.

Lo primero es lo primero, importemos los paquetes:

```python
# Recuerda, utilizaremos arrays y pintaremos gráficas.
```

## 2. Parámetros del problema

######
__Fuente:__ _Aerodinámica básica, Meseguer Ruiz, J., Sanz Andrés, A._

La transformación de Yukovski es: $$\tau=t+\frac{a^{2}}{t}$$

Los parámetros del problema son los del siguiente bloque, puedes cambiarlos más adelante:

```python
# Datos para el perfil de Yukovski
# Parámetro de la transformación de Yukovski
# Centro de la circunferencia
# Valor del radio de la circunferencia
# Ángulo de ataque corriente incidente
#Velocidad de la corriente incidente
```

## 3. Perfil de Yukoski a partir de una circunferencia.

### Función transformación de Yukovski

__Se trata de definir una función que realice la transformación de Yukovski.__ Esta función recibirá el parámetro de la transformación, $a$ y el punto del plano complejo $t$. Devolverá el valor $\tau$, punto del plano complejo en el que se transforma $t$.

```python

```

```python
# aeropython: preserve
#comprobamos que la función está bien programada
#puntos del eje real siguen siendo del eje real
err_message = "La transformación de Yukovski no devuelve un resultado correcto"
np.testing.assert_equal(transf_yukovski(1, 1+0j), 2+0j, err_message)
```

### Circunferencia

Ahora queremos transformar la circunferencia de radio $R$ con centro en $t_0$ usando la función anterior:

1. __Creamos `N` puntos de la circunferencia__ de modo que __en `Xc` estén las coordenadas $x$ y en `Yc` estén las coordenadas $y$__ de los puntos que la forman. Controla el número de puntos mediante un parámetro que se llame `N_perfil`.
    $$X_c = real(t_0) + R·cos(\theta)$$
    $$Y_c = imag(t_0) + R·sin(\theta)$$
2. Una vez hayas obtenido los dos arrays `Xc` e `Yc`, __píntalos mediante un `scatter`__ para comprobar que todo ha ido bien.
3. Pinta también el __centro de la circunferencia__.

Deberías obtener algo así:

```python
# Número de puntos de la circunferencia que
# vamos a transformar para obtener el perfil
#se barre un ángulo de 0 a 2 pi
#se crean las coordenadas del los puntos
#de la circunferencia
#lo visualizamos
```

```python
# aeropython: preserve
# Lo visualizamos más bonito
plt.figure("circunferencia", figsize=(5,5))
plt.title('Circunferencia', {'fontsize':20})
# Esto no tienes por qué entenderlo ahora
p = plt.Polygon(list(zip(Xc, Yc)), color="#cccccc", zorder=3)
plt.gca().add_patch(p)

plt.ylim(-1.5, 2)
plt.xlim(-2, 1.5)
plt.grid()
plt.show()
```

### Transformación de cirunferencia a perfil

Ahora estamos en condiciones de __transformar estos puntos de la circunferencia (`Xc`, `Yc`) en los del perfil (`Xp`, `Yp`)__. Para esto vamos a usar nuestra función `transf_yukovski`. Recuerda que esta función recibe y da números complejos. ¿Saldrá un perfil?

```python
#lo visualizamos
```

```python
# aeropython: preserve
#lo visualizamos más bonito
plt.figure('perfil yukovski', figsize=(10,10))
plt.title('Perfil', {'fontsize':20})

p = plt.Polygon(list(zip(Xp, Yp)), color="#cccccc", zorder=3)
plt.gca().add_patch(p)
plt.gca().set_aspect(1)

plt.xlim(-3, 3)
plt.ylim(-0.4,1)
plt.grid()

plt.show()
```

## 4. Flujo alrededor del cilindro

Para visualizar ahora el flujo alrededor del cilindro recurrimos al __potencial complejo__ de una _corriente uniforme_ que forme un ángulo $\alpha$ con el eje $x$ _en presencia de un cilindro_ (aplicando el teorema del círculo) y se añade un torbellino con la intensidad adecuada para que se cumpla la hipótesis de Kutta en el perfil:

\begin{equation}
f(t)=U_{\infty}\text{·}\left((t-t_{0})\text{·}e^{-i\alpha}+\frac{R^{2}}{t-t_{0}}\text{·}e^{i\alpha}\right)+\frac{i\Gamma}{2\pi}\text{·}ln(t-t_{0})=\Phi+i\Psi
\end{equation}

donde $\Phi$ es el potencial de velocidades y $\Psi$ es la función de corriente.

$$\Gamma = 4  \pi  a  U  (\delta + (1+\lambda)  \alpha)$$

$\Gamma$ es la circulación que hay que añadir al cilindro para que al transformarlo en el perfil se cumpla la condición de Kutta.

Recordando que la función de corriente toma un valor constante en las líneas de corriente, sabemos que: dibujando $\Psi=cte$ se puede visualizar el flujo.

__Pintaremos estas lineas de potencial constante utilizando la función `contour()`, pero antes tendremos que crear una malla circular. Esto será lo primero que hagamos:__

1. Crea un parámetro `N_R` cuyo valor sea el número de puntos que va a tener la malla en dirección radial. Desde otro punto de vista, esta parámetro es el número de círculos concéntricos que forman la malla.
2. Crea dos parámetros `R_min` y `R_max` que representen el radio mínimo y máximo entre los que se extiende la malla. El radio mínimo debe de ser el radio del círculo, porque estamos calculando el aire en el exterior del perfil.
4. La dirección tangencial necesita un sólo parámetro `N_T`, que representa el número de puntos que la malla tendrá en esta dirección. Dicho de otro modo, cuántos puntos forman los círculos concéntricos de la malla.
3. Crea un array `R_` que vaya desde `R_min` hasta `R_max` y que tenga `N_R` elementos. De manera análoga, crea el array `T_`, que al representar los ángulos de los puntos que forman las circunferencias, debe ir de 0 a 2$\pi$, y tener `N_T` elementos.
4. Para trabajar con la malla, deberemos usar coordenadas cartesianas. Crea la malla: `XX, YY` van a ser dos matrices de `N_T · N_R` elementos. Cada elemento de estas matrices se corresponde con un punto de la malla: la matriz `XX` contiene las coordenadas X de cada punto y la matriz `YY`, las coordenadas y.

La manera de generar estas matrices tiene un poco de truco, porque depende de ambos vectores.
Para cada elemento, $x = real(t_0) +  R · cos (T) $ ,  $y = imag(t_0) + R · sin(T)$.

__Recuerda que:__

* Los puntos sobre la circunferencia se transforman en el perfil.
* Los puntos interiores a la circunferencia se transforman en puntos interiores al perfil.
* Los puntos exteriores a la circunferencia se transforman en puntos exteriores al perfil. __Los puntos que nos interesan__.

```python
#se crea la malla donde se va pintar la función de corriente
# Dirección radial
# Dirección tangencial
# Crear la malla:
```

```python
#pintamos la malla para verla
```

__NOTA__: En versiones anteriores se utilizaba una malla rectangular. Esto generaba algunos problemas con los puntos interiores a la hora de pintar las líneas de corriente y los campos de velocidades y presiones. La idea de usar una malla circular está tomada de [este ejercicio](http://nbviewer.ipython.org/github/barbagroup/AeroPython/blob/master/lessons/06_Lesson06_Assignment.ipynb) del curso Aerodynamics-Hydrodynamics with Python de la [Prof. Lorena Barba](http://lorenabarba.com/).

### Probando a transformar la malla

Bueno, lo que queríamos era hacer cosas alrededor de nuestro perfil, ¿no?

Esto lo conseguiremos pintando la función $\Psi$ en los puntos `XX_tau, YY_tau` transformados de los `XX, YY` a través de la función `transf_yukovski`, recuerda que la transformación que tenemos recibe y da números complejos. Como antes, debes separar parte real e imaginaria. En la siguiente celda calcula y transforma `tt` (donde debe estar almacenada la malla en forma compleja) para obtener `XX_tau, YY_tau`.

Probemos a visualizar como se transforman los puntos de la malla primero.

```python

```

```python
# Comprobamos que los puntos exteriores a la circunferencia se transforman en los puntos exteriores del perfil
#pintamos la malla para verla
```

### Obteniendo el flujo

1. Crea una variable `T` que tenga el valor correspondiente a la circulación $\Gamma$.
2. Utilizando el array `tt`, el valor `T` y los parámetros definidos al principio (`t0, alfa, U...`) crea `f` según la fórmula de arriba (no hace falta que crees una función).
3. Guarda la parte imaginaria de esa función (función de corriente) en una variable `psi`.

```python
# aeropython: preserve
# Circulación que hay que añadir al cilindro para
# que se cumpla la hipótesis de Kutta en el perfil
T = 4 * np.pi * a * U * (delta + (1+landa) * alfa)

# Malla compleja
tt = XX + YY * 1j

# Potencial complejo
f = U * ( (tt - t0) * np.exp(-alfa *1j) + R**2 / (tt - t0) * np.exp(alfa * 1j) )
f += 1j * T / (2* np.pi) * np.log(tt - t0)

# Función de corriente
psi = np.imag(f)
```

Como la función de corriente toma un valor constante en cada línea de corriente, podemos visualizar el flujo alrededor del cilindro pintando las lineas en las que `psi` toma un valor constante. Para ello utilizaremos la función `contour()` en la malla `XX, YY`. Si no se ve nada prueba a cambiar el número de líneas y los valores máximo y mínimo de la función que se representan.

```python
#lo visualizamos
# aeropython: preserve
plt.figure('lineas de corriente', figsize=(10,10))

plt.contour(XX, YY, psi, np.linspace(-5,5,50))
plt.grid()
plt.gca().set_aspect(1)
#plt.xlim(-8, 8)
#plt.ylim(-3, 3)

plt.show()
```

```python
#ponemos el cilindro encima
# aeropython: preserve
plt.figure('flujo cilindro', figsize=(10,10))

plt.contour(XX, YY, psi, np.linspace(-5,5,50), colors=['blue', 'blue'])
plt.grid()
plt.gca().set_aspect(1)

p = plt.Polygon(list(zip(Xc, Yc)), color="#cccccc", zorder=3)
plt.gca().add_patch(p)
plt.show()
```

## 5. Flujo alrededor del perfil

```python

```

```python
#Ahora ponemos el perfil encima
# aeropython: preserve
plt.figure("flujo perfil", figsize=(12,12))

plt.contour(XX_tau, YY_tau, psi, np.linspace(-5, 5, 50), colors=['blue', 'blue'])

p = plt.Polygon(list(zip(Xp, Yp)), color="#cccccc", zorder=10)
plt.gca().add_patch(p)

plt.xlim(-8,8)
plt.ylim(-3,3)
plt.grid()
plt.gca().set_aspect(1)
plt.show()
```

## 6. Interact

__Ahora es un buen momento para jugar con todos los parámetros del problema.
~~¡Prueba a cambiarlos y ejecuta el notebook entero!~~__

__Vamos a usar un `interact`, ¿no?__

Tenemos que crear una función que haga todas las tareas: reciba los argumentos y pinte para llamar a interact con ella. No tenemos más que cortar y pegar.

```python
# aeropython: preserve
def transformacion_geometrica(a, landa, delta, N_perfil=100):
    #punto del plano complejo
    t0 = a * (-landa + delta * 1j)
    #valor del radio de la circunferencia
    R = a * np.sqrt((1 + landa)**2 + delta**2)
    #se barre un ángulo de 0 a 2 pi
    theta = np.linspace(0, 2*np.pi, N_perfil)
    #se crean las coordenadas del los puntos
    #de la circunferencia
    Xc = - a * landa + R * np.cos(theta)
    Yc =   a * delta + R * np.sin(theta)
    #se crean las coordenadas del los puntos
    #del perfil
    Puntos_perfil = transf_yukovski(a, Xc+Yc*1j)
    Xp, Yp = np.real(Puntos_perfil) , np.imag(Puntos_perfil)

    #Se pintan la cirunferencia y el perfil
    fig, ax = plt.subplots(1,2)
    fig.set_size_inches(15,15)
    p_c = plt.Polygon(list(zip(Xc, Yc)), color="#cccccc", zorder=1)
    ax[0].add_patch(p_c)
    ax[0].plot(Xc,Yc)
    ax[0].set_aspect(1)
    ax[0].set_xlim(-3, 3)
    ax[0].set_ylim(-2,2)
    ax[0].grid()

    p_p = plt.Polygon(list(zip(Xp, Yp)), color="#cccccc", zorder=1)
    ax[1].add_patch(p_p)
    ax[1].plot(Xp,Yp)
    ax[1].set_aspect(1)

    ax[1].set_xlim(-3, 3)
    ax[1].set_ylim(-2,2)
    ax[1].grid()
    plt.show()
```

```python

```

```python
# aeropython: preserve
interact(transformacion_geometrica, landa=(-1.,1, 0.01), delta=(-1.,1,0.01),
             a=(0,2.,0.1), N_perfil=(4, 200) )
```

Failed to display Jupyter Widget of type interactive.

  If you're reading this message in the Jupyter Notebook or JupyterLab Notebook, it may mean
  that the widgets JavaScript is still loading. If this message persists, it
  likely means that the widgets JavaScript library is either not installed or
  not enabled. See the Jupyter
  Widgets Documentation for setup instructions.

  If you're reading this message in another frontend (for example, a static
  rendering on GitHub or NBViewer),
  it may mean that your frontend doesn't currently support widgets.

    <function __main__.transformacion_geometrica>

```python
# aeropython: preserve
def flujo_perfil_circunferencia(landa, delta, alfa, U=1,  N_malla = 100):
    N_perfil=N_malla
    a=1
    #punto del plano complejo
    t0 = a * (-landa + delta * 1j)
    #valor del radio de la circunferencia
    R = a * np.sqrt((1 + landa)**2 + delta**2)

    #se barre un ángulo de 0 a 2 pi
    theta = np.linspace(0, 2*np.pi, N_perfil)
    #se crean las coordenadas del los puntos
    #de la circunferencia
    Xc = - a * landa + R * np.cos(theta)
    Yc =   a * delta + R * np.sin(theta)

    #se crean las coordenadas del los puntos
    #del perfil
    Puntos_perfil = transf_yukovski(a, Xc+Yc*1j)
    Xp, Yp = np.real(Puntos_perfil) , np.imag(Puntos_perfil)

    #se crea la malla donde se va pintar la función de corriente

    # Dirección radial
    N_R = N_malla//2   # Número de puntos en la dirección radial
    R_min = R
    R_max = 10

    # Dirección tangencial
    N_T = N_malla   # Número de puntos en la dirección tangencial

    R_ = np.linspace(R_min, R_max, N_R)
    T_ = np.linspace(0, 2*np.pi , N_T)

    # El menos en la XX  es para que el borde de ataque del perfil esté en la izquierda
    XX = - (R_ * np.cos(T_).reshape((-1, 1)) - np.real(t0))
    YY = R_ * np.sin(T_).reshape((-1, 1)) + np.imag(t0)

    tt = XX + YY * 1j

    alfa = np.deg2rad(alfa)
    # Circulación que hay que añadir al cilindro para
    # que se cumpla la hipótesis de Kutta en el perfil
    T = 4 * np.pi * a * U * (delta + (1+landa) * alfa)
    #Potencial complejo
    f = U * ( (tt - t0) * np.exp(-alfa *1j) + R**2 / (tt - t0) * np.exp(alfa * 1j) )
    f += 1j * T / (2* np.pi) * np.log(tt - t0)
    #Función de corriente
    psi = np.imag(f)

    Puntos_plano_tau = transf_yukovski(a, tt)
    XX_tau, YY_tau = np.real(Puntos_plano_tau) , np.imag(Puntos_plano_tau)

    #Se pinta
    fig, ax = plt.subplots(1,2)
    #lineas de corriente
    fig.set_size_inches(15,15)

    ax[0].contour(XX, YY, psi, np.linspace(-10,10,50), colors = ['blue', 'blue'])
    ax[0].grid()
    ax[0].set_aspect(1)
    p = plt.Polygon(list(zip(Xc, Yc)), color="#cccccc", zorder=10)
    ax[0].add_patch(p)
    ax[0].set_xlim(-5, 5)
    ax[0].set_ylim(-2,2)

    ax[1].contour(XX_tau, YY_tau, psi, np.linspace(-10,10,50), colors = ['blue', 'blue'])
    ax[1].grid()
    ax[1].set_aspect(1)
    p = plt.Polygon(list(zip(Xp, Yp)), color="#cccccc", zorder=10)
    ax[1].add_patch(p)
    ax[1].set_xlim(-5, 5)
    ax[1].set_ylim(-2,2)
    plt.show()
```

```python

```

## 7. Pintemos un poco más

Con los datos que ya hemos manejado, sin mucho más esfuerzo, podemos fácilmente pintar la velocidad y la presión del aire alrededor del perfil.

```python
# aeropython: preserve
#Velocidad conjugada
dfdt = U * ( 1 * np.exp(-alfa * 1j) - R**2 / (tt - t0)**2 * np.exp(alfa * 1j) )
dfdt += 1j * T / (2*np.pi) * 1 / (tt - t0)
#coeficiente de presion
cp = 1 - np.abs(dfdt)**2 / U**2
```

```python
# aeropython: preserve
cmap = plt.cm.RdBu
```

```python
# aeropython: preserve
#Se pinta
fig, ax = plt.subplots(1,3)
#lineas de corriente
fig.set_size_inches(15,15)
ax[0].contour(XX, YY, psi, np.linspace(-10,10,50), colors = ['blue', 'blue'])
ax[0].grid()
ax[0].set_aspect(1)
p = plt.Polygon(list(zip(Xc, Yc)), color="#cccccc", zorder=10)
ax[0].add_patch(p)
#Campo de velocidades
ax[1].contourf(XX, YY, np.abs(dfdt), 200, cmap=cmap)
p = plt.Polygon(list(zip(Xc, Yc)), color="#cccccc", zorder=10)
ax[1].set_title('campo de velocidades')
ax[1].add_patch(p)
ax[1].set_aspect(1)
ax[1].grid()
#campo de presiones
ax[2].contourf(XX, YY, cp, 200, cmap=cmap)
p = plt.Polygon(list(zip(Xc, Yc)), color="#cccccc", zorder=10)
ax[2].set_title('coeficiente de presión')
ax[2].add_patch(p)
ax[2].set_aspect(1)
ax[2].grid()
plt.show()
```

```python
#Se pinta
# aeropython: preserve
fig, ax = plt.subplots(1,3)
#lineas de corriente
fig.set_size_inches(15,15)
ax[0].contour(XX_tau, YY_tau, psi, np.linspace(-10,10,50), colors = ['blue', 'blue'])
ax[0].grid()
ax[0].set_aspect(1)
p = plt.Polygon(list(zip(Xp, Yp)), color="#cccccc", zorder=10)
ax[0].add_patch(p)
#Campo de velocidades
ax[1].contourf(XX_tau, YY_tau, np.abs(dfdt), 200, cmap=cmap)
p = plt.Polygon(list(zip(Xp, Yp)), color="#cccccc", zorder=10)
ax[1].set_title('campo de velocidades')
ax[1].add_patch(p)
ax[1].set_aspect(1)
ax[1].grid()
#campo de presiones
ax[2].contourf(XX_tau, YY_tau, cp, 200, cmap=cmap)
p = plt.Polygon(list(zip(Xp, Yp)), color="#cccccc", zorder=10)
ax[2].set_title('coeficiente de presión')
ax[2].add_patch(p)
ax[2].set_aspect(1)
ax[2].grid()
plt.show()
```

```python
# aeropython: preserve
def cp_perfil_circunferencia(landa, delta, alfa, U=1,  N_malla = 100):
    N_perfil=N_malla
    a=1
    #punto del plano complejo
    t0 = a * (-landa + delta * 1j)
    #valor del radio de la circunferencia
    R = a * np.sqrt((1 + landa)**2 + delta**2)

    #se barre un ángulo de 0 a 2 pi
    theta = np.linspace(0, 2*np.pi, N_perfil)
    #se crean las coordenadas del los puntos
    #de la circunferencia
    Xc = - a * landa + R * np.cos(theta)
    Yc =   a * delta + R * np.sin(theta)

    #se crean las coordenadas del los puntos
    #del perfil
    Puntos_perfil = transf_yukovski(a, Xc+Yc*1j)
    Xp, Yp = np.real(Puntos_perfil) , np.imag(Puntos_perfil)

    #se crea la malla donde se va pintar la función de corriente

    # Dirección radial
    N_R = N_malla//2   # Número de puntos en la dirección radial
    R_min = R
    R_max = 10

    # Dirección tangencial
    N_T = N_malla   # Número de puntos en la dirección tangencial

    R_ = np.linspace(R_min, R_max, N_R)
    T_ = np.linspace(0, 2*np.pi, N_T)

    # El menos en la XX  es para que el borde de ataque del perfil esté en la izquierda
    XX = - (R_ * np.cos(T_).reshape((-1, 1)) - np.real(t0))
    YY = R_ * np.sin(T_).reshape((-1, 1)) + np.imag(t0)

    tt = XX + YY * 1j

    alfa = np.deg2rad(alfa)
    # Circulación que hay que añadir al cilindro para
    # que se cumpla la hipótesis de Kutta en el perfil
    T = 4 * np.pi * a * U * (delta + (1+landa) * alfa)

    #Velocidad conjugada
    dfdt = U * ( 1 * np.exp(-alfa * 1j) - R**2 / (tt - t0)**2 * np.exp(alfa * 1j) )
    dfdt = dfdt + 1j * T / (2*np.pi) * 1 / (tt - t0)
    #coeficiente de presion
    cp = 1 - np.abs(dfdt)**2 / U**2

    Puntos_plano_tau = transf_yukovski(a, tt)
    XX_tau, YY_tau = np.real(Puntos_plano_tau) , np.imag(Puntos_plano_tau)

    #Se pinta
    fig, ax = plt.subplots(1,2)

    #coeficiente de presión
    fig.set_size_inches(15,15)

    ax[0].contourf(XX, YY, cp, 200, cmap=cmap)
    ax[0].grid()
    ax[0].set_aspect(1)
    p = plt.Polygon(list(zip(Xc, Yc)), color="#cccccc", zorder=10)
    ax[0].add_patch(p)
    ax[0].set_xlim(-5, 5)
    ax[0].set_ylim(-3,3)

    ax[1].contourf(XX_tau, YY_tau, cp, 200, cmap=cmap)
    ax[1].grid()
    ax[1].set_aspect(1)
    p = plt.Polygon(list(zip(Xp, Yp)), color="#cccccc", zorder=10)
    ax[1].add_patch(p)
    ax[1].set_xlim(-5, 5)
    ax[1].set_ylim(-3,3)
    plt.show()
```

```python

```

---

_En esta clase hemos reafirmado nuestros conocimientos de NumPy, matplotlib y Python, general (funciones, bucles, condicionales...) aplicándolos a un ejemplo muy aeronáutico_

Si te ha gustado esta clase:

Tweet

---

####
¡Síguenos en Twitter!

###### Follow @Pybonacci   Follow @Alex__S12   Follow @newlawrence

Curso AeroPython por Juan Luis Cano Rodriguez y Alejandro Sáez Mollejo se distribuye bajo una Licencia Creative Commons Atribución 4.0 Internacional.

---
_Las siguientes celdas contienen configuración del Notebook_

_Para visualizar y utlizar los enlaces a Twitter el notebook debe ejecutarse como [seguro](http://ipython.org/ipython-doc/dev/notebook/security.html)_

    File > Trusted Notebook

```python

```

```python
# Esta celda da el estilo al notebook
from IPython.core.display import HTML
css_file = '../styles/aeropython.css'
HTML(open(css_file, "r").read())
```

/* This template is inspired in the one used by Lorena Barba
in the numerical-mooc repository: https://github.com/numerical-mooc/numerical-mooc
We thank her work and hope you also enjoy the look of the notobooks with this style */

<link href='http://fonts.googleapis.com/css?family=Source+Sans+Pro|Josefin+Sans:400,700,400italic|Ubuntu+Condensed' rel='stylesheet' type='text/css'>

El estilo se ha aplicado =)


```python
%reload_ext nb_black
```

    <IPython.core.display.Javascript object>

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

from quantum_systems import ODQD, GeneralOrbitalSystem
```

    <IPython.core.display.Javascript object>

```python
l = 10
grid_length = 10
num_grid_points = 2001
omega = 0.25

odho = ODQD(
    l, grid_length, num_grid_points, a=0.25, alpha=1, potential=ODQD.HOPotential(omega)
)
```

    <IPython.core.display.Javascript object>

\begin{align}
    \epsilon_i = \hbar \omega \left(i + \frac{1}{2}\right)
\end{align}

```python
np.diag(odho.h)
```

    array([0.1249998 +0.j, 0.37499902+0.j, 0.62499748+0.j, 0.87499542+0.j,
           1.12499515+0.j, 1.37501276+0.j, 1.62513397+0.j, 1.87570984+0.j,
           2.12783907+0.j, 2.38410631+0.j])

    <IPython.core.display.Javascript object>

```python
fig = plt.figure(figsize=(16, 10))

plt.plot(odho.grid, ODQD.HOPotential(omega)(odho.grid))

for i in range(l):
    plt.plot(
        odho.grid,
        np.abs(odho.spf[i]) ** 2 + odho.h[i, i].real,
        label=r"$\psi_{" + f"{i}" + r"}$",
    )

plt.grid()
plt.legend()
plt.show()
```

```python
print(f"l = {odho.l}")
print(f"grid shape = {odho.grid.shape}")
print(f"h shape = {odho.h.shape}")
print(f"u shape = {odho.u.shape}")
print(f"x shape = {odho.position.shape}")
print(f"spf shape = {odho.spf.shape}")
```

    l = 10
    grid shape = (2001,)
    h shape = (10, 10)
    u shape = (10, 10, 10, 10)
    x shape = (1, 10, 10)
    spf shape = (10, 2001)

    <IPython.core.display.Javascript object>

```python
system = GeneralOrbitalSystem(n=2, basis_set=odho, anti_symmetrize_u=True)
```

    <IPython.core.display.Javascript object>

```python
print(f"l = {system.l}")
print(f"grid shape = {system._basis_set.grid.shape}")
print(f"h shape = {system.h.shape}")
print(f"u shape = {system.u.shape}")
print(f"x shape = {system.position.shape}")
print(f"spf shape = {system.spf.shape}")
```

    l = 20
    grid shape = (2001,)
    h shape = (20, 20)
    u shape = (20, 20, 20, 20)
    x shape = (1, 20, 20)
    spf shape = (20, 2001)

    <IPython.core.display.Javascript object>

__Note:__ `system.u` in the `GeneralOrbitalSystem` are the anti-symmetric Coulomb matrix elements.

\begin{align}
    \hat{H}
    &= \hat{t} + \hat{v} + \hat{u}
    = \hat{h} + \hat{u}
    = \sum_{i}\left(
        -\frac{1}{2}\frac{d^2}{dx^2}
        + \frac{1}{2}\omega^2 x^2
    \right)
    + \sum_{i < j} \frac{\alpha}{\sqrt{(x_i - x_j)^2 + a^2}}.
\end{align}

\begin{align}
    \hat{H} \Psi(x_1, \dots, x_N) = E \Psi(x_1, \dots, x_N)
\end{align}

In Hartree-Fock:
\begin{align}
    \Psi(x_1, \dots, x_N) \approx \Phi(x_1, \dots, x_N)
    = \begin{vmatrix}
        \phi_1(x_1) & \dots & \phi_1(x_N) \\
        \vdots & \ddots & \vdots \\
        \phi_N(x_1) & \dots & \phi_N(x_N)
    \end{vmatrix}
\end{align}

Variational principle:
\begin{align}
    E_{gs} \leq E[\Psi] = \langle \Psi | \hat{H} | \Psi \rangle
\end{align}

Minimization:
\begin{align}
    L = \langle \Phi | \hat{H} | \Phi \rangle - \lambda_{ji}\left( \langle \phi_i | \phi_j \rangle - \delta_{ij} \right)
\end{align}

$\implies$ Modern Quantum Chemistry - Szabo & Östlund $\implies$

\begin{align}
    \hat{f}|\phi_i\rangle = \varepsilon_i |\phi_i\rangle.
\end{align}

Atomic orbitals (harmonic oscillator orbitals) $\{\chi_{\alpha}\}_{\alpha = 1}^{l}$ to the molecular orbitals (Hartree-Fock orbitals):

\begin{align}
    |\phi_i \rangle = C_{\alpha i} | \chi_{\alpha} \rangle,
\end{align}

where $\{\phi_i\}_{i = 1}^{N}$ and $N$ is not necessarily equal to $l$.

Inserting the basis transformation into the Hartree-Fock equations:
\begin{gather}
    \hat{f} C_{\alpha i} | \chi_{\alpha} \rangle = \varepsilon_i C_{\alpha i} | \chi_{\alpha} \rangle
\end{gather}

Left-projecting with $\chi_{\beta}$:
\begin{gather}
    C_{\alpha i} \langle \chi_{\beta} | \hat{f} | \chi_{\alpha} \rangle
    = \varepsilon_i C_{\alpha i} \langle \chi_{\beta} | \chi_\alpha \rangle
    = \varepsilon_i C_{\beta i},
\end{gather}
as $\langle \chi_{\beta} | \chi_\alpha \rangle = \delta_{\beta \alpha}$ in our case.

Define the matrices $\mathbf{F}$ and $\mathbf{C}$, and the vector $\boldsymbol{\varepsilon}$. The elements of $\mathbf{F}$ are
\begin{gather}
    [\mathbf{F}]_{\beta \alpha} \equiv \langle \chi_{\beta} | \hat{f} | \chi_{\alpha} \rangle.
\end{gather}

This lets us write the Roothan-Hall equations:
\begin{align}
    \mathbf{F} \mathbf{C} = \mathbf{C} \boldsymbol{\varepsilon},
\end{align}
which is a generalized eigenvalue equation.

The Fock operator:
\begin{align}
    \hat{f} = \hat{h} + \hat{u}^{direct} + \hat{u}^{exchange}
\end{align}

The matrix elements are:
\begin{align}
    \langle \chi_{\beta} | \hat{f} | \chi_{\alpha} \rangle
    = h_{\beta \alpha} + u^{direct}_{\beta \alpha} + u^{exchange}_{\beta \alpha},
\end{align}
where the one-body Hamiltonian is:
\begin{align}
    h_{\beta \alpha}
    = \int dx \chi_{\beta}(x) h \chi_{\alpha}(x),
\end{align}
(this is found in `odho.h` in the code.)
the direct interaction is:
\begin{align}
    u^{direct}_{\beta \alpha}
    = \sum_{i}^{N} \langle \chi_{\beta} \phi_i | \hat{u} | \chi_{\alpha} \phi_i \rangle,
\end{align}
the exchange interaction is:
\begin{align}
    u^{exchange}_{\beta \alpha}
    = \sum_{i}^{N} \langle \chi_{\beta} \phi_i | \hat{u} | \phi_i \chi_{\alpha} \rangle,
\end{align}

\begin{gather}
    \langle \chi_{\beta} | \hat{f} | \chi_{\alpha} \rangle
    = h_{\beta \alpha} + C^{*}_{??} C_{??} u_{\beta ? ? \alpha}
\end{gather}

The matrix elements $u^{\alpha \beta}_{\gamma \delta}$ can be found in `odho.u`. They are labelled:
\begin{align}
    u^{\alpha \beta}_{\gamma \delta}
    = \int d x_1 d x_2 \chi^{*}_{\alpha}(x_1) \chi^{*}_{\beta}(x_2) u(x_1, x_2) \chi_{\gamma}(x_1) \chi_{\delta}(x_2)
\end{align}


#  Taylor problem 5.32

last revised: 12-Jan-2019 by Dick Furnstahl [furnstahl.1@osu.edu]

**Replace ### by appropriate expressions.**

The equation for an underdamped oscillator, such as a mass on the end of a spring, takes the form

$\begin{align}
  x(t) = e^{-\beta t} [B_1 \cos(\omega_1 t) + B_2 \sin(\omega_1 t)]
\end{align}$

where

$\begin{align}
  \omega_1 = \sqrt{\omega_0^2 - \beta^2}
\end{align}$

and the mass is released from rest at position $x_0$ at $t=0$.

**Goal: plot $x(t)$ for $0 \leq t \leq 20$, with $x_0 = 1$, $\omega_0=1$, and $\beta = 0.$, 0.02, 0.1, 0.3, and 1.**

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

```python
def underdamped(t, beta, omega_0=1, x_0=1):
    """Solution x(t) for an underdamped harmonic oscillator."""
    omega_1 = np.sqrt(omega_0**2 - beta**2)
    B_1 = 2
    B_2 = 5
    return np.exp(-beta*t) \
             * ( B_1 * np.cos(omega_1*t) + B_2 * np.sin(omega_1*t) )
```

```python
t_pts = np.arange(0., 20., .01)
betas = [0., 0.02, 0.1, 0.3, 0.9999]

fig = plt.figure(figsize=(10,6))
# look up "python enumerate" to find out how this works!
for i, beta in enumerate(betas):
    ax = fig.add_subplot(2, 3, i+1)
    ax.plot(t_pts, underdamped(t_pts, beta), color='blue')
    ax.set_title(rf'$\beta = {beta:.2f}$')
    ax.set_xlabel('t')
    ax.set_ylabel('x(t)')
    ax.set_ylim(-1.1,1.1)
    ax.axhline(0., color='black', alpha=0.3)  # lightened black zero line

fig.tight_layout()
### add code to print the figure
```

## Bonus: Widgetized!

```python
from ipywidgets import interact, fixed
import ipywidgets as widgets

omega_0 = 1.

def plot_beta(beta):
    """Plot function for underdamped harmonic oscillator."""
    t_pts = np.arange(0., 20., .01)

    fig = plt.figure()
    ax = fig.add_subplot(1,1,1)
    ax.plot(t_pts, underdamped(t_pts, beta), color='blue')
    ax.set_title(rf'$\beta = {beta:.2f}$')
    ax.set_xlabel('t')
    ax.set_ylabel('x(t)')
    ax.set_ylim(-1.1,1.1)
    ax.axhline(0., color='black', alpha=0.3)

    fig.tight_layout()

max_value = omega_0 - 0.0001
interact(plot_beta,
         beta=widgets.FloatSlider(min=0., max=max_value, step=0.01,
                                  value=0., readout_format='.2f',
                                  continuous_update=False));

```

    interactive(children=(FloatSlider(value=0.0, continuous_update=False, description='beta', max=0.9999, step=0.0…

Now let's allow for complex numbers!  This will enable us to take $\beta > \omega_0$.

```python
# numpy.lib.scimath version of sqrt handles complex numbers.
#  numpy exp, cos, and sin already can.
import numpy.lib.scimath as smath

def all_beta(t, beta, omega_0=1, x_0=1):
    """Solution x(t) for damped harmonic oscillator, allowing for overdamped
        as well as underdamped solution.
    """
    omega_1 = smath.sqrt(omega_0**2 - beta**2)
    return np.real( x_0 * np.exp(-beta*t) \
               * (np.cos(omega_1*t) + (beta/omega_1)*np.sin(omega_1*t)) )
```

```python
from ipywidgets import interact, fixed
import ipywidgets as widgets

omega_0 = 1.

def plot_all_beta(beta):
    """Plot of x(t) for damped harmonic oscillator, allowing for overdamped
       as well as underdamped cases."""
    t_pts = np.arange(0., 20., .01)

    fig = plt.figure()
    ax = fig.add_subplot(1,1,1)
    ax.plot(t_pts, all_beta(t_pts, beta), color='blue')
    ax.set_title(rf'$\beta = {beta:.2f}$')
    ax.set_xlabel('t')
    ax.set_ylabel('x(t)')
    ax.set_ylim(-1.1,1.1)
    ax.axhline(0., color='black', alpha=0.3)

    fig.tight_layout()

interact(plot_all_beta,
         beta=widgets.FloatSlider(min=0., max=2, step=0.01,
                                  value=0., readout_format='.2f',
                                  continuous_update=False));

```

    interactive(children=(FloatSlider(value=0.0, continuous_update=False, description='beta', max=2.0, step=0.01),…

```python

```


# Coupled-perturbed SCF

Coupled-perturbed SCF (CP-SCF) is a method to compute __static__ (i.e. time-independent) __molecular properties__ from a reference wavefunction. In this Jupyter notebook, we will use a Hartree-Fock wavefunction (dubbed CP-HF) to compute the static, electric dipole polarizability tensor for the water molecule.

## Basics of Polarization

In some cases, take dielectrics for example, we would like to know how these materials behave under the influence of an external electric field. Here, we attempt to compute the __electric dipole polarizability__ $\alpha_{ij}$ with Hartree-Fock theory.

How do we connect Hartree-Fock to the polarizability? Any Hartree-Fock calculation results in an energy, $E_\text{HF}$, so we need a relation between the energy and the polarizability. A direct connection between these two quantities is not immediately obvious, but we know how to (i) compute the energy of a dipole moment in an electric field and (ii) how the polarizability is related to the dipole moment.

From basic electrostatics, we know how to compute the energy of a dipole in a homogeneous electric field: It is the (negative of the) dot product between the __dipole moment__ $\vec{\mu}$ and the electric field $\vec{E}$. Here, the index $i$ denotes one of the directions of space, e.g. $x$.

\begin{align}
    E &= - \vec{\mu} \cdot \vec{E}\\
    \Leftrightarrow \quad \mu_i &= - \frac{\partial E}{\partial E_i}
\end{align}

The total dipole moment can be written as a sum of a permanent contribution (think of the water molecule here) and a part that is __induced__ by an external field. The dipole polarizability $\alpha$ describes how much the total dipole moment reacts to the external field:

\begin{align}
    \mu_{i} &= \mu_{i,\text{permanent}} + \sum_j \left.\frac{\partial \mu_i}{\partial E_j}\right|_{\vec{E}=0} E_j + \text{higher orders}\\
            &= \mu_{i,\text{permanent}} + \sum_j \alpha_{ij} E_j + \text{higher orders}
\end{align}

Again, the indices $i$ and $j$ refer to the three spatial dimensions $x$, $y$, and $z$. From the two equations, we identify the polarizability to be

\begin{align}
    \alpha_{ij} &= \left.\frac{\partial \mu_i}{\partial E_j}\right|_{\vec{E}=0}
\end{align}

Combined with the previous definition of $ \mu_{i} $, we can write

\begin{align}
    \alpha_{ij} &= - \frac{\partial^2 E}{\partial E_i \partial E_j}
\end{align}

We can compute the polarizability by differentiating the Hartree-Fock energy twice with respect to the fields in $i$ and $j$ direction. Now, we only have to think about how to actually differentiate the Hartree-Fock energy.

## Linear Response Theory

Linear Response Theory is a useful method to compute _ab initio_ molecular properties. Here, we focus on the time-independent response of a molecular system. From the above, we know that we need to compute the second derivative of the energy with respect to electric fields, and linear response theory can tell us how to do that. This section is kept as general as possible, and the next section will give the explicit expressions used later in the computational procedure.

First of all, we have to define our wavefunction and the Hamiltonian. The wavefunction $\psi$ is parameterized as a function of the __wavefunction parameter__ $\lambda$ as $\psi(\lambda)$ (allows for easy mathematical manipulation) and our Hamiltonian is written as $\hat{H}(x) = \hat{H}_0 + V(x)$ with $V(x)$ being the __perturbation__ of the system. The $x$ denotes the __perturbation parameter__, which is the electric field in our case. Keep in mind that the wavefunction parameter depends on the perturbation parameter as $\lambda(x)$! This is because the wavefunction will be influenced by an external perturbation.

For the energy of the system under a perturbation of $V(x)$ we can now write

\begin{align}
    E (x, \lambda) &= \langle \psi(\lambda) | \hat{H}(x) | \psi(\lambda) \rangle
\end{align}

Remember, we need derivatives of the energy to compute our desired property. So let's differentiate the energy with respect to the perturbation parameter:

\begin{align}
    \frac{\mathrm{d} E(x, \lambda_0)}{\mathrm{d} x} &= \underbrace{\frac{\partial E(x, \lambda_0)}{\partial x}}_\text{explicit dependence} + \underbrace{\left.\frac{\partial E(x, \lambda)}{\partial \lambda}\right|_{\lambda=\lambda_0} \left.\frac{\partial \lambda}{\partial x}\right|_{\lambda=\lambda_0}}_\text{implicit dependence}
\end{align}

where we have used the chain rule for the implicit-dependence part. The subscripts $\lambda=\lambda_0$ indicate that the derivatives are evaluated at the parameters of a converged wavefunction, $\lambda_0$.

The expression for the first derivative can be simplified: If $\partial E / \partial \lambda$ is evaluated at the wavefunction parameter of an optimized wavefunction, we know that this term will be exactly zero since we are in a minimum! We arrive at

\begin{align}
    \frac{\mathrm{d} E(x, \lambda_0)}{\mathrm{d} x} &= \frac{\partial E(x, \lambda_0)}{\partial x}
\end{align}

Now we can tackle the second derivatives. We differentiate the above expression again to get

\begin{align}
    \frac{\mathrm{d}^2 E(x, \lambda_0)}{\mathrm{d} x^2} &= \frac{\partial^2 E(x, \lambda_0)}{\partial x^2} + \left.\frac{\partial E(x, \lambda)}{\partial x \partial \lambda}\right|_{\lambda=\lambda_0} \left.\frac{\partial \lambda}{\partial x}\right|_{\lambda=\lambda_0}
\end{align}

So far, so good. However, we now need an expression for $\partial \lambda / \partial x$, the __wavefunction response__ (turns out, we know how to compute the rest, see next section). We can get it with a trick: Remember that _for any perturbation parameter_ $x$

\begin{align}
    \left.\frac{\partial E(x, \lambda)}{\partial \lambda}\right|_{\lambda=\lambda_0} = 0 \quad \forall x
\end{align}

We can differentiate this condition with respect to $x$ to obtain our desired wavefunction response!

\begin{align}
    \frac{\mathrm{d}}{\mathrm{d}x} \left.\frac{\partial E(x, \lambda)}{\partial \lambda}\right|_{\lambda=\lambda_0} &= \left.\frac{\partial^2 E(x,\lambda)}{\partial x \partial \lambda}\right|_{\lambda=\lambda_0} + \left.\frac{\partial^2 E(x,\lambda)}{\partial \lambda^2}\right|_{\lambda=\lambda_0} \left.\frac{\partial \lambda}{\partial x}\right|_{\lambda=\lambda_0} = 0\\
    \Leftrightarrow \quad \underbrace{\left.\frac{\partial^2 E(x,\lambda)}{\partial \lambda^2}\right|_{\lambda=\lambda_0}}_\text{electronic
    Hessian} \left.\frac{\partial \lambda}{\partial x}\right|_{\lambda=\lambda_0} &= - \underbrace{\left.\frac{\partial^2 E(x,\lambda)}{\partial x \partial \lambda}\right|_{\lambda=\lambda_0}}_{\substack{\text{perturbation tensor} \\ \text{"right-hand side"}}}
\end{align}

_Interesting deviation_: Doesn't the last equation remind you of something? Think of __Hooke's law__ for springs:

\begin{align}
    k x &= -F
\end{align}

Hooke's law tells us how a mass on a spring reacts to an applied force. In our case, the equation describes how the wavefunction will react to an applied perturbation. We can view this as the orbitals changing slightly (remember, a perturbation has to be small for perturbation theory to be applicable!) to new orbitals that are optimal under the perturbation, called __orbital rotation__. You can also think about it as the electrons being __excited__ from the occupied to the unoccupied orbitals in response to the perturbation (the mathematical connection will be made clear in the next section).

## Coupled-Perturbed Hartree-Fock

We are finally ready to apply all of the above to our problem! To summarize the above, we need the following equations (of course, we need to replace the generic perturbation parameter $x$ with the electric field components $E_i$ and $E_j$):

\begin{align}
    \alpha_{ij} &= - \frac{\partial^2 E}{\partial E_i \partial E_j}\\
    &= - \left[\frac{\partial^2 E(\vec{E}, \lambda_0)}{\partial E_i \partial E_j} + \left.\frac{\partial E(\vec{E}, \lambda)}{\partial E_i \partial \lambda}\right|_{\lambda=\lambda_0} \left.\frac{\partial \lambda}{\partial E_j}\right|_{\lambda=\lambda_0}\right]\\
    \left.\frac{\partial^2 E(\vec{E},\lambda)}{\partial \lambda^2}\right|_{\lambda=\lambda_0} \left.\frac{\partial \lambda}{\partial E_i}\right|_{\lambda=\lambda_0} &= - \left.\frac{\partial^2 E(\vec{E},\lambda)}{\partial E_i \partial \lambda}\right|_{\lambda=\lambda_0}
\end{align}

Now we have to find explicit expression for Hartree-Fock theory and the polarizability. Let's start again with the parameterization of the wavefunction: This can be conveniently done in Hartree-Fock theory with an __orbital rotation operator__ $\hat{\kappa}$ (see Helgaker, chapters 3 and 10.1.2)

\begin{align}
    \hat{\kappa} &= \sum_{p>q} \kappa_{pq} E^{-}_{pq}\\
    E^{-}_{pq} &= E_{pq} - E_{qp}\\
    &= (a_{p\alpha}^\dagger a_{q\alpha} + a_{p\beta}^\dagger a_{q\beta}) - (a_{q\alpha}^\dagger a_{p\alpha} + a_{q\beta}^\dagger a_{p\beta})
\end{align}

$E_{pq}$ is a __singlet excitation operator__; it raises a pair ($\alpha$ and $\beta$) of electrons from orbital $p$ to $q$ (remember the last paragraph in the last section!). Now, the wavefunction can be written like

\begin{align}
    \psi (\kappa) &= \exp(-\hat{\kappa}) \psi
\end{align}

Here, $\exp(-\hat{\kappa})$ is a _unitary operator_ because the orbital rotation operator is _anti-Hermitian_: Every unitary matrix may be written as an exponential of an anti-Hermitian matrix. This is where the term __orbital rotation__ can easily be rationalized: If you apply a unitary matrix on a vector, the vector's length remains unchanged but it 'rotates' in space.

Now for the Hamiltonian: In our case, the perturbation is

\begin{align}
    V(\vec{E}) &= -\hat{\mu} \vec{E}\\
    \hat{H}(\vec{E}) &= \hat{H}_0 + V(\vec{E})
\end{align}

With this, we can already simplify the response equation from above: Differentiating $\partial^2 E(\vec{E}, \kappa_0) / \partial E_i \partial E_j$ twice is zero here because $(\partial^2 / \partial E_i \partial E_j) \hat{\mu} \vec{E} = 0$.

Now we only need two more explicit expressions: One for the electronic Hessian to solve for the wavefunction response and our perturbation vectors. Thankfully, these are known in the literature ([Jørgensen 1975](https://dx.doi.org/10.1146/annurev.pc.26.100175.002043), p. 368). The electronic Hessian for our closed-shell HF calculation reads

\begin{align}
    \left.\frac{\partial^2 E(x,\kappa)}{\partial \kappa^2}\right|_{\kappa=0} &= \mathbf{A} + \mathbf{B}\\
    A_{iajb} &= \delta_{ij} F_{ab} - \delta_{ab} F_{ij} + 2 g_{aijb} - g_{jiab}\\
    B_{iajb} &= 2 g_{aijb} - g_{ajbi}
\end{align}

Here, $\mathbf{F}$ is the molecular Fock matrix and $g_{pqrs}$ denotes the two-electron repulsion integrals. The molecular Hessian was split into two matrices $\mathbf{A}$ and $\mathbf{B}$ (which occur explicitly in TDSCF calculations).

The perturbation vectors ("right-hand sides"), from now on denoted with $\mathbf{f}$, are

\begin{align}
    (\mathbf{f}_i)_{rs} &= \left.\frac{\partial E(\vec{E}, \kappa)}{\partial \vec{E}_i \partial \kappa_{rs}}\right|_{\kappa=0} = -2 \langle r | \hat{\mu}_i | s \rangle
\end{align}

where $\hat{\mu}_i$ is the __dipole moment operator__.

With this knowledge, we can assemble the final equations. We also introduce the abbreviation for the wavefunction response vector $\mathbf{x}_j = \partial \kappa / \partial E_j$.

\begin{align}
    \alpha_{ij} &= - \left[\underbrace{\frac{\partial^2 E(\vec{E}, \kappa_0)}{\partial E_i \partial E_j}}_{=0} + \left.\frac{\partial E(\vec{E}, \kappa)}{\partial E_i \partial \kappa}\right|_{\kappa=0} \left.\frac{\partial \kappa}{\partial E_j}\right|_{\kappa=0}\right]\\
    &= - \mathbf{f}_i \mathbf{x}_j\\
    (\mathbf{A}+\mathbf{B}) \mathbf{x}_i &= - \mathbf{f}_i
\end{align}

Therefore, the polarizability can be computed as
\begin{align}
    \alpha_{ij} &= \mathbf{f}_i\, (\mathbf{A} - \mathbf{B})^{-1}\, \mathbf{f}_j
\end{align}

## Computational Procedure

For our computation, we need the matrices and vectors that were discussed in the theory section. We will use Psi4 to compute these for a converged Hartree-Fock wavefunction.

We use `numpy` to expose the perturbation tensors $\mathbf{f}$, the occupation matrix $\mathbf{C}$, the Fock matrix $\mathbf{F}$ and the electron repulsion integrals $\mathbf{I}$ to Python such that we can use `numpy`'s matrix/vector manipulations.

```python
import psi4

import numpy as np
np.set_printoptions(3, linewidth=100, suppress=True)    # when we inspect the vectors/matrices,
                                                        # use a prettier format for printing
```

The geometry of the water molecule is set to a previously optimized one for the aug-cc-pVDZ basis. We use the keyword `symmetry c1` to tell Psi4 not to use the actual point group $C_{2v}$ in the computation: If Psi4 used symmetry, we would not be able to create correct matrices for use in `numpy`.

```python
mol = psi4.geometry('''
    O
    H  1  0.9435
    H  1  0.9435  2  105.9443
    symmetry c1
''')
```

Now, we set the basis to aug-cc-pVDZ. This is the same basis set that was used for the geometry optimization.

```python
psi4.set_options({'BASIS': 'aug-cc-pVDZ'})
```

We are now ready to run the SCF cycle to converge the wavefunction. By telling Psi4 to `return_wfn=True`, we get back the converged wavefunction from which we can extract the pertinent matrices. We print out the energy such that we can compare it against a pre-computed reference. It needs not exactly match the reference, slight variations are OK.

```python
scf_energy, wfn = psi4.energy('SCF', return_wfn=True)
print('SCF Energy: {}'.format(scf_energy))  # Print the final, converged SCF energy, should be something like
                                            # -76.04182368701949
```

    SCF Energy: -76.04182368694052

From now on, we need the orbital occupation matrix $\mathbf{C}$: The quantities that we get from Psi4 are in the spin-orbital basis. We, on the other hand, need them to be in the molecular orbital basis. The $\mathbf{C}$ matrix is the key to this as it tells us how to get from one basis to the other. The rows are the spin orbitals $\lambda$, and the coefficients in the columns tell us how to construct the corresponding molecular orbital $p$ from the spin orbitals:

\begin{align}
    \chi_p &= \sum_\lambda \phi_\lambda C_{\lambda p}
\end{align}

Similarly, we can transform any index from the SO to the MO basis.

Note that the code says `wfn.Ca`: We get the coefficients matrix for the $\alpha$ spins. However, since we are working in restricted Hartree-Fock (RHF) where spins are always paired, this does not really matter.

```python
C = np.asarray(wfn.Ca())
```

We use the transformation with the Fock matrix first. In the first line of the following code, we get the Fock matrix in the SO basis with the elements

\begin{align}
    F_{\lambda \mu}
\end{align}

To transform the first index, we left-multiply with the transpose of the coefficients matrix

\begin{align}
    F_{p\mu} &= \sum_\lambda C_{\lambda p} F_{\lambda \mu}\\
    &= \sum_\lambda C_{p \lambda}^T F_{\lambda\mu}\\
    \mathbf{F}' &= \mathbf{C}^T \mathbf{F}
\end{align}

We can do the same for the second index on the intermediate matrix $\mathbf{F}'$ to arrive at the molecular Fock matrix:

\begin{align}
    F_{pq} &= \sum_{\mu} F_{p\mu} C_{\mu q}\\
    \mathbf{F}_\text{mol} &= \mathbf{F}' \mathbf{C}\\
    &= \mathbf{C}^T \mathbf{F} \mathbf{C}
\end{align}

```python
F = np.asarray(wfn.Fa())
F = np.dot(C.T, F).dot(C)
```

In the following computations, we will need the electron repulsion integrals. These can be computed with a `psi4.core.MintsHelper`. The `MintsHelper` is told to use the same basis that was used for the wavefunction to compute the basis-dependent integrals.

```python
mints = psi4.core.MintsHelper(wfn.basisset())
```

The electron repulsion integrals are obtained by `mints.ao_eri()` and exposed to `numpy`. These integrals are obtained in the atomic orbital basis (AO). Since we are in RHF, we can use them with the other quantities that are computed for the SO basis.

```python
I = np.asarray(mints.ao_eri())
```

These integrals have to be transformed to the MO basis in the same manner as was done for the Fock matrix. Here, we use `numpy`'s Einstein summation function. Albert Einstein introduced in an article on "[The basis of general relativity](https://dx.doi.org/10.1002/andp.19163540702)" (p. 781) in 1916, where he states that "Should an index appear in a term of an expression twice, then it is always summed over, except it is explicitly noted otherwise."

Here, for the transformation of the first index, we get

\begin{align}
    g_{p\mu\nu\sigma} &= \sum_\lambda g_{\lambda\mu\nu\sigma} C_{\lambda p} \equiv g_{\lambda\mu\nu\sigma} C_{\lambda p}
\end{align}

In `numpy.einsum`, these indices would be written as $\lambda\mu\nu\sigma, \lambda p \rightarrow p\mu\nu\sigma$ and so on for the remaining indices.

```python
g_pqrs = np.einsum('pqrs,pt->tqrs', I, C)
g_pqrs = np.einsum('pqrs,qt->ptrs', g_pqrs, C)
g_pqrs = np.einsum('pqrs,rt->pqts', g_pqrs, C)
g_pqrs = np.einsum('pqrs,st->pqrt', g_pqrs, C)
```

We are now ready to build the orbital rotation matrices $\mathbf{A}$ and $\mathbf{B}$. Recall from above that

\begin{align}
    A_{iajb} &= \delta_{ij} F_{ab} - \delta_{ab} F_{ij} + 2 g_{aijb} - g_{jiab}
\end{align}

The Kronecker deltas are represented by a unit matrix `numpy.eye(...)` which has the same shape as the molecular Fock matrix.

```python
A = np.einsum('ij,ab->iajb', np.eye(F.shape[0]), F)
A -= np.einsum('ab,ij->iajb', np.eye(F.shape[0]), F)
A += 2 * np.einsum('aijb->iajb', g_pqrs)
A -= np.einsum('jiab->iajb', g_pqrs)
```

The $\mathbf{B}$ matrix is computed from

\begin{align}
    B_{iajb} &= 2 g_{aijb} - g_{ajbi}
\end{align}

and thus only contains contributions from the electron repulsion integrals.

```python
B = 2 * np.einsum('aijb->iajb', g_pqrs)
B -= np.einsum('ajbi->iajb', g_pqrs)
```

The electronic Hessian is then simply the sum of these two matrices. When searching the literature, keep in mind that some authors defined their $\mathbf{B}$ with the opposite sign. For them, the Hessian will be the difference of both orbital rotation matrices.

```python
H = A + B
```

Actually, we do not need the full Hessian, only the parts where the inactive and active orbitals contribute are of importance (see also Helgaker, sections 10.1.2 and 10.2.2). Therefore, we will now aim to extract the relevant parts of the Hessian. We already hinted at this by using the indices $i,j$ for inactive and $a,b$ for virtual orbitals in the definition of the Hessian.

For this, we need to know the number of number of basis functions = number of MOs, the number of doubly occupied orbitals (for RHF, identical to the number of electrons with $\alpha$ spin as all are paired) and the number of virtual orbitals. The number of rotable orbitals, i.e. the possible combinations of inactive and active orbitals, can then be computed by simple multiplication.

```python
nbf = wfn.nmo()
ndocc = wfn.nalpha()
nvirt = nbf - ndocc
nrot = ndocc * nvirt
```

The elements in the Hessian are sorted, the inactive orbitals come first and the virtual ones second. Therefore, the number of doubly occupied orbitals is the boundary between both, so that we can use `ndocc` for slicing. Remember that we need to slice such that we get

\begin{align}
    H_{iajb}
\end{align}

Finally, to bring the Hessian into matrix form for `numpy.linalg.solve`, we reshape it to be of 'number of rotable' $\times$ 'number of rotable' orbitals.

```python
H = H[:ndocc, ndocc:, :ndocc, ndocc:]
H = H.reshape(nrot, nrot)
```

We now have everything in place except for the perturbations themselves! These dipole tensors are computed by the `MintsHelper` instance and subsequently scaled by $-2$ to account for the doubly occupied orbitals.

```python
tmp_tensors = mints.so_dipole()     # get the tensors in the spin-orbital basis

dipole_tensors = []
for tensor in tmp_tensors:
    tensor.scale(-2)    # use the correct prefactor
    dipole_tensors.append(np.asarray(tensor))   # convert psi4's internal tensors to a numpy vector
```

These tensors are in the SO basis, so we have to transform them the same way as done above. Also, same as for the Hessian, they still contain redundant rotations. The vectors are sliced such that we only use the relevant parts and raveled for use with `numpy.linalg.solve`.

```python
for i in range(len(dipole_tensors)):
    dipole_tensors[i] = np.dot(C.T, dipole_tensors[i]).dot(C)
    dipole_tensors[i] = dipole_tensors[i][:ndocc, ndocc:].ravel()
```

We are finally ready to solve the linear response equations! For each perturbation, solve

\begin{align}
    H_{ia,jb} x_{jb} &= -f_{ia}
\end{align}

and store the resultant responses in `responses`.

```python
responses = []
for perturbation in dipole_tensors:
    responses.append(np.linalg.solve(H, -perturbation))
```

The static polarizability tensor can be computed by dotting the perturbation vectors with the reponse vectors.

\begin{align}
    \alpha_{ij} &= - \mathbf{f}_i \mathbf{x}_j
\end{align}

This is exactly how the polarizability tensor is usually defined: It tells us how the system responds in $i$-direction to a perturbation along $j$.

```python
polarizabilities = np.zeros((3, 3))
for i in range(3):
    for j in range(3):
        polarizabilities[i, j] = -np.dot(dipole_tensors[i], responses[j])
```

The tensor is:

```python
print(polarizabilities)
```

    [[ 7.259 -0.    -0.   ]
     [-0.     8.797  0.   ]
     [-0.     0.     7.854]]

## References

Trygve Helgaker, Poul Jørgensen, Jeppe Olsen, _Molecular Electronic-Structure Theory_ (John Wiley & Sons, Chichester, 2000). Available in digital form at [doi:10.1002/9781119019572](https://dx.doi.org/10.1002/9781119019572).

Julien Toulouse, _Introduction to the calculation of molecular properties by response theory_, lecture notes for a class given at the Université Pierre et Marie Curie, 2017. Last accessed on March 11, 2017 at [http://www.lct.jussieu.fr/pagesperso/toulouse/enseignement/molecular_properties.pdf](http://www.lct.jussieu.fr/pagesperso/toulouse/enseignement/molecular_properties.pdf).

Trygve Helgaker, _Time-independent molecular properties_, lecture at the 13th Sostrup Summer School Quantum Chemistry and Molecular Properties, 2014. Last accessed on March 11, 2017 at [http://folk.uio.no/helgaker/talks/SostrupTI_14.pdf](http://folk.uio.no/helgaker/talks/SostrupTI_14.pdf).

Poul Jørgensen, _Molecular and Atomic Applications of Time-Dependent Hartree-Fock Theory_, Annual Review of Physical Chemistry __26__, 359-380 (1975). Available in digital form at [doi:10.1146/annurev.pc.26.100175.002043](https://dx.doi.org/10.1146/annurev.pc.26.100175.002043).