Read two saved template Models
Interpolate Ge and Ph data to create input and output targets for training.

1. Load existing template, relational and generative NN models
2. Initialize coherence model
3. Train coherence model
4. Push Template date through Relational, Generative and Trained Coherence Models
5. Graph context values over morphology for different time values
6. Calculate coherence values for graphing over morphology
7. Graph coherence field for time values

```python
import torch as torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

import os
import numpy as np
import json
import math as m
import random
import time
import NNArt as gy
import NNArt1 as gy1
from sympy.utilities.iterables import multiset_permutations

import plotly as py
import plotly.offline as offline
import plotly.graph_objs as go
import plotly.figure_factory as ff
import plotly.io as pio

CURRENT_MODEL_SAVE_DIR = './save/'

offline.init_notebook_mode(connected=True)
np.set_printoptions(precision=3, suppress=True)

```

```python
class Net(nn.Module):
    def __init__(self, layerSizes, name):
        super(Net, self).__init__()
        self.shape = layerSizes
        self.name = name
        self.seq = nn.Sequential(
            nn.Linear(layerSizes[0],layerSizes[1]),
            nn.ELU(),
            nn.Linear(layerSizes[1],layerSizes[2]),
            nn.ELU(),
            nn.Linear(layerSizes[2],layerSizes[3]),
            nn.ELU()
        )
    def forward(self, x):
        return self.seq(x)

# class NetEEL(nn.Module):
#     def __init__(self, layerSizes, name):
#         super(NetEEL, self).__init__()
#         self.shape = layerSizes
#         self.name = name
#         self.seq = nn.Sequential(
#             nn.Linear(layerSizes[0],layerSizes[1]),
#             nn.ELU(),
#             nn.Linear(layerSizes[1],layerSizes[2]),
#             nn.ELU(),
#             nn.Linear(layerSizes[2],layerSizes[3]),
#             nn.ELU(),

#         )
#     def forward(self, x):
#         return self.seq(x)
```

```python
'''
1. Load existing template, relational and generative NN models

'''
tmModel0 = Net([2,10,15,21],'tmModel0')
tmModel1 = Net([2,10,15,21],'tmModel1')
relModel = Net([21,15,10,3],'relModel')
genModel = Net([3,10,15,21],'genModel')

PATH=CURRENT_MODEL_SAVE_DIR+'templateModel0.pth'
tmModel0.load_state_dict(torch.load(PATH))
PATH=CURRENT_MODEL_SAVE_DIR+'templateModel1.pth'
tmModel1.load_state_dict(torch.load(PATH))
PATH=CURRENT_MODEL_SAVE_DIR+'REL PhRc-T0T1-mask 11p05.pth'
relModel.load_state_dict(torch.load(PATH))
PATH=CURRENT_MODEL_SAVE_DIR+'GEN RcGen-T0T1-mask 11p05.pth'
genModel.load_state_dict(torch.load(PATH))
tmModel0.eval()
tmModel1.eval()
relModel.eval()
genModel.eval()

tmModel0.shape, tmModel1.shape,genModel.shape
```

    ([2, 10, 15, 21], [2, 10, 15, 21], [3, 10, 15, 21])

```python
def createRcCohTargets(tmModels, relModel, numSamples=1,rmRange = 1.):
    #generate random templateModel input
    ge = np.random.random([numSamples,2])*rmRange
    relIn = torch.Tensor(ge)
    tmOut0 = tmModels[0](torch.Tensor(relIn))
    tmOut1 = tmModels[1](torch.Tensor(relIn))

    #get relational Model outputs for template inputs
    relCout0 = relModel(tmOut0)
    relCout1 = relModel(tmOut1)
    rc0 = relCout0.data.numpy()
    rc1 = relCout1.data.numpy()
#     print('{}\n{}\n'.format(rc0,rc1))

    rcList = []
    cohList = []
#     for i in range(rc0.shape[0]):
    for i in range(numSamples):
        delta = 2*(np.random.random()-.5)
#         print('delta: ',delta)
        #clone before changing
        rcc = np.array(rc0[i])
        rcc[2] = delta #set new context value
        rcList.append(rcc)

        c0 = rc0[i,2]
        c1 = rc1[i,2]
        mse0 = (c0-delta)**2
        mse1 = (c1-delta)**2
        if c0-delta < 0:
            mse0 = -mse0
        if c1-delta < 0:
            mse1 = -mse1

#         print('c0,c1,delta:\n {}\n{}\n{}\n'.format(c0,c1,delta))

        cohList.append([mse0,mse1])

    return torch.Tensor(rcList), torch.Tensor(cohList)

torch.set_printoptions(precision=20)
r,c = createRcCohTargets([tmModel0, tmModel1], relModel, numSamples=2)
print('{}\n{}'.format(np.array(r),np.array(c)))

```

    [[ 0.352  0.845  0.007]
     [ 0.682  0.631 -0.518]]
    [[0.003 0.   ]
     [0.245 0.17 ]]

```python
def trainRcCoh(tmModels, relModel, cohModel, parms):
    epochs = parms['epochs']
    lRate = parms['lRate']
    numSamples = parms['numSamples']

    minLoss = 10
    maxLoss = -10
    criterion = torch.nn.MSELoss(reduction='sum')
    optimizer = torch.optim.Adam(cohModel.parameters(), lr=lRate)
    for epoch in range(epochs):
        cohIn, cohTarget = createRcCohTargets(tmModels,relModel,numSamples,rmRange=1.)
        optimizer.zero_grad()
        cohOut = cohModel(cohIn)
        loss = criterion(cohOut, cohTarget)
        (loss).backward()
        optimizer.step()
        minLoss = min(loss.data.item(), minLoss)
        maxLoss = max(loss.data.item(), maxLoss)

        if (epoch)%(epochs/10) == 0:
            print("Epoch {0:,.0f} - loss: {1:,.5f}".format(epoch, loss.data.item()))
    print("Epoch {0:,.0f} - loss: {1:,.5f}:{2:,.5f}".format(epoch+1, minLoss, maxLoss))

    return
```

```python
'''
2. Initialize coherence model

'''

#initialize network
manualSeed = 1
np.random.seed(manualSeed)
torch.manual_seed(manualSeed)

cohModel = NetEEL([3,5,3,2],'cohModel')
cohModel.train()

# device = torch.device("cuda:0")
device = torch.device("cpu")
if device:
    tmModel0.to(device)
    tmModel1.to(device)
    relModel.to(device)
    cohModel.to(device)
    print("Transfer to use ", device)

tmModel0.shape,relModel.shape, cohModel.shape
```

    Transfer to use  cpu

    ([2, 10, 15, 21], [21, 15, 10, 3], [3, 5, 3, 2])

```python
'''
3. Train coherence model

'''
start = time.time()

parms={ 'epochs':100000,'lRate':.0001,'numSamples':1,'device':device}
trainRcCoh([tmModel0,tmModel1],relModel,cohModel,parms)

print('time: ', time.time()-start)
cohModel.train = False
```

    Epoch 0 - loss: 0.00082
    Epoch 10,000 - loss: 0.00080
    Epoch 20,000 - loss: 0.00010
    Epoch 30,000 - loss: 0.00038
    Epoch 40,000 - loss: 0.00098
    Epoch 50,000 - loss: 0.00027
    Epoch 60,000 - loss: 0.00200
    Epoch 70,000 - loss: 0.00039
    Epoch 80,000 - loss: 0.00003
    Epoch 90,000 - loss: 0.00056
    Epoch 100,000 - loss: 0.00000:0.43873
    time:  109.1226737499237

```python
'''
parms={ 'epochs':100000,'lRate':.01,'numSamples':1,'device':device}
Epoch 100,000 - loss: 0.00000:3.36957
parms={ 'epochs':100000,'lRate':.005,'numSamples':1,'device':device}
Epoch 100,000 - loss: 0.00000:0.43895
parms={ 'epochs':100000,'lRate':.001,'numSamples':1,'device':device}
Epoch 100,000 - loss: 0.00000:0.43133
parms={ 'epochs':100000,'lRate':.0005,'numSamples':1,'device':device}
Epoch 100,000 - loss: 0.00000:0.41156
parms={ 'epochs':100000,'lRate':.0001,'numSamples':1,'device':device}
Epoch 100,000 - loss: 0.00000:0.43873

'''
pass
```

```python
'''
4. Push Template date through Relational, Generative and Trained Coherence Models

'''
morphIndices=[0.,0.1,.2,.3,.5,.7,.9,1.0, 1.1]
timeIndices=[0.,0.1,.2,.3,.5,.7,.9,1.0, 1.1]

#sample source templates to get ph samples
relIn = gy.createRmInput(morphIndices, timeIndices, sPermutation=[])
tmOut0 = tmModel0(torch.Tensor(relIn))
tmOut1 = tmModel1(torch.Tensor(relIn))

#use template generated ph samples to feed through contex models
relCout0 = relModel(tmOut0)
relCout1 = relModel(tmOut1)

genOut0 = genModel(relCout0)
genOut1 = genModel(relCout1)

cohOut0 = cohModel(relCout0)
cohOut1 = cohModel(relCout1)

relIn.shape, tmOut0.shape, relCout0.shape, genOut0.shape, cohOut0.shape
```

    ((9, 9, 2),
     torch.Size([9, 9, 21]),
     torch.Size([9, 9, 3]),
     torch.Size([9, 9, 21]),
     torch.Size([9, 9, 2]))

```python
'''
5. Graph context values over morphology for different time values

'''
#relCout0 for 0-8 time intervals
gTp0T0 = np.array([relIn[0,:,1],relCout0[0,:,2].detach().numpy()])
gTp0Tp2 = np.array([relIn[2,:,1],relCout0[2,:,2].detach().numpy()])
gTp0Tp5 = np.array([relIn[4,:,1],relCout0[4,:,2].detach().numpy()])
gTp0Tp9 = np.array([relIn[6,:,1],relCout0[6,:,2].detach().numpy()])
gTp0T1 = np.array([relIn[7,:,1],relCout0[7,:,2].detach().numpy()])
gTp0T1p1 = np.array([relIn[8,:,1],relCout0[8,:,2].detach().numpy()])

#relCout1 for 0-8 time intervals
gTp1T0 = np.array([relIn[0,:,1],relCout1[0,:,2].detach().numpy()])
gTp1Tp2 = np.array([relIn[2,:,1],relCout1[2,:,2].detach().numpy()])
gTp1Tp5 = np.array([relIn[4,:,1],relCout1[4,:,2].detach().numpy()])
gTp1Tp9 = np.array([relIn[6,:,1],relCout1[6,:,2].detach().numpy()])
gTp1T1 = np.array([relIn[7,:,1],relCout1[7,:,2].detach().numpy()])
gTp1T1p1 = np.array([relIn[8,:,1],relCout1[8,:,2].detach().numpy()])

gx = [gTp0T0, gTp1T0, gTp0Tp2, gTp1Tp2, gTp0Tp5, gTp1Tp5,
    gTp0Tp9, gTp1Tp9,gTp0T1p1,gTp1T1p1]

```

```python
layout = go.Layout(
    title = 'Initial Curves',
    height = 700,
    width = 800,
    hovermode= 'closest',
    xaxis= dict(
        title= 'Morphology',
        range = [-.1,1.5],
    ),
    yaxis=dict(
        title= 'Context',
        range = [-.6,1.],
    )
)
layout.title = 'Context values over morphology for different time values'
f = gy.graphCurves(gx, layout, labels=['gTp0T0','gTp1T0', 'gTp0Tp2', 'gTp1Tp2', 'gTp0T0p5', 'gTp1T0p5',
                                       'gTp0T0p9','gTp1T0p9',  'gTp0T0_1p1','gTp1T0_1p1'],
                   dashRange=gy.rangeList([range(2,4),range(6,8)]))

offline.iplot(f)

```

```python
'''
6. Calculate coherence values for graphing over morphology

'''

t=8
#structure context values for graphing
con0 = np.array([relIn[t,:,1],relCout0[t,:,2].data.numpy()])
con1 = np.array([relIn[t,:,1],relCout1[t,:,2].data.numpy()])

#coherence values
#reshape into a 1,1,9 list of 9 coh values
# ch0 = np.reshape(cohOut0[t].data.numpy(), [1,2,-1])
# ch1 = np.reshape(cohOut1[t].data.numpy(), [1,2,-1])
ch0 = np.reshape(cohOut1[t].data.numpy()[:,0],[1,1,-1])
coh0 = np.array([relIn[t,:,1],ch0[0,0]])
cohD0 =  np.array(con0)
cohD0[1,:] = coh0[1,:]+con0[1,:]

```

```python
gxx = [con0,con1,coh0,cohD0]
layout = go.Layout(
    title = 'Initial Curves',
    height = 600,
    width = 600,
    hovermode= 'closest',
    xaxis= dict(
        title= 'X',
        range = [-.4,1.2],
    ),
    yaxis=dict(
        title= 'Y',
        range = [-.4,.5],
    )
)
fxx = gy.graphCurves(gxx, layout, labels=['context0','context1','coherence0','coherence1', 'con0+coh0','con1+coh1'], dashRange=range(0,2))
offline.iplot(fxx)
```

```python
'''
7. Graph coherence field for time values

'''

#create a matrix of mesh values
mm,cc = np.meshgrid(np.arange(-0., 1.1, .1), np.arange(-.5, .9, .1))

t = .9
ma = np.array(mm)
ca = np.array(cc)
xList = []
yList = []
uList = []
vList = []
for mi in range(ma.shape[0]):
    for ci in range(ca.shape[1]):
        cIn = torch.Tensor([[t,ma[mi,ci], ca[mi,ci]]])
        cohOut = cohModel(cIn)
        x = ma[mi,ci]
        y = ca[mi,ci]
        u = 0
        v = cohOut.data.numpy()[0,0] #extract value 0.0716
        xList.append(x)
        yList.append(y)
        uList.append(u)
        vList.append(v)

xx = np.array(xList)
yy = np.array(yList)
uu = np.array(uList)
vv = np.array(vList)

fig0 = ff.create_quiver(xx, yy, uu, vv,
       scale=.4,
       arrow_scale=.4,
       name='quiver'
       #,line=dict(width=2)
      )

fig0.layout.title = 'Coherence field for time ' + str(t)
fig0.add_trace(f.data[6])
fig0.add_trace(f.data[7])
offline.iplot(fig0)

```

```python
#create a matrix of mesh values
mm,cc = np.meshgrid(np.arange(-0., 1.1, .1), np.arange(-.5, .9, .1))

t = .5
ma = np.array(mm)
ca = np.array(cc)
xList = []
yList = []
uList = []
vList = []
for mi in range(ma.shape[0]):
    for ci in range(ca.shape[1]):
#         print('({},{})'.format(mi,ci))
        cIn = torch.Tensor([[t,ma[mi,ci], ca[mi,ci]]])
        cohOut = cohModel(cIn)
        x = ma[mi,ci]
        y = ca[mi,ci]
        u = 0
        v = cohOut.data.numpy()[0,0] #extract value 0.0716
        xList.append(x)
        yList.append(y)
        uList.append(u)
        vList.append(v)
#         print('xy[{}, {}], uv[{}, {}]'.format(x,y,u,v))

xx = np.array(xList)
yy = np.array(yList)
uu = np.array(uList)
vv = np.array(vList)

fig0 = ff.create_quiver(xx, yy, uu, vv,
       scale=.4,
       arrow_scale=.4,
       name='quiver'
       #,line=dict(width=2)
      )
fig0.layout.title = 'Coherence field for time ' + str(t)
fig0.add_trace(f.data[4])
fig0.add_trace(f.data[5])
offline.iplot(fig0)

```

```python
#create a matrix of mesh values
mm,cc = np.meshgrid(np.arange(-0., 1.2, .1), np.arange(-.5, 1, .1))

t = .2
ma = np.array(mm)
ca = np.array(cc)
xList = []
yList = []
uList = []
vList = []
for mi in range(ma.shape[0]):
    for ci in range(ca.shape[1]):
        cIn = torch.Tensor([[t,ma[mi,ci], ca[mi,ci]]])
        cohOut = cohModel(cIn)
        x = ma[mi,ci]
        y = ca[mi,ci]
        u = 0
        v = cohOut.data.numpy()[0,0] #extract value 0.0716
        xList.append(x)
        yList.append(y)
        uList.append(u)
        vList.append(v)

xx = np.array(xList)
yy = np.array(yList)
uu = np.array(uList)
vv = np.array(vList)

fig0 = ff.create_quiver(xx, yy, uu, vv,
       scale=.4,
       arrow_scale=.4,
       name='quiver'
       #,line=dict(width=2)
      )

fig0.layout.title = 'Coherence field for time ' + str(t)
fig0.add_trace(f.data[2])
fig0.add_trace(f.data[3])
offline.iplot(fig0)

```

```python
#create a matrix of mesh values
mm,cc = np.meshgrid(np.arange(-0., 1.2, .1), np.arange(-.5, 1, .1))

t = 0
ma = np.array(mm)
ca = np.array(cc)
xList = []
yList = []
uList = []
vList = []
for mi in range(ma.shape[0]):
    for ci in range(ca.shape[1]):
        cIn = torch.Tensor([[t,ma[mi,ci], ca[mi,ci]]])
        cohOut = cohModel(cIn)
        x = ma[mi,ci]
        y = ca[mi,ci]
        u = 0
        v = cohOut.data.numpy()[0,0] #extract value 0.0716
        xList.append(x)
        yList.append(y)
        uList.append(u)
        vList.append(v)

xx = np.array(xList)
yy = np.array(yList)
uu = np.array(uList)
vv = np.array(vList)

fig0 = ff.create_quiver(xx, yy, uu, vv,
       scale=.4,
       arrow_scale=.4,
       name='quiver'
       #,line=dict(width=2)
      )

fig0.layout.title = 'Coherence field for time ' + str(t)
fig0.add_trace(f.data[0])
fig0.add_trace(f.data[1])
offline.iplot(fig0)

```

```python
'''
7. Save two template interpolation model to file

'''

# PATH = CURRENT_MODEL_SAVE_DIR +'GEN PhRcPh-T0T1-mask 11p05.pth'
# torch.save(phModel.state_dict(), PATH)
# PATH = CURRENT_MODEL_SAVE_DIR +'REL PhRc-T0T1-mask 11p05.pth'
# torch.save(geModel.state_dict(), PATH)

PATH = CURRENT_MODEL_SAVE_DIR +'cohModel.pth'
torch.save(cohModel.state_dict(), PATH)
PATH
```

    './save/cohModel.pth'

```python
'''
9. Generate blender data from template interpolation model output

#phOut.shape torch.Size([10, 7, 23])

'''

# phOut = phModel(torch.Tensor(torch.Tensor(inpt0)))

# x = phOut.detach().numpy()
# fb = gy.FormatNetworkToBlender(x, xOffset=0, yOffset=7, zOffset=14, colPoints=7)

# with open(CURRENT_MODEL_SAVE_DIR +'btest.json', 'w') as fp:
#     json.dump(fb.tolist(),fp)

# x.shape, fb.shape
```


# Determining initial $T_{\rm eff}$ and luminosity for DMESTAR seed polytropes

Currently, we are having difficulty with models in the mass range of $0.14 M_{\odot}$ -- $0.22 M_{\odot}$ not converging after an initial relaxation. There are several potential candidates for why the models are not converging. The first is FreeEOS is running with a set of plasma properties (pressure, temperature) that are outside of it's typical working range. I suspect this is not the case, as lower mass models converge properly, despite having cooler temperatures. Other potential candidates are the seed luminosity and $T_{\rm eff}$ supplied to $\texttt{newpoly}$ for computation of an initial polytrope model that DMESTAR then relaxes before a full stellar evolution run. To test this idea, we can compare model properties for the seed polytropes with the final relaxed quantities determined by DMESTAR.

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

## Current seed values

Scripts used to generate a new polytrope for DMESTAR models rely on a piece-wise function to generate an appropriate combination of $T_{\rm eff}$ and luminosity for a model based on the requested stellar mass and solar composition. That piece-wise function is
\begin{align}
    \log(T) & = 3.64 & M \ge 3.9 \\
    \log(L) & = 0.2\cdot (M - 5.0) + 2.6 & \\
     & \\
    \log(T) & = -0.028\cdot M + 3.875 & 3.9 > M \ge 3.0 \\
    \log(L) & =  0.55 \cdot M + 0.1   & \\
     & \\
    \log(T) & =  0.039\cdot M + 3.5765 & 3.0 > M \ge 1.5 \\
    \log(L) & =  1.7  & \\
     & \\
    \log(T) & =  0.039\cdot M + 3.5765 & 1.5 > M \ge 0.23 \\
    \log(L) & =  0.85\cdot M + 0.4  & \\
     & \\
    \log(T) & =  0.614\cdot M + 3.3863 & 0.23 > M \\
    \log(L) & = -0.16877\cdot M - 0.117637  & \\
\end{align}
While models with masses below $0.23 M$ are found to converge, the greatest issues occur right in the vicinity of the final piecewise condition. We can view this graphically,

```python
fig, ax = plt.subplots(2, 1, figsize=(8, 8))

masses = np.arange(0.08, 5.0, 0.02)

# compute and plot temperature relationship
p1 = [3.64 for m in masses if m >= 3.9]
p2 = [-0.028*m + 3.875 for m in masses if 3.9 > m >= 3.0]
p3 = [0.039*m + 3.5765 for m in masses if 3.0 > m >= 0.23]
p4 = [0.614*m + 3.3863 for m in masses if m < 0.23]
tr = p4 + p3 + p2 + p1

ax[0].set_xlabel("initial mass [Msun]")
ax[0].set_ylabel("log(T / K)")

ax[0].plot(masses, tr, '-', c='#dc143c', lw=3)

# plot luminosity relationship
# compute and plot temperature relationship
p1 = [0.2*(m - 5.0) + 2.6 for m in masses if m >= 3.9]
p2 = [0.55*m + 0.1 for m in masses if 3.9 > m >= 3.0]
p3 = [1.7 for m in masses if 3.0 > m >= 1.5]
p4 = [0.85*m + 0.4 for m in masses if 1.5 > m >= 0.23]
p5 = [-0.16877*m - 0.117637 for m in masses if m < 0.23]
lr = p5 + p4 + p3 + p2 + p1

ax[1].set_xlabel("initial mass [Msun]")
ax[1].set_ylabel("log(L / Lsun)")

ax[1].plot(masses, lr, '-', c='#dc143c', lw=3)
```

## Relaxed model values

We can compare the relationship(s) quoted above with model values for temperature and luminosity after the model has relaxed to a stable configuration. This takes only a couple time steps to achieve, so we will look at the model relationship during the third time step for all models with masses between 0.08 and 5.0 Msun. Models are taken from a recent study where we used the most up-to-date version of the Dartmouth models for young stars (Feiden 2016).

```python
model_directory = "../../papers/MagneticUpperSco/models/trk/std/"

# get all file names
from os import listdir
all_fnames = listdir(model_directory)

# sort out only those file names that end in .trk
fnames = [f for f in all_fnames if f[-4:] == ".trk"]

# sort numerically
fnames = sorted(fnames)
```

To select which model time step is most representative of a relaxed model, we can step through the first 50 iterations to find if there are any noticable jumps in model properties.

```python
fig, ax = plt.subplots(2, 1, figsize=(8, 8))

model_props = np.empty((len(fnames), 3))
for j in range(0, 50):
    for i, f in enumerate(fnames):
        model_props[i, 0] = float(f[1:5])/1000.0

        try:
            trk = np.genfromtxt(model_directory + f, usecols=(0, 1, 2, 3))
        except ValueError:
            model_props[i, 1] = 0.0    # temperature
            model_props[i, 2] = 0.0    # luminosity
            continue

        model_props[i, 1] = trk[j, 1]  # temperature
        model_props[i, 2] = trk[j, 3]  # luminosity

    ax[0].semilogx(model_props[:,0], model_props[:,1], '-', c='#008b8b', lw=3)
    ax[1].semilogx(model_props[:,0], model_props[:,2], '-', c='#008b8b', lw=3)

```

We can now iterate through these filenames and save the third timestep to an array.

```python
model_props = np.empty((len(fnames), 3))
for i, f in enumerate(fnames):
    model_props[i, 0] = float(f[1:5])/1000.0

    try:
        trk = np.genfromtxt(model_directory + f, usecols=(0, 1, 2, 3))
    except ValueError:
        model_props[i, 1] = 0.0    # temperature
        model_props[i, 2] = 0.0    # luminosity
        continue

    model_props[i, 1] = trk[1, 1]  # temperature
    model_props[i, 2] = trk[1, 3]  # luminosity
```

Plotting these two relations, we can compare against the function used to generate the polytrope seed model.

```python
fig, ax = plt.subplots(2, 1, figsize=(8, 8))

masses = np.arange(0.08, 5.0, 0.02)

ax[0].set_xlabel("initial mass [Msun]")
ax[0].set_ylabel("log(T / K)")

ax[0].semilogx(model_props[:,0], model_props[:,1], '-', c='#008b8b', lw=3)
ax[0].semilogx(masses, tr, '-', c='#dc143c', lw=3)

ax[1].set_xlabel("initial mass [Msun]")
ax[1].set_ylabel("log(L / Lsun)")

ax[1].semilogx(model_props[:,0], model_props[:,2], '-', c='#008b8b', lw=3)
ax[1].semilogx(masses, lr, '-', c='#dc143c', lw=3)
```

There are clear discrepancies, particularly in the low-mass regime. However, we note there are significant differences in relaxed effective temperatures starting around 1.5 solar masses. Luminosities tend to trace the relaxed models quite well until approximately 0.4 Msun. Since these are logarithmic values, noticeable differences are quite sizeable when it comes to model adjustments during runtime. It's quite likely that corrections will exceed tolerances in the allowed parameter adjustments during a model's evolution.

### Effective temperature

```python
tp1 = np.array([line for line in model_props if line[0] < 0.23])
tp2 = np.array([line for line in model_props if 0.23 <= line[0] < 1.5])

tpoly1 = np.polyfit(tp1[:,0], tp1[:,1], 2)
tpoly2 = np.polyfit(tp2[:,0], tp2[:,1], 3)
```

```python
fig, ax = plt.subplots(1, 1, figsize=(8, 4))

ax.semilogx(tp1[:,0], tp1[:,1], '-', c='#008b8b', lw=3)
ax.semilogx(tp2[:,0], tp2[:,1], '-', c='#008b8b', lw=3)
ax.semilogx(tp1[:,0], tpoly1[0]*tp1[:,0]**2 + tpoly1[1]*tp1[:,0] + tpoly1[2], '--', c='black', lw=3)
ax.semilogx(tp2[:,0], tpoly2[0]*tp2[:,0]**3 + tpoly2[1]*tp2[:,0]**2 + tpoly2[2]*tp2[:,0] + tpoly2[3], '--', c='black', lw=3)
```

### Luminosity

Above 1.5 Msun, there appear to be very little deviations of the true model sequence from the initial seed model sequence. We can thus leave this parameteriztion alone. Below 1.5 Msun, we can alter the shape of the relationship down to 0.23 Msun. In addition, we can prescribe a new shape to the relationship for objects with masses below 0.23 Msun.

```python
p1 = np.array([line for line in model_props if line[0] < 0.23])
p2 = np.array([line for line in model_props if 0.23 <= line[0] < 1.5])

poly1 = np.polyfit(p1[:,0], p1[:,2], 2)
poly2 = np.polyfit(p2[:,0], p2[:,2], 2)
```

```python
fig, ax = plt.subplots(1, 1, figsize=(8, 4))

ax.semilogx(p1[:,0], p1[:,2], '-', c='#008b8b', lw=3)
ax.semilogx(p2[:,0], p2[:,2], '-', c='#008b8b', lw=3)
ax.semilogx(p1[:,0], poly1[0]*p1[:,0]**2 + poly1[1]*p1[:,0] + poly1[2], '--', c='black', lw=3)
ax.semilogx(p2[:,0], poly2[0]*p2[:,0]**2 + poly2[1]*p2[:,0] + poly2[2], '--', c='black', lw=3)
```

## Implementation

These new fits better represent the relaxed models, but will they work when implemented as seed values?

```python
print "log(T) and log(L) Coefficients for the lowest mass objects: \n", tpoly1, poly1
print "\n\nlog(T) and log(L) Coefficients for low mass objects: \n", tpoly2, poly2
```

    log(T) and log(L) Coefficients for the lowest mass objects:
    [-1.27192877  0.9650018   3.36316571] [-14.33705482   5.09735054  -0.6592411 ]

    log(T) and log(L) Coefficients for low mass objects:
    [ 0.11365404 -0.38799192  0.45719128  3.45458154] [-0.45439653  1.7710227   0.06103889]

The new parameterization had no influence over model convergence.

## UPDATE

Error appears to have been due to a value (JCORE) being read or set to zero erroneously. Since it is used to define an array index later in the routine (STARIN), a memory reference error was thrown and SEGFAULT initiated. A temporary fix has been issued.

```python

```


**Playing with Maxwell's Equations** / Holly C. Gibbs, Ph.D., Texas A&M University /
BMEN 608: Biophotonics II

Reference material:
A Student's Guide to Maxwell's Equations by Daniel Fleisch /
Linked pages

Behold the Maxwell's equations! **WARNING:** Do not stare directly at their magnificent beauty.

\begin{align}
    \vec{\nabla} \cdot  \vec{D} &= \rho \\
    \vec{\nabla} \cdot  \vec{B} &= 0   \\
    \vec{\nabla} \times \vec{E} &= - \frac{\partial \vec{B}}{\partial t} \label{eq:maxwell} \\
    \vec{\nabla} \times \vec{H} &= \vec{J} + \frac{\partial \vec{D}}{\partial t} \,
\end{align}

These equations describe the foundation of classical electromagnetism, classical meaning "not-so-tiny-that-we-need-quantum-mechanics" to approximate the behavior of electric and magnetic fields. To understand them, you need to know what each symbol represents. If you've forgotten these, a reasonable interactive/conceptual overview of both the physical quantities and the vector calculus operators can be found [here](http://www.maxwells-equations.com/). Don't neglect the differences between $\vec{D}$ and $\vec{E}$, which are related by the electric permitivitty, $\epsilon$, and $\vec{B}$ and $\vec{H}$, which are related by the magnetic permeability, $\mu$.

**Section 1: Gauss' Law**

***Integral form.*** Let's play with these equations one at a time to get to know them. In fact, the above expressions are only one of myriad ways these relations can be written.
The first is **Gauss' Law**. You were probably introduced to it during an introductory physics course in it's integral form, since you may not have known any vector calculus at the time. Here it is in free space.

\begin{align}
    \int\limits_S \vec{E} \cdot \vec{dA} &= \frac{q_{enclosed}}{\epsilon_0}
\end{align}

The above equation says that **if you add up all the electric field, $\vec{E}$, over a surface the quantity is proportional to the charge inside the surface, $q_{enclosed}$**. The term $\vec{dA}$ is just shorthand for $\hat{n}\times dA$, which means that prior to integration, $\vec{E}$ is projected onto the surface normal vector, $\hat{n}$ of the infinitesimally small area elements of the surface, $dA$. If you're going to use this equation, you should definitely choose a surface that is easy to integrate over!

It's an important expression because it's generally easier for us to detect $\vec{E}$, often by way of voltage, $V$, (recall $\vec{E}$= -$\nabla V$ for conservative fields), and use that information to calculate $q_{enclosed}$. It also makes a lot of sense, since electric fields, when not busy radiating through space as an electromagnetic wave, originate on positive charges and terminate on negative charges.

[Here](https://phet.colorado.edu/sims/html/charges-and-fields/latest/charges-and-fields_en.html) is a webapp where you can play around with different configurations of point charges and see what $\vec{E}$ looks like. The simplest example presented typically is that of a single point charge, $q$, and it's $\vec{E}$ that radially-decreases as $1/r^2$. This relationship is typically presented with some reference to gravitational force, which also decreases as $1/r^2$.

\begin{align}
    \vec{E}  &= \frac{1}{4 \pi \epsilon_0} \frac{q}{r^2} \hat{r}
\end{align}

***Exercise 1.1*** In the cell below, write a python program to calculate and visualize the electric field, $\vec{E}$, and scalar voltage potential field, $V$ from an electric dipole ($+q$,$-q$).

```python

```

**Notice in the solution that areas of high potential (red, positive charges) are sources of $\vec{E}$ field lines that flow to areas of low potential (blue, negative charges). This is a visualization of the relationship $\vec{E}$= -$\nabla V$.** The visualization, though correct, certainly has some undesirable features. [Here](https://pythonmatplotlibtips.blogspot.com/2017/12/draw-beautiful-electric-field-lines.html) is a more elegant example if you'd like to geek out on the coding.

***Discussion Questions.***

1. What happens if you make both charges positive? Negative?

2. Move them farther apart or closer together?

3. Any better options for dealing with the infinity at r=r_point?

4. What would be a reasonable way to add more charges to the simulation?

5. What if you change the permittivity? ($\epsilon_r=\epsilon$ / $\epsilon_0$ for rubber is 7, for water is 80, for gold is 6.7)

6. What does it mean that all relative permittivity are > 1?

7. This is a static problem. What might happen if it is dynamic?

***Side note.*** The electric dipole will be an important concept later when we talk about waves and how electromagnetic waves induce electric dipoles in materials they propagate through.

***To the derivative form of Gauss' Law.*** Now, if you wanted, you could write this tiny simulation in three dimensions with spherical coordinates (I might just for fun!), but perhaps you have wondered where this expression for the field of a point charge comes from in the first place. Well, if you evaluate the integral form of Gauss' Law, choosing a sphere as your Gaussian surface to place around the charge, your integral will simply produce the surface area of a sphere multiplied by a factor of $q_{enclosed}$/$\epsilon_0$. That is how we know the field of a point charge, aside from experimental measurement.

The concept of divergence was introduced to do away with the concept of flux through imaginary surfaces and just worry about the flux at individual points in space (fields). So, of course the definition of divergence of a vector field, $\vec{A}$ includes taking the limit of the flux integral as $\Delta v \to 0$.

\begin{align}
    \vec{\nabla} \cdot \vec{A} \equiv \lim\limits_{\Delta v \to 0} \int\limits_S \vec{A} \cdot \vec{dA}
\end{align}

If we take take the limit as $\Delta v \to 0$ for the right hand side of the integral form of Gauss' Law as well (really, we are applying the Divergence Theorem), we will have the definition of charge density, $\rho$, divided by $\epsilon_0$. So, the way Gauss' law will now look is:

\begin{align}
\vec{\nabla} \cdot  \vec{E} &= \frac{\rho}{\epsilon_0}
\end{align}

The above expression is very close to the original Gauss' Law we presented. To recover that expression, we need only realize that $\vec{D} = \epsilon \vec{E}$, and since we have been discussing point charges in free space, $\vec{D} = \epsilon_0 \vec{E}$. So, we have:

\begin{align}
    \vec{\nabla} \cdot  \vec{D} &= \rho \\
\end{align}

A good way to read this expression is to say that **charges in space give rise to electric fields with non-zero divergence**. Where exactly in the fields is the divergence non-zero? At the location of the charges. It will be positive (by convention) where there are positive charges and negative where there are negative charges.

***Exercise 2.1*** In the cell below, develop a python program that will do the opposite of Exercise 1, that is, from a given potential field $V$, find $\vec{E}$ and $\rho$. You can use a symbolic libray (eg. SymPy) if you like.

```python

```

In this example I chose a line of charge, knowing the function of the voltage a priori from analytical solution with Gauss' Law.

***Discussion Questions.***

1. How did you choose how to represent $V$?

2. What approach did you take to finding $\vec{E}$?

3. Does the description you found for $\rho$ make sense?

**Section 2: Gauss' Law for Magnetism**

The same discussion of integral v. derivative form we had for Gauss' Law (for Electricity) applies for Gauss' Law for Magnetism. What it implies is that **you will never find a point in space (free space or in any material) that has more or less magnetic field going in than going out, which means there is no such thing as magnetic north particles and magnetic south particles.**

\begin{align}
\vec{\nabla} \cdot  \vec{B} &= 0   \\
\end{align}

***Exercise 2.1*** In the cell below, visualize the vector field $\vec{A} = \sin(\frac{\pi}{2}y)\hat{i} - \sin(\frac{\pi}{2}x)\hat{j}$ and calculate its divergence.

```python

```

***Discussion Questions.***

1. What does it mean that this field has no divergence?

2. What are some examples of fields that behave like this?

3. Go back and also take the curl of the field.

4. Where is the curl non-zero?

5. What is the direction the curl is pointing and why?

6. In the case of a magnetic field, where would you expect the curl to be non-zero?

Even though fundamental magnetic particles don't exist, we know magnetic fields come from somewhere. We'll talk about the sources of magnetic fields (moving charge and displacement current) in Section 4 on the Ampere-Maxwell Law.

**Section 3: Faraday's Law**

***Integral form.*** We are moving now to the dynamic part of Maxwell's equations where fields that vary as a function of time produce other fields. Michael Faraday discovered this phenomenon experimentally when he produced a time-varying magnetic field inside an electric coil. You can try a virtual version of his experiment [here](https://micro.magnet.fsu.edu/electromag/java/faraday/). Maxwell later formalized the mathematical nature of the induction of electricity by magnetism observed by Faraday into a physical law, represented in integral form below.

\begin{align}
    \int\limits_C \vec{E} \cdot \vec{dl} &= -\int\limits_S \frac{\partial \vec{B}}{\partial t}  \cdot \vec{dS}
\end{align}

The lefthand side of the above equation describes the sum of an electric field around a closed curve. A potentially relevant curve is a coil of wire. If such a conductive coil had an electric field circulating in it, that would drive a current through the coil according to the relationship $\vec{J}=\sigma\vec{E}$. The right hand side of the equation describes the rate of change in time of the flux of magnetic field through the surface, $S$ enclosed by curve $C$.

***Exercise 3.1*** Examine this interactive [experiment](https://micro.magnet.fsu.edu/electromag/java/faraday2/) and answer the following questions.

***Discussion Questions.***

1. How is a current induced in the coil?

2. Explain the sign of the induced current (hint: recall Lenz' Law)

3. Explain the magnitude of the induced current.

4. What else could be changed abou the experiment to change the magnetic flux through the loop as a function of time?

***Derivative form.*** In Biophotonics, we'll often be more interested in how much $\vec{E}$ is circulating around each point in space rather than around a specific loop, so we'd like to have this quantity expressed as a field. The operator for this is the curl operator, is defined as:

\begin{align}
\vec{\nabla} \times \vec{A} \cdot \hat{n} \equiv \lim\limits_{\Delta s \to 0} \frac{1}{\Delta s}\int\limits_C \vec{A} \cdot \vec{dl}
\end{align}

The equation above says that the curl of a vector field $\vec{A}$ is the circulation per unit area along an infinitesimal path, with $\hat{n}$ pointing in the direction that the circulation is maximized. Here we should also mention Stoke's theorem that says:

\begin{align}
\int\limits_C \vec{A} \cdot \vec{dl} = \int\limits_S (\vec{\nabla} \times \vec{A})\cdot \vec{dS}
\end{align}

Using Stoke's theorem to represent the left hand side of Faraday's law, we will have the relationship:

\begin{align}
\int\limits_S (\vec{\nabla} \times \vec{E})\cdot \vec{dS} = -\int\limits_S \frac{\partial \vec{B}}{\partial t}  \cdot \vec{dS}
\end{align}

Then by integrating both sides over the surface, we are led back to the form we have already seen:

\begin{align}
\vec{\nabla} \times \vec{E} &= - \frac{\partial \vec{B}}{\partial t}
\end{align}

This expression, which says that **a time-varying magnetic field produces a (n orthogonal) electric field is critical for the propagation of electromagnetic waves.**

***Exercise 3.2*** Given the magnetic field $\vec{B}(t) = B_0\cos(kz-\omega t)\hat{j}$, and a priori knowledge that $E_z=0$, find $E_x$. You can use python or work the problem by hand, but graph the results in python.

```python

```

**Section 4: Ampere-Maxwell Law**

***Integral form.*** The last of Maxwell's equations describes how currents, both real and imagined, give rise to magnetic fields. I say "real current" in the sense of moving charged particles. By "imagined current", I mean a physical quantity that is not strictly a current (but is certainly not imaginary) but is rather a moving (time-varying) electric field which is referred to as displacement current.

\begin{align}
    \int\limits_C \vec{B}\cdot\vec{dl} = \mu_0(I_{enc} + \epsilon_0\frac{\partial}{\partial t}\int\limits_S\vec{E}\cdot\vec{dA})
\end{align}

Usually this law is introduced initially worrying only about the contribution of current, $I_{enc}$, as a source of magnetic fields, $\vec{B}$, as historically this was the first part of the law to be understood. [Here](https://sites.google.com/a/nd.edu/nd-physics-demo-website/about/magnetic-fields-and-forces/magnetic-field-around-a-current-carrying-wire) you can see a recreation of the way in which it was discovered that currents that flow in wires create curling magnetic fields around them.

***Exercise 4.1*** Find a mathematical expression for the magnetic field around a current carrying wire and rework exercise 2.1 with this field.

```python

```

In Exercise 4.1 you will hopefully have arrived at the expression $\vec{B} = \frac{\mu_0 I_{enc}}{2\pi r}\hat{\theta}$ where $\hat{\theta}$ is orthogonal to $\hat{z}$ and $\hat{r}$ which describe the direction of current flow and radius from the wire, respectively. The magnitude of $\vec{B}$ will be highest close to the wire and decrease in magnitude as $1/r$. Hopefully you haven't forgotten that this field will exhert a force on moving charged particles (recall the Lorentz force equation $\vec{F}=q(\vec{E}+v\times\vec{B})$. What would happen with two wires near each other carrying a current? For a reminder, watch [this](https://www.youtube.com/watch?v=43AeuDvWc0k) video.

***Toward the derivative version of the Ampere-Maxwell Law.*** There are different ways to show that this law is incomplete without the time-varying field component. One way is to demonstrate that I can draw a curve around a current-carrying wire but place the surface bound to that curve in the middle of a capacitor. The current through the surface, by the strict definition of moving charge, is zero, but the magnetic field is not. Something is missing. Another way is evident from what we know about vector calculus and conservation of charge. With a similar approach to how we went from integral to derivative from of Faraday's law, we can show that

\begin{align}
    \int\limits_C \vec{B}\cdot\vec{dl} = \mu_0 I_{enc}
\end{align}

is equivalent to

\begin{align}
\vec{\nabla} \times \vec{H} &= \vec{J}
\end{align}

where $\vec{J}$ is the current density (of which the sum over an area is $I_{enc}$) and $\vec{H}=\frac{\vec{B}}{\mu_0}$, and $\mu_0$ is the permeability of free space. Maxwell noticed a curious problem when you take the divergence of both sides of this equation (Recall that the divergence of the curl of a vector field is zero).

\begin{align}
\vec{\nabla} \cdot (\vec{\nabla} \times \vec{H}) &= \vec{\nabla} \cdot \vec{J} = 0
\end{align}

Everyone acknowledged that $\vec{\nabla} \cdot \vec{J} = 0$ was wrong because of conservation of charge which predicts the continuity equation which states

\begin{align}
\vec{\nabla} \cdot \vec{J} = - \frac{\partial \vec{\rho}}{\partial t}
\end{align}

that is, that current flows from points where the charge density changes as a function of time and flow out is the same as the flow in. Maxwell proposed the missing term was the variation in the electric displacement field as a function of time and added it accordingly, giving us

\begin{align}
\vec{\nabla} \times \vec{H} &= \vec{J} + \frac{\partial \vec{D}}{\partial t}
\end{align}

He had many skeptics until this extra term led him to a wave-like solution to the larger set of equations that predicted the wave would travel with the same speed as the speed that had been measured for light. This astounding prediction won most over to Maxwell's displacement current term and the theory that light was electromagnetic radiation.


This notebook is part of https://github.com/AudioSceneDescriptionFormat/splines, see also https://splines.readthedocs.io/.

[back to overview](catmull-rom.ipynb)

# Uniform Catmull-Rom Splines

In Catmull and Rom (1974),
a class of splines is presented which can be,
in its most generic form,
described mathematically with what is referred to as equation (1):

\begin{equation*}
F(s) =
\frac{
\sum x_i(s) w_i(s)
}{
\sum w_i(s)
},
\end{equation*}
where the part $w_i(s)/\sum w_i(s)$ is called *blending functions*.

> Since the blending functions presented above are,
as of now, completely
arbitrary
we impose
some
constraints in order to make them easier to use. We
shall deal only with blending functions that are zero
outside of some given interval.
Also we require that
$\sum w_i(s)$ does not vanish for any $s$.
We shall normalize $w_i(s)$ so that
$\sum w_i(s) = 1$ for all $s$.
>
> ---Catmull and Rom (1974), section 3, "Blending Functions"

The components of the equation are further constrained
to produce a interpolating function:

> Consider the following case:
Let $x_i(s)$ be any function
interpolating the points $p_i$ through $p_{i+k}$
and let $w_i(s)$ be zero outside $(s_{i-1}, s_{i+k+1})$.
The function $F(s)$ defined in equation (1)
will thus be an interpolating function.
Intuitively, this says that if all of the functions
that have an effect at a point,
pass through the point,
then the average of the functions will pass through the point.
>
> ---Catmull and Rom (1974), section 2: "The Model"

Typo Alert

The typo "$p_i$ through $s_{i+k}$" has been fixed in the quote above.

> A polynomial of degree $k$
that pass[e]s through $k+1$ points will be used as $x(s)$.
In general it will not pass through the other points.
If the width of the interval in which $w_i(s)$ is non zero
is less than or equal to $k+2$ then $x_i(s)$ will not affect $F(s)$
outside the interpolation interval.
This means that $F(s)$ will be an interpolating function.
On the other hand
if the width of $w_i(s)$ is greater than $k+2$
then $x_i(s)$ will have an effect on the curve
outside the interpolation interval.
$F(s)$ will then be an approximating function.
>
> ---Catmull and Rom (1974), section 2: "The Model"

After limiting the scope of the paper to *interpolating* splines,
it is further reduced to *uniform* splines:

> [...] in
the
parametric
space
we can, without
loss
of
generality, place $s_j=j$.
>
> ---Catmull and Rom (1974), section 2: "The Model"

Whether or not generality is lost,
this means that the rest of the paper doesn't give any hints
how to construct non-uniform splines.
For those who are interested anyway,
we show how to do that in
[the notebook about non-uniform Catmull--Rom splines](catmull-rom-non-uniform.ipynb)
and once again in
[the notebook about the Barry--Goldman algorithm](catmull-rom-barry-goldman.ipynb).

After the aforementioned constraints
and the definition of the term *cardinal function* ...

> Cardinal function: a function that is $1$ at some
knot, $0$ at all other knots and can be anything in
between the other knots. It satisfies $F_i(s_j) = \delta_{ij}$.
>
> ---Catmull and Rom (1974), section 1: "Introduction"

... the gratuitously generic equation (1) is made a bit more concrete:

> If in equation (1) we assume $x_i(s)$
to be polynomials of degree $k$
then this equation can be reduced to a much simpler form:
>
> \begin{equation*}
F(s) =
\sum_j p_j C_{jk}(s)
\end{equation*}
> where the $C_{jk}(s)$ are cardinal blending functions
and $j$ is the knot to which the cardinal function and the point belong
and each $C_{jk}(s)$ is a shifted version of $C_{0,k}(s)$.
$C_{0,k}(s)$ is a function of both the degree $k$
of the polynomials and the blending functions $w(s)$:
>
> \begin{equation*}
C_{0,k}(s) =
\sum_{i=0}^k
\Big[
\prod_{\substack{j=i-k\\j \ne 0}}^i \left(\frac{s}{j}+1\right)
\Big]
w(s+i)
\end{equation*}
>
> In essence we see that for a polynomial case
our cardinal functions are a blend of Lagrange polynomials.
When calculating $C_{0,k}(s)$,
$w(s)$ should be centered about $\frac{k}{2}$.
>
> ---Catmull and Rom (1974), section 4: "Calculating Cardinal Functions"

This looks like something we can work with,
even though the blending function $w(s)$ is still not defined.

```python
import sympy as sp
```

We use $t$ instead of $s$:

```python
t = sp.symbols('t')
```

```python
i, j, k = sp.symbols('i j k', integer=True)
```

```python
w = sp.Function('w')
```

```python
C0k = sp.Sum(
        sp.Product(
            sp.Piecewise((1, sp.Eq(j, 0)), ((t / j) + 1, True)),
            (j, i - k, i)) * w(t + i),
        (i, 0, k))
C0k
```

## Blending Functions

Catmull and Rom (1974)
leaves the choice of blending function to the reader.
It shows two plots (figure 1 and figure 3) for a custom blending function
stitched together from two Bézier curves,
but it doesn't show the cardinal function nor an actual spline
created from it.

The only other concrete suggestion is to use B-spline basis functions
as blending functions.
A quadratic B-spline basis function is shown in figure 2
and both cardinal functions and example curves are shown
that utilize both quadratic and cubic B-spline basis functions
(figures 4 through 7).
No mathematical description of B-spline basis functions is given,
instead the paper refers to
Gordon and Riesenfeld (1974).
That paper provides a pair of equations (3.1 and 3.2)
that can be used to recursively construct B-spline basis functions.
Simplified to the *uniform* case,
this leads to the base case (degree 0) ...

```python
B0 = sp.Piecewise((0, t < i), (1, t < i + 1), (0, True))
B0
```

... which can be used to obtain the linear (degree 1) basis functions:

```python
B1 = (t - i) * B0 + (i + 2 - t) * B0.subs(i, i + 1)
```

We can use one of them (where $i = 0$) as blending function:

```python
w1 = B1.subs(i, 0)
```

With some helper functions from [helper.py](helper.py)
we can plot this.

```python
from helper import plot_sympy, grid_lines
```

```python
plot_sympy(w1, (t, -0.2, 2.2))
grid_lines([0, 1, 2], [0, 1])
```

The quadratic (degree 2) basis functions can be obtained like this:

```python
B2 = (t - i) / 2 * B1 + (i + 3 - t) / 2 * B1.subs(i, i + 1)
```

For our further calculations,
we use the function with $i=-1$ as blending function:

```python
w2 = B2.subs(i, -1)
```

```python
plot_sympy(w2, (t, -1.2, 2.2))
grid_lines([-1, 0, 1, 2], [0, 1])
```

This should be the same function as shown in figure 2 of
Catmull and Rom (1974).

## Cardinal Functions

The first example curve in the paper (figure 5)
is a cubic curve,
constructed using
a cardinal function with $k=1$ (i.e. using linear Lagrange interpolation)
and
a quadratic B-spline basis function (as shown above)
as blending function.

With the information so far,
we can construct the cardinal function $C_{0,1}(t)$,
using our *quadratic* B-spline blending function `w2`
(which is, as required, centered about $\frac{k}{2}$):

```python
C01 = C0k.subs(k, 1).replace(w, lambda x: w2.subs(t, x)).doit().simplify()
C01
```

```python
plot_sympy(C01, (t, -2.2, 2.2))
grid_lines(range(-2, 3), [0, 1])
```

This should be the same function as shown in figure 4 of
Catmull and Rom (1974).

The paper does not show that,
but we can also try to flip the respective degrees
of Lagrange interpolation and B-spline blending.
In other words, we can set $k=2$ to construct the
cardinal function $C_{0,2}(t)$,
this time using the *linear* B-spline blending function `w1`
(which is also centered about $\frac{k}{2}$)
leading to a total degree of 3:

```python
C02 = C0k.subs(k, 2).replace(w, lambda x: w1.subs(t, x)).doit().simplify()
```

And as it turns out, this is exactly the same thing!

```python
assert C01 == C02
```

By the way, we come to the same conclusion in our
[notebook about the Barry--Goldman algorithm](catmull-rom-barry-goldman.ipynb),
which means that this is also true in the *non-uniform* case.

Many authors nowadays, when using the term *Catmull--Rom spline*,
mean the cubic spline created using exactly this cardinal function.

As we have seen,
this can be equivalently understood
either as
three linear interpolations
(more exactly: one interpolation and two extrapolations)
followed by quadratic B-spline blending
or as
two overlapping quadratic Lagrange interpolations
followed by linear blending.
The two equivalent approaches are illustrated
by means of animations in the
[notebook about non-uniform Catmull--Rom splines](catmull-rom-non-uniform.ipynb#Animation).

## Example Plot

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

To quickly check how a spline segment would look like
when using the cardinal function we just derived,
let's define a few points ...

```python
vertices = np.array([
    (-0.1, -0.5),
    (0, 0),
    (1, 0),
    (0.5, 1),
])
```

... and plot $F(t)$ (or $F(s)$, as it has been called originally):

```python
plt.scatter(*np.array([
    sum([vertices[i] * C01.subs(t, s - i + 1) for i in range(4)])
    for s in np.linspace(0, 1, 20)]).T)
plt.plot(*vertices.T, 'x:g');
```

For calculating more than one segment,
and also for creating non-uniform Catmull--Rom splines,
the class
[splines.CatmullRom](../python-module/splines.rst#splines.CatmullRom)
can be used.
For more plots, see
[the notebook about properties of Catmull--Rom splines](catmull-rom-properties.ipynb).

## Basis Polynomials

The piecewise expression for the cardinal function
is a bit unwieldy to work with,
so let's bring it into a form we know how to deal with.

We are splitting the piecewise expression into four separate pieces,
each one to be evaluated at $0 \le t \le 1$.
We are also reversing the order of the pieces,
to match our intended control point order:

```python
b_CR = sp.Matrix([
    expr.subs(t, t + cond.args[1] - 1)
    for expr, cond in C01.args[1:-1][::-1]]).T
b_CR.T
```

```python
from helper import plot_basis
```

```python
plot_basis(*b_CR, labels=sp.symbols('xbm_i-1 xbm_i xbm_i+1 xbm_i+2'))
```

For the following sections,
we are using a few tools from [utility.py](utility.py):

```python
from utility import NamedExpression, NamedMatrix
```

## Basis Matrix

```python
b_monomial = sp.Matrix([t**3, t**2, t, 1]).T
M_CR = NamedMatrix(r'{M_\text{CR}}', 4, 4)
control_points = sp.Matrix(sp.symbols('xbm3:7'))
```

As usual, we look at the fifth polynomial segment
(from $\boldsymbol{x}_4$ to $\boldsymbol{x}_5$):

```python
p4 = NamedExpression('pbm4', b_monomial * M_CR.name * control_points)
p4
```

From the basis polynomials and the control points,
we can already calculate $\boldsymbol{p}_4(t)$ ...

```python
p4.expr = b_CR.dot(control_points).expand().collect(t)
p4
```

... and with a little bit of squinting,
we can directly read off
the coefficients of the basis matrix:

```python
M_CR.expr = sp.Matrix([
    [b.get(m, 0) for b in [
        p4.expr.expand().coeff(cv).collect(t, evaluate=False)
        for cv in control_points]]
    for m in b_monomial])
M_CR.pull_out(sp.S.Half)
```

This matrix also appears in section 6 of
Catmull and Rom (1974).

In case you want to copy&paste it, here's a plain text version:

```python
print(_.expr)
```

And, in case somebody needs it, its inverse looks like this:

```python
M_CR.I
```

```python
print(_.expr)
```

## Tangent Vectors

To get the tangent vectors,
we simply have to take the first derivative ...

```python
pd4 = p4.diff(t)
```

... and evaluate it at the beginning and the end of the segment:

```python
pd4.evaluated_at(t, 0)
```

```python
pd4.evaluated_at(t, 1)
```

These two expressions can be generalized to
(as already shown in
[the notebook about Catmull--Rom properties](catmull-rom-properties.ipynb#Tangent-Vectors)):

\begin{equation*}
\boldsymbol{\dot{x}}_i =
\frac{\boldsymbol{x}_{i+1} - \boldsymbol{x}_{i-1}}{2}
\end{equation*}

## Using Bézier Segments

The above equation for the tangent vectors can be used to construct
[Hermite splines](hermite.ipynb) or,
after dividing them by 3,
to obtain the control points for
[cubic Bézier spline segments](bezier-de-casteljau.ipynb#Cubic-Tangent-Vectors):

\begin{align*}
\boldsymbol{\tilde{x}}_i^{(+)}
&= \boldsymbol{x}_i + \frac{\boldsymbol{\dot{x}}_i}{3}
= \boldsymbol{x}_i + \frac{\boldsymbol{x}_{i+1} - \boldsymbol{x}_{i-1}}{6}\\
\boldsymbol{\tilde{x}}_i^{(-)}
&= \boldsymbol{x}_i - \frac{\boldsymbol{\dot{x}}_i}{3}
= \boldsymbol{x}_i - \frac{\boldsymbol{x}_{i+1} - \boldsymbol{x}_{i-1}}{6}
\end{align*}


# Sklearn Example: LogisticRegression and Iris

In this example we will train as LogisticRegression model on the iris dataset, convert it to ONNX format, simulate historical treatment data, then estimate the treatment effects using our IV estimation method.

### 1. Train and Convert

```python
import sys
import os
import pandas as pd
import numpy as np
from pathlib import Path
from sklearn.datasets import load_iris
```

```python
iris = load_iris()
X, y = iris.data, iris.target
```

In this example, X consists of 4 continuous variables

```python
print("Dtype:", X.dtype, "\nShape:", X.shape)
```

    Dtype: float64
    Shape: (150, 4)

Our estimation method relies on binary treatment assignment, so here let's assume that iris classifications of 1 or 2 indicate treatment recommendation.

```python
y[y>0] = 1
sum(y == 1)/len(y)
```

    0.6666666666666666

```python
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression

X_train, X_test, y_train, y_test = train_test_split(X, y)
```

```python
clr = LogisticRegression()
clr.fit(X_train, y_train)
```

    LogisticRegression()

```python
clr.predict_proba(X_test)[:5]
```

    array([[6.43946299e-05, 9.99935605e-01],
           [6.17542077e-04, 9.99382458e-01],
           [9.77038234e-01, 2.29617658e-02],
           [7.83578672e-04, 9.99216421e-01],
           [3.01864719e-02, 9.69813528e-01]])

```python
import pickle

# Save model
with open(f"models/iris_logreg.pickle", "wb") as f:
    pickle.dump(clr, f)
```

Now let's load back in the model and convert to ONNX. This step is necessary because our APS estimation procedure only takes ONNX models for inference.

```python
model = pickle.load(open(f"models/iris_logreg.pickle", 'rb'))
model.get_params()
```

    {'C': 1.0,
     'class_weight': None,
     'dual': False,
     'fit_intercept': True,
     'intercept_scaling': 1,
     'l1_ratio': None,
     'max_iter': 100,
     'multi_class': 'auto',
     'n_jobs': None,
     'penalty': 'l2',
     'random_state': None,
     'solver': 'lbfgs',
     'tol': 0.0001,
     'verbose': 0,
     'warm_start': False}

The IVaps package provides an ONNX conversion wrapper function that requires a dummy input and framework string to process datatypes and run the correct conversion function.

```python
from IVaps import convert_to_onnx

X_dummy = X[0,:]
filename = "models/iris_logreg.onnx"

convert_to_onnx(model = model, dummy_input1 = X_dummy, output_path = filename, framework = "sklearn")
```

    ir_version: 4
    producer_name: "skl2onnx"
    producer_version: "1.7.0"
    domain: "ai.onnx"
    model_version: 0
    doc_string: ""
    graph {
      node {
        input: "c_inputs"
        output: "label"
        output: "probability_tensor"
        name: "LinearClassifier"
        op_type: "LinearClassifier"
        attribute {
          name: "classlabels_ints"
          ints: 0
          ints: 1
          type: INTS
        }
        attribute {
          name: "coefficients"
          floats: -0.40207019448280334
          floats: 0.8983038663864136
          floats: -2.212754726409912
          floats: -0.9189403057098389
          floats: 0.40207019448280334
          floats: -0.8983038663864136
          floats: 2.212754726409912
          floats: 0.9189403057098389
          type: FLOATS
        }
        attribute {
          name: "intercepts"
          floats: 6.111894130706787
          floats: -6.111894130706787
          type: FLOATS
        }
        attribute {
          name: "multi_class"
          i: 1
          type: INT
        }
        attribute {
          name: "post_transform"
          s: "LOGISTIC"
          type: STRING
        }
        domain: "ai.onnx.ml"
      }
      node {
        input: "probability_tensor"
        output: "probabilities"
        name: "Normalizer"
        op_type: "Normalizer"
        attribute {
          name: "norm"
          s: "L1"
          type: STRING
        }
        domain: "ai.onnx.ml"
      }
      node {
        input: "label"
        output: "output_label"
        name: "Cast"
        op_type: "Cast"
        attribute {
          name: "to"
          i: 7
          type: INT
        }
        domain: ""
      }
      node {
        input: "probabilities"
        output: "output_probability"
        name: "ZipMap"
        op_type: "ZipMap"
        attribute {
          name: "classlabels_int64s"
          ints: 0
          ints: 1
          type: INTS
        }
        domain: "ai.onnx.ml"
      }
      name: "60b9e49389a84dd887dfea7990cfcd72"
      input {
        name: "c_inputs"
        type {
          tensor_type {
            elem_type: 11
            shape {
              dim {
              }
              dim {
                dim_value: 4
              }
            }
          }
        }
      }
      output {
        name: "output_label"
        type {
          tensor_type {
            elem_type: 7
            shape {
              dim {
              }
            }
          }
        }
      }
      output {
        name: "output_probability"
        type {
          sequence_type {
            elem_type {
              map_type {
                key_type: 7
                value_type {
                  tensor_type {
                    elem_type: 1
                  }
                }
              }
            }
          }
        }
      }
    }
    opset_import {
      domain: ""
      version: 9
    }
    opset_import {
      domain: "ai.onnx.ml"
      version: 1
    }

The wrapper function will also pass on any keyword arguments to the downstream conversion function

```python
convert_to_onnx(model=model, dummy_input1=X_dummy, output_path=filename, framework="sklearn", target_opset=12,
                doc_string="Sklearn LogisticRegression model trained on iris dataset")
```

    ir_version: 4
    producer_name: "skl2onnx"
    producer_version: "1.7.0"
    domain: "ai.onnx"
    model_version: 0
    doc_string: "Sklearn LogisticRegression model trained on iris dataset"
    graph {
      node {
        input: "c_inputs"
        output: "label"
        output: "probability_tensor"
        name: "LinearClassifier"
        op_type: "LinearClassifier"
        attribute {
          name: "classlabels_ints"
          ints: 0
          ints: 1
          type: INTS
        }
        attribute {
          name: "coefficients"
          floats: -0.40207019448280334
          floats: 0.8983038663864136
          floats: -2.212754726409912
          floats: -0.9189403057098389
          floats: 0.40207019448280334
          floats: -0.8983038663864136
          floats: 2.212754726409912
          floats: 0.9189403057098389
          type: FLOATS
        }
        attribute {
          name: "intercepts"
          floats: 6.111894130706787
          floats: -6.111894130706787
          type: FLOATS
        }
        attribute {
          name: "multi_class"
          i: 1
          type: INT
        }
        attribute {
          name: "post_transform"
          s: "LOGISTIC"
          type: STRING
        }
        domain: "ai.onnx.ml"
      }
      node {
        input: "probability_tensor"
        output: "probabilities"
        name: "Normalizer"
        op_type: "Normalizer"
        attribute {
          name: "norm"
          s: "L1"
          type: STRING
        }
        domain: "ai.onnx.ml"
      }
      node {
        input: "label"
        output: "output_label"
        name: "Cast"
        op_type: "Cast"
        attribute {
          name: "to"
          i: 7
          type: INT
        }
        domain: ""
      }
      node {
        input: "probabilities"
        output: "output_probability"
        name: "ZipMap"
        op_type: "ZipMap"
        attribute {
          name: "classlabels_int64s"
          ints: 0
          ints: 1
          type: INTS
        }
        domain: "ai.onnx.ml"
      }
      name: "6a54b5730b074c3390f80f18625b3332"
      input {
        name: "c_inputs"
        type {
          tensor_type {
            elem_type: 11
            shape {
              dim {
              }
              dim {
                dim_value: 4
              }
            }
          }
        }
      }
      output {
        name: "output_label"
        type {
          tensor_type {
            elem_type: 7
            shape {
              dim {
              }
            }
          }
        }
      }
      output {
        name: "output_probability"
        type {
          sequence_type {
            elem_type {
              map_type {
                key_type: 7
                value_type {
                  tensor_type {
                    elem_type: 1
                  }
                }
              }
            }
          }
        }
      }
    }
    opset_import {
      domain: ""
      version: 9
    }
    opset_import {
      domain: "ai.onnx.ml"
      version: 1
    }

### 2. Simulate historical treatment data

Let's simulate treatment and non-treatment outcomes for each observation i with the following structural equations:
\begin{align}
Y_{0i} &= \mathcal{N}(2,2)x_{1i} + \mathcal{N}(3,1)x_{2i} + \mathcal{N}(0,1) \\
Y_{1i} &= Y_{0i} + \mathcal{N}(5,0.2) + \mathcal{N}(0,1)
\end{align}

Where $x_{1}$ and $x_{2}$ refer to the first and second variables of input X.

```python
# Generate coefficients for observations
b1 = np.random.normal(2,2,len(X))
b2 = np.random.normal(3,1,len(X))
e0 = np.random.normal(0,1,len(X))
treatment_effect = np.random.normal(5,0.2, len(X))
e1 = np.random.normal(0,1,len(X))
```

```python
Y0 = b1 * X[:,0] + b2 * X[:,1] + e0
Y1 = Y0 + treatment_effect + e1
```

Generate treatment recommendation probabilities using our trained model. Let's assume that the treatment is assigned when the model recommends assignment 75% of the time (no-defiers assumption).

```python
# Create treatment recommendation probabilities
Z_probs = clr.predict_proba(X)[:,1]
Z_probs[:5]
```

    array([0.01938358, 0.02778672, 0.017355  , 0.02808062, 0.01706032])

```python
# Draw treatment recommendations
recommend_prob = np.random.uniform(size=len(X))
Z = (recommend_prob <= Z_probs).astype(int)
Z[:5]
```

    array([0, 0, 1, 0, 0])

```python
# Realized assignment follows recommended assignment 75% of the time
treatment_prob = np.random.uniform(size=len(X))
D = []
for i in range(len(treatment_prob)):
    if Z[i] == 1:
        if treatment_prob[i] >= 0.75:
            D.append(0)
        else:
            D.append(1)
    else:
        D.append(Z[i])
```

```python
sim_data = np.stack((Y0, Y1, Z, D), axis=1)
sim_data.shape
```

    (150, 4)

```python
full_data = np.concatenate((sim_data, X), axis=1)
full_data.shape
```

    (150, 8)

```python
# Save to csv
cols = ['Y0', 'Y1', 'Z', 'D', 'X1', 'X2', 'X3', 'X4']
df = pd.DataFrame(data = full_data, columns=cols)
df['Y'] = df['Y1']
df.loc[df['D']==0, 'Y'] = df.loc[df['D']==0, 'Y0']
df.to_csv("data/iris_data.csv", index=False)
```

```python
# Treatment effects
constant = df.Y0.mean()
ate = (df.Y1 - df.Y0).mean()
atet = (df.loc[df['D'] == 1, 'Y1'] - df.loc[df['D'] == 1, 'Y0']).mean()
late = (df.loc[(df['D'] == df['Z']), 'Y1'] - df.loc[(df['D'] == df['Z']), 'Y0']).mean()
print(f"Constant: {constant}")
print(f"ATE: {ate}")
print(f"ATET: {atet}")
print(f"LATE: {late}")
```

    Constant: 20.596786577283936
    ATE: 4.993407096330326
    ATET: 5.033753500963719
    LATE: 5.004023609441949

```python
print(f"True treatment effect: {np.mean(treatment_effect)}")
```

    True treatment effect: 5.010042347601557

### 3. APS Estimation

We can pass the simulated historical data and path to the ONNX model directly into our APS estimation function.

```python
from IVaps import estimate_aps_onnx
```

The data can be passed in a variety of ways. You can pass the entire dataset with optional variable indices delineating the continuous and discrete variables, the input variables individually, or some combination of the two. If incomplete information is passed, then the remaining data will be inferred **greedily**. That is, the remaining data will be assumed to be of whatever type that is not explicitly delineated. If no indices or explicit variables are given, then all of the data will be assumed to be continuous.

For this example, our input data is all continuous.

```python
df.head()
```

      Y0
      Y1
      Z
      D
      X1
      X2
      X3
      X4
      Y

      0
      24.416542
      27.914383
      0.0
      0.0
      5.1
      3.5
      1.4
      0.2
      24.416542

      1
      28.054542
      34.666395
      0.0
      0.0
      4.9
      3.0
      1.4
      0.2
      28.054542

      2
      6.208930
      9.070442
      1.0
      1.0
      4.7
      3.2
      1.3
      0.2
      9.070442

      3
      32.709551
      37.574877
      0.0
      0.0
      4.6
      3.1
      1.5
      0.2
      32.709551

      4
      42.155093
      48.062303
      0.0
      0.0
      5.0
      3.6
      1.4
      0.2
      42.155093

In the below cell, we set a random seed and demonstrate different ways of passing data.

```python
seed = np.random.choice(range(100))

# Pass just the input columns -- all variables are assumed to be continuous
aps0 = estimate_aps_onnx(data=df[['X1', 'X2', 'X3', 'X4']], S=100, delta=0.8, onnx=filename, seed = seed)

# Pass the full dataset along with indices
aps1 = estimate_aps_onnx(data=df[['X1', 'X2', 'X3', 'X4']], C = range(4), S=100, delta=0.8, onnx=filename, seed = seed)

# Pass the continuous columns into the X_c variable
aps2 = estimate_aps_onnx(X_c=df[['X1', 'X2', 'X3', 'X4']], S=100, delta=0.8, onnx=filename, seed = seed)

assert np.array_equal(aps0, aps1)
assert np.array_equal(aps1, aps2)
```

    `data` given but no indices passed. We will assume that all the variables in `data` are continuous.

**Important:** to allow for cross-framework compatibility, our aps estimation function only works with ONNX models that take single inputs or separate continuous/discrete inputs with specified node names, and outputs with specified node names. The convert_to_onnx function will output with those settings automatically, but please keep this in mind if you plan on using an externally generated ONNX model.

The converted ONNX model sets expected input dtypes according to the dummy input, but you can also pass the continuous/discrete datatypes explicitly as a tuple to coerce the input data to a specific type.

```python
df[['X1', 'X2', 'X3', 'X4']].dtypes
```

    X1    float64
    X2    float64
    X3    float64
    X4    float64
    dtype: object

```python
aps = estimate_aps_onnx(data=df[['X1', 'X2', 'X3', 'X4']], S=100, delta=0.1, onnx=filename, types=(np.float64, np.float64))
aps[:5]
```

    `data` given but no indices passed. We will assume that all the variables in `data` are continuous.

    array([0.01907534, 0.02838389, 0.0177419 , 0.02782021, 0.01714313])

```python
import matplotlib.pyplot as plt

plt.plot(aps)
```

### 4. Treatment effect estimation

We can now estimate LATE using our IV approach. Our primary estimation function is `estimate_treatment_effect`. Similar to APS estimation, we can pass in our inputs in a variety of ways. The function returns a tuple containing an array of counterfactual values and the fitted estimation model. Our package makes use of the modules in the `linearmodels` package to estimate and output the fitted models.

```python
from IVaps import estimate_treatment_effect
```

```python
# Again we demonstrate the different ways of passing inputs
treatment_df = pd.DataFrame({"Y": df['Y'], 'Z': df['Z'], 'D': df['D'], 'APS':aps})

# Without any indices passed, the function will infer all the variable columns
model0 = estimate_treatment_effect(data = treatment_df)

# We can specify some of the indices
model1 = estimate_treatment_effect(data = treatment_df, D_ind = 2, Y_ind = 0)

# We can also pass a subset of the variables directly
model2 = estimate_treatment_effect(data = treatment_df[['Z', 'D']], Y = treatment_df['Y'], aps = treatment_df['APS'])

# We can also pass all the variables directly
model3 = estimate_treatment_effect(Y = treatment_df['Y'], Z = treatment_df['Z'], D = treatment_df['D'], aps = treatment_df['APS'])

assert np.allclose(model0.params.to_numpy().flatten(), model1.params.to_numpy().flatten())
assert np.array_equal(model1.params.to_numpy().flatten(), model2.params.to_numpy().flatten())
assert np.array_equal(model1.params.to_numpy().flatten(), model3.params.to_numpy().flatten())
```

    Indices for ['Y', 'Z', 'D', 'APS'] not explicitly passed. Assuming remaining columns in order ['Y', 'Z', 'D', 'APS']...
    We will fit on 150 values out of 150 from the dataset for which the APS estimation is nondegenerate.
                              IV-2SLS Estimation Summary
    ==============================================================================
    Dep. Variable:                      Y   R-squared:                     -0.2186
    Estimator:                    IV-2SLS   Adj. R-squared:                -0.2352
    No. Observations:                 150   F-statistic:                    3.0899
    Date:                Sat, Nov 07 2020   P-value (F-stat)                0.2133
    Time:                        18:41:00   Distribution:                  chi2(2)
    Cov. Estimator:                robust

                                 Parameter Estimates
    ==============================================================================
                Parameter  Std. Err.     T-stat    P-value    Lower CI    Upper CI
    ------------------------------------------------------------------------------
    const          21.105     1.6923     12.471     0.0000      17.788      24.422
    aps            11.802     8.0320     1.4694     0.1417     -3.9402      27.545
    D             -11.957     10.738    -1.1136     0.2655     -33.003      9.0880
    ==============================================================================

    Endogenous: D
    Instruments: Z
    Robust Covariance (Heteroskedastic)
    Debiased: False
    Indices for ['Z', 'APS'] not explicitly passed. Assuming remaining columns in order ['Z', 'APS']...
    We will fit on 150 values out of 150 from the dataset for which the APS estimation is nondegenerate.
                              IV-2SLS Estimation Summary
    ==============================================================================
    Dep. Variable:                      Y   R-squared:                     -0.2186
    Estimator:                    IV-2SLS   Adj. R-squared:                -0.2352
    No. Observations:                 150   F-statistic:                    3.0899
    Date:                Sat, Nov 07 2020   P-value (F-stat)                0.2133
    Time:                        18:41:00   Distribution:                  chi2(2)
    Cov. Estimator:                robust

                                 Parameter Estimates
    ==============================================================================
                Parameter  Std. Err.     T-stat    P-value    Lower CI    Upper CI
    ------------------------------------------------------------------------------
    const          21.105     1.6923     12.471     0.0000      17.788      24.422
    aps            11.802     8.0320     1.4694     0.1417     -3.9402      27.545
    D             -11.957     10.738    -1.1136     0.2655     -33.003      9.0880
    ==============================================================================

    Endogenous: D
    Instruments: Z
    Robust Covariance (Heteroskedastic)
    Debiased: False
    Indices for ['Z', 'D'] not explicitly passed. Assuming remaining columns in order ['Z', 'D']...
    We will fit on 150 values out of 150 from the dataset for which the APS estimation is nondegenerate.
                              IV-2SLS Estimation Summary
    ==============================================================================
    Dep. Variable:                      Y   R-squared:                     -0.2186
    Estimator:                    IV-2SLS   Adj. R-squared:                -0.2352
    No. Observations:                 150   F-statistic:                    3.0899
    Date:                Sat, Nov 07 2020   P-value (F-stat)                0.2133
    Time:                        18:41:00   Distribution:                  chi2(2)
    Cov. Estimator:                robust

                                 Parameter Estimates
    ==============================================================================
                Parameter  Std. Err.     T-stat    P-value    Lower CI    Upper CI
    ------------------------------------------------------------------------------
    const          21.105     1.6923     12.471     0.0000      17.788      24.422
    aps            11.802     8.0320     1.4694     0.1417     -3.9402      27.545
    D             -11.957     10.738    -1.1136     0.2655     -33.003      9.0880
    ==============================================================================

    Endogenous: D
    Instruments: Z
    Robust Covariance (Heteroskedastic)
    Debiased: False
    We will fit on 150 values out of 150 from the dataset for which the APS estimation is nondegenerate.
                              IV-2SLS Estimation Summary
    ==============================================================================
    Dep. Variable:                      Y   R-squared:                     -0.2186
    Estimator:                    IV-2SLS   Adj. R-squared:                -0.2352
    No. Observations:                 150   F-statistic:                    3.0899
    Date:                Sat, Nov 07 2020   P-value (F-stat)                0.2133
    Time:                        18:41:00   Distribution:                  chi2(2)
    Cov. Estimator:                robust

                                 Parameter Estimates
    ==============================================================================
                Parameter  Std. Err.     T-stat    P-value    Lower CI    Upper CI
    ------------------------------------------------------------------------------
    const          21.105     1.6923     12.471     0.0000      17.788      24.422
    aps            11.802     8.0320     1.4694     0.1417     -3.9402      27.545
    D             -11.957     10.738    -1.1136     0.2655     -33.003      9.0880
    ==============================================================================

    Endogenous: D
    Instruments: Z
    Robust Covariance (Heteroskedastic)
    Debiased: False

    /Users/rl874/opt/anaconda3/envs/IVaps/lib/python3.7/site-packages/linearmodels/iv/data.py:25: FutureWarning: is_categorical is deprecated and will be removed in a future version.  Use is_categorical_dtype instead
      if is_categorical(s):

```python
est_treatment = model0.params['D']
print("Treatment effect estimate:", est_treatment)
print("True LATE:", late)
```

    Treatment effect estimate: -11.957283136631759
    True LATE: 5.004023609441949

The fitted model also provides other estimation diagnostics

```python
# First stage details
fs = model0.first_stage.individual['D']

print("First stage coefficients:", fs.params)
print("First stage p-values:", fs.pvalues)
print("First stage r2:", fs.rsquared)
```

    First stage coefficients: const   -0.000403
    aps      0.015128
    Z        0.704335
    Name: parameter, dtype: float64
    First stage p-values: const    0.957223
    aps      0.957222
    Z        0.008332
    Name: pvalue, dtype: float64
    First stage r2: 0.44431958868711696

```python
print(fs)
```

                                OLS Estimation Summary
    ==============================================================================
    Dep. Variable:                      D   R-squared:                      0.4443
    Estimator:                        OLS   Adj. R-squared:                 0.4368
    No. Observations:                 150   F-statistic:                    262.85
    Date:                Sat, Nov 07 2020   P-value (F-stat)                0.0000
    Time:                        18:41:17   Distribution:                  chi2(2)
    Cov. Estimator:                robust

                                 Parameter Estimates
    ==============================================================================
                Parameter  Std. Err.     T-stat    P-value    Lower CI    Upper CI
    ------------------------------------------------------------------------------
    const         -0.0004     0.0075    -0.0536     0.9572     -0.0151      0.0143
    aps            0.0151     0.2820     0.0536     0.9572     -0.5376      0.5679
    Z              0.7043     0.2670     2.6383     0.0083      0.1811      1.2276
    ==============================================================================

#### The above estimate is not particularly precise given the small sample size. Let's re-simulate the treatment effects with a larger sample size of 10000 by bootstrapping from iris

```python
# Bootstrap 10000 draws from iris data and re-generate outcome data
x_mean = np.mean(X, axis = 0)
x_sd = np.std(X, axis = 0)
X_sim = np.random.normal(x_mean, x_sd, size = (10000,4))
```

Treatment equations replicated below:
\begin{align}
Y_{0i} &= \mathcal{N}(2,2)x_{1i} + \mathcal{N}(3,1)x_{2i} + \mathcal{N}(0,1) \\
Y_{1i} &= Y_{0i} + \mathcal{N}(5,0.2) + \mathcal{N}(0,1)
\end{align}

Where $x_{1}$ and $x_{2}$ refer to the first and second variables of input X.

```python
# Generate coefficients for observations
b1 = np.random.normal(2,2,len(X_sim))
b2 = np.random.normal(3,1,len(X_sim))
e0 = np.random.normal(0,1,len(X_sim))
treatment_effect = np.random.normal(5,0.2, len(X_sim))
e1 = np.random.normal(0,1,len(X_sim))

Y0 = b1 * X_sim[:,0] + b2 * X_sim[:,1] + e0
Y1 = Y0 + treatment_effect + e1
```

```python
# Create treatment recommendation probabilities
Z_probs = clr.predict_proba(X_sim)[:,1]
print(Z_probs[:5])

# Draw treatment recommendations
recommend_prob = np.random.uniform(size=len(X_sim))
Z = (recommend_prob <= Z_probs).astype(int)
print(Z[:5])

# Realized assignment follows recommended assignment 75% of the time
treatment_prob = np.random.uniform(size=len(X_sim))
D = []
for i in range(len(treatment_prob)):
    if Z[i] == 1:
        if treatment_prob[i] >= 0.75:
            D.append(0)
        else:
            D.append(1)
    else:
        D.append(Z[i])

sim_data = np.stack((Y0, Y1, Z, D), axis=1)
full_data = np.concatenate((sim_data, X_sim), axis=1)
print(full_data.shape)
```

    [0.02831431 0.13754501 0.99792862 0.92453548 0.01094307]
    [0 0 1 1 0]
    (10000, 8)

```python
cols = ['Y0', 'Y1', 'Z', 'D', 'X1', 'X2', 'X3', 'X4']
df = pd.DataFrame(data = full_data, columns=cols)
df['Y'] = df['Y1']
df.loc[df['D']==0, 'Y'] = df.loc[df['D']==0, 'Y0']
```

```python
constant = df.Y0.mean()
ate = (df.Y1 - df.Y0).mean()
atet = (df.loc[df['D'] == 1, 'Y1'] - df.loc[df['D'] == 1, 'Y0']).mean()
late = (df.loc[(df['D'] == df['Z']), 'Y1'] - df.loc[(df['D'] == df['Z']), 'Y0']).mean()
print(f"Constant: {constant}")
print(f"ATE: {ate}")
print(f"ATET: {atet}")
print(f"LATE: {late}")

for mult in [100, 1000, 10000]:
    dup_df = df.iloc[:mult, :]
    aps = estimate_aps_onnx(data=dup_df[['X1', 'X2', 'X3', 'X4']], S=100, delta=0.8, onnx=filename)
    model = estimate_treatment_effect(Y = dup_df['Y'], Z = dup_df['Z'], D = dup_df['D'], aps = aps, verbose = False)
    print(f"Estimated LATE: {model.params['D']}")
    print(f"Estimated LATE SE: {model.std_errors['D']}")
    print("-"*100)
```

    Constant: 20.955120853047653
    ATE: 4.993884167072131
    ATET: 5.003461908160361
    LATE: 4.998657376663639
    `data` given but no indices passed. We will assume that all the variables in `data` are continuous.
    We will fit on 100 values out of 100 from the dataset for which the APS estimation is nondegenerate.
    Estimated LATE: 3.399117106401775
    Estimated LATE SE: 7.0624782110057485
    ----------------------------------------------------------------------------------------------------
    `data` given but no indices passed. We will assume that all the variables in `data` are continuous.

    /Users/rl874/opt/anaconda3/envs/IVaps/lib/python3.7/site-packages/linearmodels/iv/data.py:25: FutureWarning: is_categorical is deprecated and will be removed in a future version.  Use is_categorical_dtype instead
      if is_categorical(s):

    We will fit on 1000 values out of 1000 from the dataset for which the APS estimation is nondegenerate.
    Estimated LATE: 2.915690501676977
    Estimated LATE SE: 2.0208268925613133
    ----------------------------------------------------------------------------------------------------
    `data` given but no indices passed. We will assume that all the variables in `data` are continuous.
    We will fit on 10000 values out of 10000 from the dataset for which the APS estimation is nondegenerate.
    Estimated LATE: 4.683976830613005
    Estimated LATE SE: 0.6141624837178257
    ----------------------------------------------------------------------------------------------------

```python

```


```python
from IPython.core.display import HTML
from IPython.display import Image
HTML("""
<style>
.output_png {
    display: table-cell;
    text-align: center;
    vertical-align: middle;
}
</style>
""")
```

<style>
.output_png {
    display: table-cell;
    text-align: center;
    vertical-align: middle;
}
</style>

# *Circuitos Elétricos I - Semana 3*

## Resolução de circuitos utilizando análise nodal: método das tensões de nó.

### Procedimento de solução:

**Passo 1**: identifique todos os $n_e$ nós essenciais do circuito, selecione um deles como um nó de referência (terra) e, em seguida, atribua uma tensão de nó para cada um dos $n_e - 1$ nós essenciais restantes.

**Passo 2**: em cada um dos $n_e - 1$ nós essenciais, obtenha uma equação aplicando a lei de Kirchhoff das correntes (LKC), ou seja, uma equação indicando que a soma de todas as correntes saindo do nó é igual a soma das correntes deixando o nó.

**Passo 3**: escreva as equações obtidas em função das tensões de nó.

**Passo 4**: resolva o sistema de $n_e - 1$ equações independentes para determinar as tensões de nó desconhecidas.

**Passo 5**: determine os valores das grandezas de interesse a partir das tensões de nó obtidas.

### Problema 1

Aplicando o método das tensões de nó, determine todas as correntes e potências desenvolvidas por cada elemento no circuito a seguir:

### Solução das equações

```python
from utils import symdisp
import sympy as sp
```

```python
# define as N variáveis desconhecidas
va, vb, vc, vd = sp.symbols('va, vb, vc, vd')

# define os sistema de N equações
eq1 = sp.Eq()
eq2 = sp.Eq()
eq3 = sp.Eq()
eq4 = sp.Eq()

# resolve o sistema
soluc = sp.solve((eq1, eq2, eq3, eq4), dict=True)
soluc = soluc[0]

va = soluc[va]
vb = soluc[vb]
vc = soluc[vc]
vd = soluc[vd]

print('Solução do sistema:')
symdisp('v_a = ', round(va, 2), ' V')
symdisp('v_b = ', round(vb, 2), ' V')
symdisp('v_c = ', round(vc, 2), ' V')
symdisp('v_d = ', round(vd, 2), ' V')
```

    Solução do sistema:

$\displaystyle v_a = -1.33\; V$

$\displaystyle v_b = 2\; V$

$\displaystyle v_c = 0\; V$

$\displaystyle v_d = 0.67\; V$

### Cálculo das correntes

```python
i2 =
ix =
i3 =
i4 =
i6 =
i1 =
i5 =

print('Correntes:')
symdisp('i_1 = ', round(i1, 2), ' A')
symdisp('i_2 = ', round(i2, 2), ' A')
symdisp('i_3 = ', round(i3, 2), ' A')
symdisp('i_4 = ', round(i4, 2), ' A')
symdisp('i_5 = ', round(i5, 2), ' A')
symdisp('i_6 = ', round(i6, 2), ' A')
symdisp('i_x = ', round(ix, 2), ' A')
```

### Cálculo das potências

```python
pR =
p10V =
p05A =
p12V =
p2ix =

# calcula somatório das potências
print('Somatório das potências : %.2f W\n' %(pR+p10V+p05A+p12V+p2ix))
```

## Resolução de circuitos utilizando análise de malhas: correntes de malha.

### Procedimento de solução:

**Passo 1**: identifique todas as malhas simples e atribua a cada uma delas uma corrente de malha desconhecida. A escolha sentido de referência para cada corrente de malha é arbitrário. Por conveniência, costuma-se definir utilizar o mesmo sentido para todas as correntes de malha.

**Passo 2**: para cada uma das malhas simples do circuito, obtenha uma equação aplicando a lei de Kirchhoff das tensões (LKT).

**Passo 3**: resolva o sistema de equações independentes resultantes para determinar as correntes de malha.

**Passo 4**: determine os valores das grandezas de interesse a partir das correntes de malha obtidas.

### Problema 2

Aplicando o método das correntes de malha, determine todas as correntes e potências desenvolvidas por cada elemento no circuito a seguir:

### Solução das equações

```python
# define as N variáveis desconhecidas
Ia, Ib, Ic, Id = sp.symbols('ia, ib, ic, id')

# define os sistema de N equações
eq1 = sp.Eq()
eq2 = sp.Eq()
eq3 = sp.Eq()
eq4 = sp.Eq()

# resolve o sistema
soluc = sp.solve((eq1, eq2, eq3, eq4), dict=True)
soluc = soluc[0]

Ia = soluc[Ia]
Ib = soluc[Ib]
Ic = soluc[Ic]
Id = soluc[Id]

print('Correntes:')
symdisp('i_a = ', round(Ia, 2), ' A')
symdisp('i_b = ', round(Ib, 2), ' A')
symdisp('i_c = ', round(Ic, 2), ' A')
symdisp('i_d = ', round(Id, 2), ' A')
```

    Correntes:

$\displaystyle i_a = 1\; A$

$\displaystyle i_b = 1\; A$

$\displaystyle i_c = 0.5\; A$

$\displaystyle i_d = 4.5\; A$

### Cálculo das correntes

```python
i2 =
ix =
i3 =
i4 =
i6 =
i1 =
i5 =

print('Correntes:')
symdisp('i_1 = ', round(i1, 2), ' A')
symdisp('i_2 = ', round(i2, 2), ' A')
symdisp('i_3 = ', round(i3, 2), ' A')
symdisp('i_4 = ', round(i4, 2), ' A')
symdisp('i_5 = ', round(i5, 2), ' A')
symdisp('i_6 = ', round(i6, 2), ' A')
symdisp('i_x = ', round(ix, 2), ' A')
```

### Cálculo das potências

```python
pR =
p10V =
p05A =
p12V =
p2ix =

# calcula somatório das potências
print('Somatório das potências : %.2f W\n' %(pR+p10V+p05A+p12V+p2ix))
```

Jupyter notebooks/Circuitos Eletricos I - Semana 3.ipynb


# 2. Getting started

## Introducing the GeLaTo library

From now on, we shall use the Python library [spl](https://github.com/pyccel/spl).

Let us consider the following weak formulation

$$\int_{\Omega} N_i N_j + \nabla N_i \cdot \nabla N_j ~d\Omega$$

In the case where $\Omega$ is the unit cube, we should expect the GLT symbol

$$
\mathfrak{m}_p(\theta_1) \mathfrak{m}_p(\theta_2) \mathfrak{m}_p(\theta_3) + \mathfrak{m}_p(\theta_1) \mathfrak{m}_p(\theta_2) \mathfrak{s}_p(\theta_3) + \mathfrak{m}_p(\theta_1) \mathfrak{s}_p(\theta_2) \mathfrak{m}_p(\theta_3) + \mathfrak{s}_p(\theta_1) \mathfrak{m}_p(\theta_2) \mathfrak{m}_p(\theta_3)
$$

```python
# ... some imports
from numpy import linspace, zeros, pi

from sympy.core.containers import Tuple
from sympy import symbols
from sympy import Symbol
from sympy import Lambda
from sympy import IndexedBase

from gelato.glt import glt_symbol
from gelato.calculus   import (Dot, Cross, Grad, Curl, Rot, Div)
from gelato.fem.utils    import compile_symbol

from spl.fem.splines import SplineSpace
from spl.fem.tensor  import TensorFemSpace
```

We define our weak formulation using

```python
x,y,z = symbols('x y z')

u = Symbol('u')
v = Symbol('v')

a = Lambda((x,y,z,v,u), Dot(Grad(u), Grad(v)))
```

The GLT symbol can then be computed by giving a discretization

```python

# ...  create a finite element space
p1  = 3 ; p2  = 3 ; p3  = 3
ne1 = 16 ; ne2 = 16 ; ne3 = 16

grid_1 = linspace(0., 1., ne1+1)
grid_2 = linspace(0., 1., ne2+1)
grid_3 = linspace(0., 1., ne3+1)

V1 = SplineSpace(p1, grid=grid_1)
V2 = SplineSpace(p2, grid=grid_2)
V3 = SplineSpace(p3, grid=grid_3)

V = TensorFemSpace(V1, V2, V3)
# ...

# ... create a glt symbol from a string without evaluation
symbol = glt_symbol(a, space=V)
# ...
```

We get a *sympy* expression, that we can manipulate like any other *sympy*
expression. Let's print it

```python
print (symbol)
```

    m1*m2*s3 + m1*m3*s2 + m2*m3*s1

Good, but that's not readable for a human. You can now convert this expression to latex using

```python
from IPython.display import Math
from sympy import latex

Math(latex(symbol))
```

$$m_{1} m_{2} s_{3} + m_{1} m_{3} s_{2} + m_{2} m_{3} s_{1}$$

For pretty printing, you may use the **glt_latex** function

```python
from gelato.printing.latex import glt_latex

Math(glt_latex(symbol))
```

$$\mathfrak{m}_p(\theta_1) \mathfrak{m}_p(\theta_2) \mathfrak{s}_p(\theta_3) + \mathfrak{m}_p(\theta_1) \mathfrak{m}_p(\theta_3) \mathfrak{s}_p(\theta_2) + \mathfrak{m}_p(\theta_2) \mathfrak{m}_p(\theta_3) \mathfrak{s}_p(\theta_1)$$

Let's now **evaluate** our symbol (in the symbolic sens) in order to do some diagnostics

```python
symbol = glt_symbol(a, space=V, evaluate=True)
```

```python
Math(glt_latex(symbol.expr))
```

$$\left(- 4 \cos{\left (\theta_1 \right )} - \frac{32}{5} \cos{\left (2 \theta_1 \right )} - \frac{4}{15} \cos{\left (3 \theta_1 \right )} + \frac{32}{3}\right) \left(\frac{397}{13440} \cos{\left (\theta_2 \right )} + \frac{1}{336} \cos{\left (2 \theta_2 \right )} + \frac{1}{40320} \cos{\left (3 \theta_2 \right )} + \frac{2396825387}{79999999672}\right) \left(\frac{397}{13440} \cos{\left (\theta_3 \right )} + \frac{1}{336} \cos{\left (2 \theta_3 \right )} + \frac{1}{40320} \cos{\left (3 \theta_3 \right )} + \frac{2396825387}{79999999672}\right) + \left(\frac{397}{13440} \cos{\left (\theta_1 \right )} + \frac{1}{336} \cos{\left (2 \theta_1 \right )} + \frac{1}{40320} \cos{\left (3 \theta_1 \right )} + \frac{2396825387}{79999999672}\right) \left(- 4 \cos{\left (\theta_2 \right )} - \frac{32}{5} \cos{\left (2 \theta_2 \right )} - \frac{4}{15} \cos{\left (3 \theta_2 \right )} + \frac{32}{3}\right) \left(\frac{397}{13440} \cos{\left (\theta_3 \right )} + \frac{1}{336} \cos{\left (2 \theta_3 \right )} + \frac{1}{40320} \cos{\left (3 \theta_3 \right )} + \frac{2396825387}{79999999672}\right) + \left(\frac{397}{13440} \cos{\left (\theta_1 \right )} + \frac{1}{336} \cos{\left (2 \theta_1 \right )} + \frac{1}{40320} \cos{\left (3 \theta_1 \right )} + \frac{2396825387}{79999999672}\right) \left(\frac{397}{13440} \cos{\left (\theta_2 \right )} + \frac{1}{336} \cos{\left (2 \theta_2 \right )} + \frac{1}{40320} \cos{\left (3 \theta_2 \right )} + \frac{2396825387}{79999999672}\right) \left(- 4 \cos{\left (\theta_3 \right )} - \frac{32}{5} \cos{\left (2 \theta_3 \right )} - \frac{4}{15} \cos{\left (3 \theta_3 \right )} + \frac{32}{3}\right)$$

We recall that the eigenvalues of the matrix associated to our weak formulation can be *approximated* by a *uniform sampling* of the symbol

```python
# ... make the symbol a callable function, that can be evaluated numericaly
symbol_f90 = compile_symbol('symbol_3d', a, V, backend='fortran')
# ...
```

```python
# ... example of symbol evaluation
t1 = linspace(-pi,pi, ne1+1)
t2 = linspace(-pi,pi, ne2+1)
t3 = linspace(-pi,pi, ne3+1)
x1 = linspace(0.,1., ne1+1)
x2 = linspace(0.,1., ne2+1)
x3 = linspace(0.,1., ne3+1)
eig = zeros((ne1+1, ne2+1, ne3+1), order='F')
symbol_f90(x1,x2,x3,t1,t2,t3, eig)
# ...
```

```python
%matplotlib inline
import matplotlib.pyplot as plt
```

```python
t = eig.ravel()
t.sort()
plt.plot(t, "+b", label="glt symbol")
plt.legend(loc=2);
```

## Using SPL and GeLaTo

In this section, we show how to use both [GeLaTo](https://github.com/pyccel/GeLaTo) and [spl](https://github.com/pyccel/spl) in order to assemble a weak-formulation and study its spectral properties.

We start by the Laplace operator in **1D**. As in the previous section, we define its weak formulation using the **lambda** expression

```python
# ... define the weak formulation
x = Symbol('x')

u = Symbol('u')
v = Symbol('v')

expr = Lambda((x,v,u), Dot(Grad(u), Grad(v)) + u*v)
# ...
```

```python
# needed imports
from numpy import linspace
from spl.fem.splines import SplineSpace
from gelato.fem.assembly import assemble_matrix
from gelato.fem.utils    import compile_kernel
```

```python
# ...  create a finite element space
p  = 3
ne = 64

grid = linspace(0., 1., ne+1)

V = SplineSpace(p, grid=grid)
# ...
```

*GeLaTo* has a nice function **compile_kernel**, that constructs the FEM assembly kernel, given a weak formulation. You can call it this way:

```python
kernel_py = compile_kernel('kernel_1', a, V)
```

You can now assemble the corresponding matrix, using:

```python
M_py  = assemble_matrix(V, kernel_py).tocsr()
```

The **compile_kernel** function has a **backend** argument which is used to decide the target language (*Python/Fortran*) for the kernel. The available choices are *'python'* or *'fortran'*. For *Fortran*, you can call it this way:

```python
kernel_f90 = compile_kernel('kernel_1', a, V, backend='fortran')
M_f90 = assemble_matrix(V, kernel_f90).tocsr()
```

Now, we convert the sparse matrix to a dense one, in order to use lapack eigenvalue solver.

```python
M = M_f90.todense()
```

```python
# import eigenvalue solver
from scipy.linalg import eig

w, v = eig(M)
wr = w.real
wr.sort()
```

```python
# compile the glt symbol to get a callable function that can be evaluated
symbol_f90 = compile_symbol('symbol_1d', a, V, backend='fortran')
```

```python
# example of symbol evaluation
t1 = linspace(-pi,pi, ne+1)
x1 = linspace(0.,1., ne+1)
e = zeros(ne+1)
symbol_f90(x1, t1, e)
e.sort()
```

```python
plt.plot(e, "+b", label="glt symbol")
plt.plot(wr, "xr", label="eigenvalues")
plt.legend(loc=2);
```

```python

```

```python
from IPython.core.display import HTML
def css_styling():
    styles = open("../../styles/custom.css", "r").read()
    return HTML(styles)
css_styling()
```

<link href='http://fonts.googleapis.com/css?family=Fenix' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Alegreya+Sans:100,300,400,500,700,800,900,100italic,300italic,400italic,500italic,700italic,800italic,900italic' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Source+Code+Pro:300,400' rel='stylesheet' type='text/css'>
<style>
    @font-face {
        font-family: "Computer Modern";
        src: url('http://mirrors.ctan.org/fonts/cm-unicode/fonts/otf/cmunss.otf');
    }
    div.cell{
        width:600px;
        margin-left:16% !important;
        margin-right:auto;
    }
    h1 {
        font-family: 'Alegreya Sans', sans-serif;
    }
    h2 {
        font-family: 'Fenix', serif;
    }
    h3{
		font-family: 'Fenix', serif;
        margin-top:12px;
        margin-bottom: 3px;
       }
	h4{
		font-family: 'Fenix', serif;
       }
    h5 {
        font-family: 'Alegreya Sans', sans-serif;
    }
    div.text_cell_render{
        font-family: 'Alegreya Sans',Computer Modern, "Helvetica Neue", Arial, Helvetica, Geneva, sans-serif;
        line-height: 135%;
        font-size: 120%;
        width:600px;
        margin-left:auto;
        margin-right:auto;
    }
    .CodeMirror{
            font-family: "Source Code Pro";
			font-size: 90%;
    }
/*    .prompt{
        display: None;
    }*/
    .text_cell_render h1 {
        font-weight: 200;
        font-size: 50pt;
		line-height: 100%;
        color:#054BCD;
        margin-bottom: 0.5em;
        margin-top: 0.5em;
        display: block;
    }
    .text_cell_render h5 {
        font-weight: 300;
        font-size: 16pt;
        color: #054BCD;
        font-style: italic;
        margin-bottom: .5em;
        margin-top: 0.5em;
        display: block;
    }

    .warning{
        color: rgb( 240, 20, 20 )
        }
</style>