# Finding the volume dependant main channel capillary pressure of the off-valve transistor (capillary-) using the Z001 chip

The closing time of the off-valve transistor or capillary-FET depends on the liquid flow rate in the trigger channel. The flow rate, in turn, is defined by total capillary pressure difference between the trigger channel and the main channel, divded by the flow resistance of the trigger channel and main channel. Using this, we can express the closing time of the cFET as.

\begin{equation}
t_{close} = \int_0^{V_p} \frac{R_l(V) + R_g(V)}{p_{cap,tr} - p_{cap,main}}
\end{equation}

Finding the capillary pressure of the trigger channel is trivial - it can be calculated using the Young-laplace equation for a square microchannel.

\begin{equation}
p_{cap} = -\gamma(\frac{cos\theta_{PDMS} + cos\theta_{PMMA}}{h} + 2\frac{cos\theta_{PMMA}}{w}) = -\gamma(R_{horizontal}^{-1} + R_{vertical}^{-1})
\end{equation}

In addition, the flow resistance of the trigger channel can be calculated from well established empirical models

\begin{equation}
R_h = \frac{12\mu L}{wh^3(1-0.63\frac{h}{w})}
\end{equation}

Finding the capillary pressure of the main channel is more complicated. The meniscus shape depends on the the volume of the bubble - so it will change throughout the closing of the cFET. However, we can find this pressure by using the Z001 chip. This design uses trigger channels with precisely defined volumes to put the the occluding bubbles into a large range of stable states between fully open, and fully closed. This gives a stable snapshot of the bubble at defined points throughout the closing. A radius can then be fitted to the bubble, and the capillary pressure extracted throughout the closing of the valve.

```python
# -----------------Python packages and package parameter settings -----------------------
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
from PIL import Image
import scipy.signal as sp
from mpl_toolkits.mplot3d import Axes3D
from matplotlib.gridspec import GridSpec
from matplotlib import rcParams
rcParams['font.family'] = 'sans-serif'
rcParams['font.sans-serif'] = ['Arial']
```

```python
# DO NOT ADJUST - relevant to the physical experiment underpinning this model

# -----------------Camera Parameters------------------------
pixel_to_mm = 1e-5 # Factor converting from pixels to metres

# this array is the real volumes of the trigger channel volumes up to the pinch off volume.
# this is hard coded by the experiment and cannot be adjsuted in this code
trigger_channel_volumes = np.linspace(5.7,25.7,20)
pinch_off_volume = 25.7e-9 #litres
main_chanel_depth = 200e-6 # metres
```

```python
# ------------------------------Parameters in this cell can be adjusted to simulate the effect of different properties ------------------------------------
# Liquid and gas properties
gamma_water = 0.072 # N/m
mu_air = 1.872e-5 # Pa.S @25C from https://www.engineersedge.com/physics/viscosity_of_air_dynamic_and_kinematic_14483.html
mu_water = 8.9e-4 # Pa.s @25C same source as above

# the number of intermediate points to analyse
step_size= 100

# Trigger Channel geometries
total_channel_length = 10e-3 # metres
w_tr_channel = 100e-6 #metres
h_tr = np.linspace(20e-6,150e-6, step_size) # metres

# Solid surface properties
theta_pmma = np.linspace(0, 60, step_size)
theta_pdms = 90*np.ones(step_size)
thetas = np.array([np.deg2rad(theta_pmma), np.deg2rad(theta_pdms)])

```

```python
# Open the image, and plot vertical slices through the meniscus.
im = Image.open('test_fittingij.tif')
test = np.array(im)
fig = plt.figure(figsize=(15,6))
ax = fig.add_subplot(121)
ax.plot(test)
plt.xlabel('Vertical pixel postion')
plt.ylabel('Binary Pixel Value')

def transition_index(bin_array):
    '''
    Scans binary Image matrix columns, and saves the first transition point.
    '''
    arc = np.array([])
    # The if statement sets the intital value - transition point is when it changes from val.
    if bin_array[0,:][0] == 255:
        val = 255
    else:
        val = 0
    ## ii is the transistion point.
    for i in range(0,len(bin_array[0,:])):
        section = bin_array[:,i]
        ii = 0
        while section[ii] == val:
            ii+=1
        arc = np.append(arc, ii)
    return arc

# Plot the indetified transition point
first_arc= transition_index(test)
ax = fig.add_subplot(122)
ax.plot(first_arc)
plt.ylim((0,85))
plt.ylim((0,85))
plt.show()
```

# Multi-variate gradient descent

To initialise the values
1. Assume the bubble is a cicular arc ($r = y^2 - x^2$), and that the 'turning point' of the arc is horizontal|veritcal with its origin. This defines the x-coordinate of the circle origin.
2. Start with an assumed Y origin coordinate. Calculate a radius from the distance to the turning point.

To fit the parameters

3. Take the current parameters, and some step size parameter.
4. Calculate the error gradient for every parameter, gamma*previous_gradient distance away from the present point.
5. Use that vector error gradient to update the parameters.
6. Calculate the new arc, and mean sqaure error.
7. Repeat 3-6 until some error threshold.

```python
def calculate_mse(data, arc):
    '''
    Takes in the data and calcualtes the MSE between the measured data and the fitting.
    '''
    mse = ((data - arc)**2).mean()
    return mse

def calculate_new_error_gradient(params, mse, gamma, gradient, x_values, data):
    '''
    Calculates the error gradient, gamma steps away from the current arc.
    '''
    # I was going to write this in a loop, but my encoding is silly.
    # Should have used matrices and vectors.

    # Calculate x_gradient
    new_x = params[0] - gamma[0]*gradient[0]
    arc_x = np.sqrt(params[2]**2 - (x_values - new_x)**2) + params[1]
    mse_x = calculate_mse(data, arc_x)

    # If statement stops divide by zero errors
    if new_x == params[0]:
        g_mse_x = 0
    else:
        g_mse_x = (mse_x - mse)/(new_x - params[0])

    # Calculate y gradeint
    new_y = params[1] - gamma[1]*gradient[1]
    arc_y = np.sqrt(params[2]**2 - (x_values - params[0])**2) + new_y
    mse_y = calculate_mse(data, arc_y)
    if new_y == params[1]:
        g_mse_y = 0
    else:
        g_mse_y = (mse_y - mse)/(new_y - params[1])

    new_r = params[2] - gamma[2]*gradient[2]
    arc_r = np.sqrt(new_r**2 - (x_values - params[0])**2) + params[1]
    mse_r = calculate_mse(data, arc_r)
    if new_r == params[2]:
        g_mse_r = 0
    else:
        g_mse_r = (mse_r - mse)/(new_r - params[2])

    # put the three error gradients into a vector
    mse_gradient = [g_mse_x, g_mse_y, g_mse_r]

    # if a NaN has snuck in there, report it.
    if np.isnan(mse_gradient).any():
        print('NaN found')

    return mse_gradient

def update_params(params, gamma, gradient):
    '''
    Using the 3D error gradient, update the new arc parameters
    '''
    new_x = params[0] - gamma[0]*gradient[0]
    new_y = params[1] - gamma[1]*gradient[1]
    new_r = params[2] - gamma[2]*gradient[2]

    params = [new_x, new_y, new_r]

    return params
```

```python
# all ImageJ processes images are in the folder "/meniscus-picture/"

# Open all images, and then repeat this entire process.
meni_images = []
for file_num in range(1,21):
    im = Image.open('meniscus-pictures/{}.tif'.format(file_num))
    meni_images.append(np.array(im))

# Convert all these images into data points which can be fitted.
extracted_arcs = []
for meniscus in meni_images:
    arc = transition_index(meniscus)
    extracted_arcs.append(arc)

# Arrays for storing values as they are calculated
r_params = []
global_params = []

# the same process as previous, but now looped - and with a few other checks in there

for meni in extracted_arcs:
    max_val = max(meni)
    max_index = np.where(meni == max_val)
    if (len(max_index[0]) > 1):
        centre_index = len(max_index[0])//2
        x_origin = max_index[0][centre_index]
    y_origin = min(meni) -  len(meni)
    x_values = np.linspace(0,len(meni),len(meni))

    r_arc = meni[x_origin] - y_origin
    new_arc = np.sqrt(r_arc**2 - (x_values - x_origin)**2) + y_origin
    mse = ((new_arc - meni)**2).mean()
    mse_gradient = [0.5, 0.5, 0.5]

    arc_params = [x_origin, y_origin, r_arc]
    gammas = [0.01, 0.15, 0.03]

    while mse > 1:
        mse_gradient = calculate_new_error_gradient(arc_params, mse, gammas, mse_gradient, x_values, meni)
        arc_params = update_params(arc_params, gammas, mse_gradient)
        new_arc = np.sqrt(arc_params[2]**2 - (x_values - arc_params[0])**2) + arc_params[1]
        mse = calculate_mse(meni, new_arc)
        # this just checks if the error gradient has completely plataued, and kills the loop
        # a bad way of doing things -  but it works for now
        if np.count_nonzero(mse_gradient) == 0:
            mse = 0

    r_params.append(arc_params[2])
    global_params.append(arc_params)

# plots all the fitted arcs for a manual check of the fit
ncols = 6
nrows = len(extracted_arcs)//ncols
fig,axs = plt.subplots(nrows, ncols,figsize=(15,8))
for i in range(ncols):
    for ii in range(nrows):
        data = extracted_arcs[nrows*i + ii]
        x_values = np.linspace(0,len(data),len(data))
        plotting_arc = np.sqrt(global_params[nrows*i + ii][2]**2 - (x_values - global_params[nrows*i + ii][0])**2) + global_params[nrows*i + ii][1]
        axs[ii,i].plot(data)
        axs[ii,i].plot(plotting_arc)
        axs[ii,i].set_xticklabels([])
        axs[ii,i].set_yticklabels([])

plt.show()
```

Next, plot the radii of all of these arcs changing over trigger channel displacement.

```python
r_raw = r_params
r_filtered = np.copy(r_params)

# Replace the two major outliers with linear interpolatation of there neighbours
r_filtered[7] = (r_filtered[6] + r_filtered[9])/2
r_filtered[8] = (r_filtered[6] + r_filtered[9])/2

# Use a linear filter to smooth the result.
smoothed_r_params = sp.savgol_filter(r_filtered,5,1)

fig = plt.figure(figsize=(4,3))
ax = fig.add_subplot(111)
ax.plot(trigger_channel_volumes[7:9],1000*pixel_to_mm*np.array(r_raw)[7:9],'r^', label='raw data')

ax.plot(trigger_channel_volumes, 1000*pixel_to_mm*np.array(r_filtered),'kx',label='outliners removed',linewidth=0,alpha=0.9,)

ax.plot(trigger_channel_volumes, 1000*pixel_to_mm*np.array(smoothed_r_params),label='outliners removed',linewidth=2.5,alpha=0.9,color='k')

plt.ylabel('Bubble radius ($mm$)',fontsize=12)
plt.xlabel('Displaced volume ($nL$)',fontsize=12)
plt.grid('both')
plt.show()

```

The result is a bit noisy. There are also two clear outliers. These stem from the meniscus not pinning at the geometric edges of the void volume edge, but rather off some point outside the channel. As a result, I will remove the outliers, smooth the result with a sav-gol filter, and interpolate the result at some sample rate.

# Closing time

We now need to use the contact angle to calculate the capillary pressure of the system.  We do not know the contact angle, because it changes substantially between runs with plasma ashing. Due to this, I'm going to calculate it for a range.

Assumed material values: $\gamma_{water} = 0.072 N/m$, and $\theta_{PDMS} = 90$.

### Process

0. Assume a constant total trigger channel length - but varied depth, and contact angle.

1. For a each trigger channel depth, calculate the channel length which means the pinch off volume of air has been displaced ($L_p = \frac{V_p}{hw}$).

2. Calculate the change of the channel resistance over that length, both gas and water contributions ($R_{tr} = \frac{12\mu_{H_2O}L_{H_2O}}{wh^3(1-0.63\frac{h}{w})} + \frac{12\mu_{air}(L - L_{H_2O})}{wh^3(1-0.63\frac{h}{w})}$).

3. Calculate and substitute in the capillary pressures.

4. Numerically integrate the resulting flow rates up until the pinch off volume, and use to find the closing time.

```python

def trigger_channel_pcap(h, w, theta, gamma = gamma_water):
    '''
    Returns capillary pressure of rectangular channel

    Written in a way to accept the coloumn vectors
    '''
    return -gamma*((np.cos(theta[0,:]) + np.cos(theta[1,:]))/h + 2*np.cos(theta[0,:])/w)

def rh_square(mu, w, h, L):
    '''
    returns the hydraulic resistance of a sqaure channel while water is displaced

    '''
    return 12*mu*L/(w*h**3*(1-0.63*(h/w)))

def main_channel_pcap(radii, gamma= gamma_water):
    '''
    Evaluates the young-laplace equation using the principal radii form.

    radii[0] is the vertical component, which is a function of contact angle.
    radii[1] is horizontal component, extracted from the gradient descent above,
    '''
    return -gamma * ((1/radii[0].reshape(len(radii[0]),1)) + 1/radii[1])

h_tr_channel = h_tr.reshape((step_size,1))
p_cap_tr = trigger_channel_pcap(h_tr_channel, w_tr_channel, thetas)

#plotting
fig = plt.figure()
ax = fig.add_subplot(111)
ax.plot(1e6*h_tr_channel,p_cap_tr)
plt.grid('both')

plt.xlabel('Trigger channel depth ($\mu m$)')
plt.ylabel('Capillary pressure')

plt.show()
```

Trigger channel pressure matrix: Function of channel depth and contact angle.

Contact angle goes across the columns, trigger channel depth goes down the rows

```python
# Start by calculating what the length which will pinch off the channel
# divide pinch-off volume by the channel area, times by 1000 for length in mm
closing_length = []
for height in h_tr_channel:
    closing_length.append(pinch_off_volume/(1000*height*w_tr_channel))

# next turn the "final length" into an evenly spaced array of points.
lengths = []
for length in closing_length:
    lengths.append(np.linspace(0.0,length,step_size))

# use the new array of lengths to also create an matrix of all the volumes
volumes = np.array(lengths)*w_tr_channel*h_tr_channel

# Next, construct the fluid resistance matrix.
# sum of the fluid resistances as a funciton of changing length.
lengths = np.transpose(lengths)
r_specific = rh_square(mu_water,
                       w_tr_channel,
                       np.transpose(h_tr_channel),
                       lengths[0]) + rh_square(mu_air,
                                               w_tr_channel,
                                               np.transpose(h_tr_channel),
                                               (total_channel_length  - lengths[0]))

#plotting
fig = plt.figure()
ax = fig.add_subplot(111)
ax.plot(r_specific)
plt.ylabel('Resistance')
plt.xlabel('Samples')
plt.show()
```

Array is a product of volume and trigger channel geometry.

Volume goes down rows, and trigger channel depth is across columns.

Every point in the resistance should have a corresponding point in the main channel capillary pressure.

To calculate the main channel capillary pressure the principal radii form of the Young-Laplace equation can be used. The horizontal radius is found from the meniscus fittings above, while the vertical radius is found by

\begin{equation}
R_{vertical} = \frac{h_{main}}{cos\theta_{PMMA} + cos\theta_{PDMS}}
\end{equation}

```python
# calculate the main channel capillary presssure.
# interpolate the meniscus radii so that the dispalced volumes arrays have a corresponding radii
# first arg is volume points the up-sampled volume points, expressed in uL, second two arguments, are the X, Y points that are going to be linearly interpolated and upsampled.
radii_horizontal = pixel_to_mm*np.interp(1000*np.linspace(0.0057,0.0257,step_size), trigger_channel_volumes, smoothed_r_params)

# calculate the radius of the vertical component of the meniscus
radii_vertical = main_chanel_depth/(np.cos(np.deg2rad(theta_pmma)) + np.cos(np.deg2rad(90)))

principle_radii = [radii_horizontal, radii_vertical]
p_cap_main = main_channel_pcap(principle_radii)

# Plot the variation of main channel pressure against displaced volume
fig = plt.figure()
ax = fig.add_subplot(111)
ax.plot(p_cap_main)
plt.xlabel('Samples')
plt.ylabel('$P_{main}$')
plt.show()
```

Main channel capillary pressure is a product of displaced volume and capillary pressure.
Displaced volume is going down rows, contact angle going across coloumns.

```python
# Empty array to store results in
results = np.empty((0,step_size),float)

# Perform the actual integration
for i in range(step_size):
    row_result = []
    for ii in range(step_size):
        # numerical integration of the model - checks for negative flow before attempting.
        if np.any(-1.0*(p_cap_tr[i,ii] - p_cap_main[:,i]) < 0):
            integrated_result = np.nan
        else:
            integrated_result = np.trapz((r_specific[:,ii])/(-1.0*(p_cap_tr[i,ii] - p_cap_main[:,i])),
                                         x=volumes[i,:].reshape(-1))

        row_result.append(integrated_result)

    row_result = np.array([row_result])
    results = np.append(results, row_result,axis=0)

# generate the plot
fig = plt.figure(figsize=(4,4))
ax = fig.add_subplot()

# Arrays which are used for the plotting
channels = np.empty((0, step_size))
thee = np.empty((0, step_size))

for i in range(step_size):
    channels = np.append(channels, [h_tr],axis=0)
    thee = np.append(thee, [np.flip(theta_pmma)],axis=0)

# Contour plot
CS = ax.contour(thee.transpose(), 1e6*channels, np.log10(results),15,cmap='magma')
ax.set_xlabel('Contact Angle ($^\circ$)', fontsize=12)

fmt = {}
strs = 10**(CS.levels)
for l, s in zip(CS.levels[0:-4:1], strs[0:-4:1]):
    fmt[l] = '{:.3f} s'.format(s)

ax.clabel(CS,CS.levels[0:-4:1],fmt=fmt,fontsize=7)

plt.tight_layout()
ax.set_ylabel('Channel Depth ($\mu m$)',fontsize=12)
plt.savefig('closing-time.png', dpi=500, bbox_inches='tight')

```

```python
print('The minimum closing time is {} seconds'.format(np.nanmin(results)))
print('The maximum closing time is {} seconds'.format(np.nanmax(results)))
```

    The minimum closing time is 0.010185219670789352 seconds
    The maximum closing time is 5.421049963498329 seconds

```python
optimal_theta = np.where(results == np.nanmin(results))[0]
optimal_channel = np.where(results == np.nanmin(results))[1]

print('The optimum closing contact angle is:', np.flip(theta_pmma)[optimal_theta], 'degrees')
print('The optimum trigger channel depth is:', h_tr[optimal_channel], 'meters')
```

    The optimum closing contact angle is: [0.] degrees
    The optimum trigger channel depth is: [0.00011586] meters

```python

```


# Input Driven Observations ("GLM-HMM")

Notebook prepared by Zoe Ashwood: feel free to email me with feedback or questions (zashwood at cs dot princeton dot edu).

This notebook demonstrates the "InputDrivenObservations" class, and illustrates its use in the context of modeling decision-making data as in Ashwood et al. (2020) ([Mice alternate between discrete strategies during perceptual
decision-making](https://www.biorxiv.org/content/10.1101/2020.10.19.346353v1.full.pdf)).

Compared to the model considered in the notebook ["2 Input Driven HMM"](https://github.com/lindermanlab/ssm/blob/master/notebooks/2%20Input%20Driven%20HMM.ipynb), Ashwood et al. (2020) assumes a stationary transition matrix where transition probabilities *do not* depend on external inputs. However, observation probabilities now *do* depend on external covariates according to:

$$
\begin{align}
\Pr(y_t = c \mid z_{t} = k, u_t, w_{kc}) =
\frac{\exp\{w_{kc}^\mathsf{T} u_t\}}
{\sum_{c'=1}^C \exp\{w_{kc'}^\mathsf{T} u_t\}}
\end{align}
$$

where $c \in \{1, ..., C\}$ indicates the categorical class for the observation, $u_{t} \in \mathbb{R}^{M}$ is the set of input covariates, and $w_{kc} \in \mathbb{R}^{M}$ is the set of input weights associated with state $k$ and class $c$. These weights, along with the transition matrix and initial state probabilities, will be learned.

In Ashwood et al. (2020), $C = 2$ as $y_{t}$ represents the binary choice made by an animal during a 2AFC (2-Alternative Forced Choice) task. The above equation then reduces to:

$$
\begin{align}
\Pr(y_t = 1 \mid z_{t} = k, u_t, w_{k}) =
\frac{1}
{1 + \exp\{-w_{k}^\mathsf{T} u_t\}}.
\end{align}
$$

and only a single set of weights is associated with each state.

## 1. Setup
The line `import ssm` imports the package for use. Here, we have also imported a few other packages for plotting.

```python
import numpy as np
import numpy.random as npr
import matplotlib.pyplot as plt
import ssm
from ssm.util import one_hot, find_permutation

%matplotlib inline

npr.seed(0)
```

## 2. Input Driven Observations
We create a HMM with input-driven observations and 'standard' (stationary) transitions with the following line:
```python
        ssm.HMM(num_states, obs_dim, input_dim, observations="input_driven_obs", observation_kwargs=dict(C=num_categories), transitions="standard")
```

As in Ashwood et al. (2020), we are going to model an animal's binary choice data during a decision-making task, so we will set `num_categories=2` because the animal only has two options available to it. We will also set `obs_dim = 1` because the dimensionality of the observation data is 1 (if we were also modeling, for example, the binned reaction time of the animal, we could set `obs_dim = 2`).  For the sake of simplicity, we will assume that an animal's choice in a particular state is only affected by the external stimulus associated with that particular trial, and its innate choice bias. Thus, we will set `input_dim = 2` and we will simulate input data that resembles sequences of stimuli in what follows.  In Ashwood et al. (2020), they found that many mice used 3 decision-making states when performing 2AFC tasks. We will, thus, set `num_states = 3`.

### 2a. Initialize GLM-HMM

```python
# Set the parameters of the GLM-HMM
num_states = 3        # number of discrete states
obs_dim = 1           # number of observed dimensions
num_categories = 2    # number of categories for output
input_dim = 2         # input dimensions

# Make a GLM-HMM
true_glmhmm = ssm.HMM(num_states, obs_dim, input_dim, observations="input_driven_obs",
                   observation_kwargs=dict(C=num_categories), transitions="standard")
```

## Generate test data

```python
def categorical(p):
    return (p.cumsum(-1) >= np.random.uniform(size=p.shape[:-1])[..., None]).argmax(-1)
```

```python
zcurr = 0
scurr = 0

def next_state(zcurr):
    zlst = [0, 1, 2]
    if np.random.uniform() < 0.1: #switch
        zlst.pop(zcurr)
        return zlst[0] if np.random.uniform() > 0.5 else zlst[1]
    return zcurr

def next_target(scurr):
    return scurr if np.random.uniform() > 0.1 else 1 - scurr

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

zlst = [0]
ylst = [0]
slst = [0]
rlst = [0]
w = np.array([[5, 0], [0, 5], [0, -5]])
np.random.seed(0)
for i in range(1000):
    zcurr = next_state(zcurr)
    zlst.append(zcurr)

    scurr = next_target(scurr)
    slst.append(scurr)

    yprob = w[zcurr,0] * (int(ylst[i]) * 2 - 1) * (int(rlst[i]) * 2 - 1) + w[zcurr, 1]
    y = np.random.uniform() < sigmoid(yprob)
    ylst.append(y)

    rcurr = y == scurr
    rlst.append(rcurr)

```

```python
inpts1 = (np.array(ylst, dtype='int') * 2 - 1) * (np.array(rlst, dtype='int') * 2 - 1)
inpts2 = np.ones(len(ylst))
inpts = np.array([inpts1, inpts2]).T
true_choices = np.array([ylst], dtype='int').T
inpts = inpts[:-1,:]
true_choices = true_choices[1:]
print('inpts shape:', inpts.shape, '. true choices shape:', true_choices.shape)
```

    inpts shape: (1000, 2) . true choices shape: (1000, 1)

```python
plt.plot(rlst,label='r')
plt.plot(zlst, label='z')
plt.plot(ylst, label='y')
plt.plot(slst, label='s')
plt.xlim(0, 100)
plt.legend()
```

## 3. Fit GLM-HMM and perform recovery analysis

### 3a. Maximum Likelihood Estimation

Now we instantiate a new GLM-HMM and check that we can recover the generative parameters in simulated data:

```python
new_glmhmm = ssm.HMM(num_states, obs_dim, input_dim, observations="input_driven_obs",
                   observation_kwargs=dict(C=num_categories), transitions="standard")

N_iters = 200 # maximum number of EM iterations. Fitting with stop earlier if increase in LL is below tolerance specified by tolerance parameter
fit_ll = new_glmhmm.fit(true_choices, inputs=inpts, method="em", num_iters=N_iters, tolerance=10**-4)
```

      0%|          | 0/200 [00:00<?, ?it/s]

```python
# Plot the log probabilities of the true and fit models. Fit model final LL should be greater
# than or equal to true LL.
fig = plt.figure(figsize=(4, 3), dpi=80, facecolor='w', edgecolor='k')
plt.plot(fit_ll, label="EM")
#plt.plot([0, len(fit_ll)], true_ll * np.ones(2), ':k', label="True")
plt.legend(loc="lower right")
plt.xlabel("EM Iteration")
plt.xlim(0, len(fit_ll))
plt.ylabel("Log Probability")
plt.show()
```

### 3b. Retrieved parameters

Compare retrieved weights and transition matrices to generative parameters. To do this, we may first need to permute the states of the fit GLM-HMM relative to the
generative model.  One way to do this uses the `find_permutation` function from `ssm`:

```python
new_glmhmm.permute(find_permutation(true_latents[0], new_glmhmm.most_likely_states(true_choices[0], input=inpts[0])))
```

Now plot generative and retrieved weights for GLMs (analogous plot to Figure S1c in
Ashwood et al. (2020)):

```python
new_glmhmm.ob
```

```python
fig = plt.figure(figsize=(4, 3), dpi=80, facecolor='w', edgecolor='k')
cols = ['#ff7f00', '#4daf4a', '#377eb8']
recovered_weights = new_glmhmm.observations.params
for k in range(num_states):
    if k ==0:
#         plt.plot(range(input_dim), gen_weights[k][0], marker='o',
#                  color=cols[k], linestyle='-',
#                  lw=1.5, label="generative")
        plt.plot(range(input_dim), recovered_weights[k][0], color=cols[k],
                     lw=1.5,  label = "recovered", linestyle = '--')
    else:
#         plt.plot(range(input_dim), gen_weights[k][0], marker='o',
#                  color=cols[k], linestyle='-',
#                  lw=1.5, label="")
        plt.plot(range(input_dim), recovered_weights[k][0], color=cols[k],
                     lw=1.5,  label = '', linestyle = '--')
plt.yticks(fontsize=10)
plt.ylabel("GLM weight", fontsize=15)
plt.xlabel("covariate", fontsize=15)
plt.xticks([0, 1], ['stimulus', 'bias'], fontsize=12, rotation=45)
plt.axhline(y=0, color="k", alpha=0.5, ls="--")
plt.legend()
plt.title("Weight recovery", fontsize=15)
```

Now plot generative and retrieved transition matrices (analogous plot to Figure S1c in
Ashwood et al. (2020)):

```python
fig = plt.figure(figsize=(5, 2.5), dpi=80, facecolor='w', edgecolor='k')
plt.subplot(1, 2, 1)
# gen_trans_mat = np.exp(gen_log_trans_mat)[0]
# plt.imshow(gen_trans_mat, vmin=-0.8, vmax=1, cmap='bone')
# for i in range(gen_trans_mat.shape[0]):
#     for j in range(gen_trans_mat.shape[1]):
#         text = plt.text(j, i, str(np.around(gen_trans_mat[i, j], decimals=2)), ha="center", va="center",
#                         color="k", fontsize=12)
# plt.xlim(-0.5, num_states - 0.5)
# plt.xticks(range(0, num_states), ('1', '2', '3'), fontsize=10)
# plt.yticks(range(0, num_states), ('1', '2', '3'), fontsize=10)
# plt.ylim(num_states - 0.5, -0.5)
# plt.ylabel("state t", fontsize = 15)
# plt.xlabel("state t+1", fontsize = 15)
# plt.title("generative", fontsize = 15)

plt.subplot(1, 2, 2)
recovered_trans_mat = np.exp(new_glmhmm.transitions.log_Ps)
plt.imshow(recovered_trans_mat, vmin=-0.8, vmax=1, cmap='bone')
for i in range(recovered_trans_mat.shape[0]):
    for j in range(recovered_trans_mat.shape[1]):
        text = plt.text(j, i, str(np.around(recovered_trans_mat[i, j], decimals=2)), ha="center", va="center",
                        color="k", fontsize=12)
plt.xlim(-0.5, num_states - 0.5)
plt.xticks(range(0, num_states), ('1', '2', '3'), fontsize=10)
plt.yticks(range(0, num_states), ('1', '2', '3'), fontsize=10)
plt.ylim(num_states - 0.5, -0.5)
plt.title("recovered", fontsize = 15)
plt.subplots_adjust(0, 0, 1, 1)

```

### 3c. Posterior State Probabilities

Let's now plot $p(z_{t} = k|\mathbf{y}, \{u_{t}\}_{t=1}^{T})$, the posterior state probabilities, which give the probability of the animal being in state k at trial t.

```python
# Get expected states:
posterior_probs = [new_glmhmm.expected_states(data=data, input=inpt)[0]
                for data, inpt
                in zip([true_choices], [inpts])]
```

```python
fig = plt.figure(figsize=(5, 2.5), dpi=80, facecolor='w', edgecolor='k')
sess_id = 0 #session id; can choose any index between 0 and num_sess-1
for k in range(num_states):
    plt.plot(posterior_probs[sess_id][:, k], label="State " + str(k + 1), lw=2,
             color=cols[k])
plt.ylim((-0.01, 1.01))
plt.yticks([0, 0.5, 1], fontsize = 10)
plt.xlabel("trial #", fontsize = 15)
plt.ylabel("p(state)", fontsize = 15)
plt.xlim(0, 100)
```

```python
decoded_states = np.argmax(posterior_probs[0], axis=1)
copy_states = decoded_states.copy()
copy_states[decoded_states == 1] = 0
copy_states[decoded_states == 0] = 1

# plt.plot(rlst,label='r')
plt.plot(zlst, label='z')
plt.plot(copy_states)
# plt.plot(ylst, label='y')
# plt.plot(slst, label='s')
plt.xlim(0, 500)
plt.legend()
```

```python
np.sum(np.array(copy_states, dtype='int') == np.array(zlst, dtype='int'))
```

    <ipython-input-68-1bd8fa2af5f7>:1: DeprecationWarning: elementwise comparison failed; this will raise an error in the future.
      np.sum(np.array(copy_states, dtype='int') == np.array(zlst, dtype='int'))

    0

```python
np.array(zlst) == np.array(copy_states)
```

    <ipython-input-74-98e8faa0aabf>:1: DeprecationWarning: elementwise comparison failed; this will raise an error in the future.
      np.array(zlst) == np.array(copy_states)

    False

```python
x1 = np.array(zlst)[1:]
x2 = np.array(copy_states)
np.sum(x1==x2)
```

    853

```python
x1.shape
```

    (1000,)

With these posterior state probabilities, we can assign trials to states and then plot the fractional occupancy of each state:

```python
# concatenate posterior probabilities across sessions
posterior_probs_concat = np.concatenate(posterior_probs)
# get state with maximum posterior probability at particular trial:
state_max_posterior = np.argmax(posterior_probs_concat, axis = 1)
# now obtain state fractional occupancies:
_, state_occupancies = np.unique(state_max_posterior, return_counts=True)
state_occupancies = state_occupancies/np.sum(state_occupancies)
```

```python
fig = plt.figure(figsize=(2, 2.5), dpi=80, facecolor='w', edgecolor='k')
for z, occ in enumerate(state_occupancies):
    plt.bar(z, occ, width = 0.8, color = cols[z])
plt.ylim((0, 1))
plt.xticks([0, 1, 2], ['1', '2', '3'], fontsize = 10)
plt.yticks([0, 0.5, 1], ['0', '0.5', '1'], fontsize=10)
plt.xlabel('state', fontsize = 15)
plt.ylabel('frac. occupancy', fontsize=15)
```

## 4. Fit GLM-HMM and perform recovery analysis: Maximum A Priori Estimation

Above, we performed Maximum Likelihood Estimation to retrieve the generative parameters of the GLM-HMM in simulated data. In the small data regime, where we do not have many trials available to us, we may instead want to perform Maximum A Priori (MAP) Estimation in order to incorporate a prior term and restrict the range for the best fitting parameters. Unfortunately, what is meant by 'small data regime' is problem dependent and will be affected by the number of states in the generative GLM-HMM, and the specific parameters of the generative model, amongst other things. In practice, we may perform both Maximum Likelihood Estimation and MAP estimation and compare the ability of the fit models to make predictions on held-out data (see Section 5 on Cross-Validation below).

The prior we consider for the GLM-HMM is the product of a Gaussian prior on the GLM weights, $W$, and a Dirichlet prior on the transition matrix, $A$:

$$
\begin{align}
\Pr(W, A) &= \mathcal{N}(W|0, \Sigma) \Pr(A|\alpha) \\&= \mathcal{N}(W|0, diag(\sigma^{2}, \cdots, \sigma^{2})) \prod_{j=1}^{K} \dfrac{1}{B(\alpha)} \prod_{k=1}^{K} A_{jk}^{\alpha -1}
\end{align}
$$

There are two hyperparameters controlling the strength of the prior: $\sigma$ and $\alpha$.  The larger the value of $\sigma$ and if $\alpha = 1$, the more similar MAP estimation will become to Maximum Likelihood Estimation, and the prior term will become an additive offset to the objective function of the GLM-HMM that is independent of the values of $W$ and $A$.  In comparison, setting $\sigma = 2$ and $\alpha = 2$ will result in the prior no longer being independent of $W$ and $\alpha$.

In order to perform MAP estimation for the GLM-HMM with `ssm`, the new syntax is:

```python
ssm.HMM(num_states, obs_dim, input_dim, observations="input_driven_obs",
             observation_kwargs=dict(C=num_categories,prior_sigma=prior_sigma),
             transitions="sticky", transition_kwargs=dict(alpha=prior_alpha,kappa=0))```

where `prior_sigma` is the $\sigma$ parameter from above, and `prior_alpha` is the $\alpha$ parameter.

```python
# Instantiate GLM-HMM and set prior hyperparameters
prior_sigma = 2
prior_alpha = 2
map_glmhmm = ssm.HMM(num_states, obs_dim, input_dim, observations="input_driven_obs",
             observation_kwargs=dict(C=num_categories,prior_sigma=prior_sigma),
             transitions="sticky", transition_kwargs=dict(alpha=prior_alpha,kappa=0))
```

```python
# Fit GLM-HMM with MAP estimation:
_ = map_glmhmm.fit(true_choices, inputs=inpts, method="em", num_iters=N_iters, tolerance=10**-4)
```

    HBox(children=(HTML(value=''), FloatProgress(value=0.0, max=200.0), HTML(value='')))

Compare final likelihood of data with MAP estimation and MLE to likelihood under generative model (note: we cannot use log_probability that is output of `fit` function as this incorporates prior term, which is not comparable between generative and MAP models). We want to check that MAP and MLE likelihood values are higher than true likelihood; if they are not, this may indicate a poor initialization and that we should refit these models.

```python
true_likelihood = true_glmhmm.log_likelihood(true_choices, inputs=inpts)
mle_final_ll = new_glmhmm.log_likelihood(true_choices, inputs=inpts)
map_final_ll = map_glmhmm.log_likelihood(true_choices, inputs=inpts)
```

```python
# Plot these values
fig = plt.figure(figsize=(2, 2.5), dpi=80, facecolor='w', edgecolor='k')
loglikelihood_vals = [true_likelihood, mle_final_ll, map_final_ll]
colors = ['Red', 'Navy', 'Purple']
for z, occ in enumerate(loglikelihood_vals):
    plt.bar(z, occ, width = 0.8, color = colors[z])
plt.ylim((true_likelihood-5, true_likelihood+15))
plt.xticks([0, 1, 2], ['true', 'mle', 'map'], fontsize = 10)
plt.xlabel('model', fontsize = 15)
plt.ylabel('loglikelihood', fontsize=15)
```

## 5. Cross Validation

To assess which model is better - the model fit via Maximum Likelihood Estimation, or the model fit via MAP estimation - we can investigate the predictive power of these fit models on held-out test data sets.

```python
# Create additional input sequences to be used as held-out test data
num_test_sess = 10
test_inpts = np.ones((num_test_sess, num_trials_per_sess, input_dim))
test_inpts[:,:,0] = np.random.choice(stim_vals, (num_test_sess, num_trials_per_sess))
test_inpts = list(test_inpts)
```

```python
# Create set of test latents and choices to accompany input sequences:
test_latents, test_choices = [], []
for sess in range(num_test_sess):
    test_z, test_y = true_glmhmm.sample(num_trials_per_sess, input=test_inpts[sess])
    test_latents.append(test_z)
    test_choices.append(test_y)
```

```python
# Compare likelihood of test_choices for model fit with MLE and MAP:
mle_test_ll = new_glmhmm.log_likelihood(test_choices, inputs=test_inpts)
map_test_ll = map_glmhmm.log_likelihood(test_choices, inputs=test_inpts)
```

```python
fig = plt.figure(figsize=(2, 2.5), dpi=80, facecolor='w', edgecolor='k')
loglikelihood_vals = [mle_test_ll, map_test_ll]
colors = ['Navy', 'Purple']
for z, occ in enumerate(loglikelihood_vals):
    plt.bar(z, occ, width = 0.8, color = colors[z])
plt.ylim((mle_test_ll-2, mle_test_ll+5))
plt.xticks([0, 1], ['mle', 'map'], fontsize = 10)
plt.xlabel('model', fontsize = 15)
plt.ylabel('loglikelihood', fontsize=15)
```

Here we see that the model fit with MAP estimation achieves higher likelihood on the held-out dataset than the model fit with MLE, so we would choose this model as the best model of animal decision-making behavior (although we'd probably want to perform multiple fold cross-validation to be sure that this is the case in all instantiations of test data).

Let's finish by comparing the retrieved weights and transition matrices from MAP estimation to the generative parameters.

```python
map_glmhmm.permute(find_permutation(true_latents[0], map_glmhmm.most_likely_states(true_choices[0], input=inpts[0])))
```

```python
fig = plt.figure(figsize=(6, 3), dpi=80, facecolor='w', edgecolor='k')
cols = ['#ff7f00', '#4daf4a', '#377eb8']
plt.subplot(1,2,1)
recovered_weights = new_glmhmm.observations.params
for k in range(num_states):
    if k ==0: # show labels only for first state
        plt.plot(range(input_dim), gen_weights[k][0], marker='o',
                 color=cols[k],
                 lw=1.5, label="generative")
        plt.plot(range(input_dim), recovered_weights[k][0], color=cols[k],
                     lw=1.5,  label = 'recovered', linestyle='--')
    else:
        plt.plot(range(input_dim), gen_weights[k][0], marker='o',
                 color=cols[k],
                 lw=1.5, label="")
        plt.plot(range(input_dim), recovered_weights[k][0], color=cols[k],
                     lw=1.5,  label = '', linestyle='--')
plt.yticks(fontsize=10)
plt.ylabel("GLM weight", fontsize=15)
plt.xlabel("covariate", fontsize=15)
plt.xticks([0, 1], ['stimulus', 'bias'], fontsize=12, rotation=45)
plt.axhline(y=0, color="k", alpha=0.5, ls="--")
plt.title("MLE", fontsize = 15)
plt.legend()

plt.subplot(1,2,2)
recovered_weights = map_glmhmm.observations.params
for k in range(num_states):
    plt.plot(range(input_dim), gen_weights[k][0], marker='o',
             color=cols[k],
             lw=1.5, label="", linestyle = '-')
    plt.plot(range(input_dim), recovered_weights[k][0], color=cols[k],
                 lw=1.5,  label = '', linestyle='--')
plt.yticks(fontsize=10)
plt.xticks([0, 1], ['', ''], fontsize=12, rotation=45)
plt.axhline(y=0, color="k", alpha=0.5, ls="--")
plt.title("MAP", fontsize = 15)
```

```python
fig = plt.figure(figsize=(7, 2.5), dpi=80, facecolor='w', edgecolor='k')
plt.subplot(1, 3, 1)
gen_trans_mat = np.exp(gen_log_trans_mat)[0]
plt.imshow(gen_trans_mat, vmin=-0.8, vmax=1, cmap='bone')
for i in range(gen_trans_mat.shape[0]):
    for j in range(gen_trans_mat.shape[1]):
        text = plt.text(j, i, str(np.around(gen_trans_mat[i, j], decimals=2)), ha="center", va="center",
                        color="k", fontsize=12)
plt.xlim(-0.5, num_states - 0.5)
plt.xticks(range(0, num_states), ('1', '2', '3'), fontsize=10)
plt.yticks(range(0, num_states), ('1', '2', '3'), fontsize=10)
plt.ylim(num_states - 0.5, -0.5)
plt.ylabel("state t", fontsize = 15)
plt.xlabel("state t+1", fontsize = 15)
plt.title("generative", fontsize = 15)

plt.subplot(1, 3, 2)
recovered_trans_mat = np.exp(new_glmhmm.transitions.log_Ps)
plt.imshow(recovered_trans_mat, vmin=-0.8, vmax=1, cmap='bone')
for i in range(recovered_trans_mat.shape[0]):
    for j in range(recovered_trans_mat.shape[1]):
        text = plt.text(j, i, str(np.around(recovered_trans_mat[i, j], decimals=2)), ha="center", va="center",
                        color="k", fontsize=12)
plt.xlim(-0.5, num_states - 0.5)
plt.xticks(range(0, num_states), ('1', '2', '3'), fontsize=10)
plt.yticks(range(0, num_states), ('1', '2', '3'), fontsize=10)
plt.ylim(num_states - 0.5, -0.5)
plt.title("recovered - MLE", fontsize = 15)
plt.subplots_adjust(0, 0, 1, 1)

plt.subplot(1, 3, 3)
recovered_trans_mat = np.exp(map_glmhmm.transitions.log_Ps)
plt.imshow(recovered_trans_mat, vmin=-0.8, vmax=1, cmap='bone')
for i in range(recovered_trans_mat.shape[0]):
    for j in range(recovered_trans_mat.shape[1]):
        text = plt.text(j, i, str(np.around(recovered_trans_mat[i, j], decimals=2)), ha="center", va="center",
                        color="k", fontsize=12)
plt.xlim(-0.5, num_states - 0.5)
plt.xticks(range(0, num_states), ('1', '2', '3'), fontsize=10)
plt.yticks(range(0, num_states), ('1', '2', '3'), fontsize=10)
plt.ylim(num_states - 0.5, -0.5)
plt.title("recovered - MAP", fontsize = 15)
plt.subplots_adjust(0, 0, 1, 1)
```


# [Polytropic TOV](https://en.wikipedia.org/wiki/Tolman%E2%80%93Oppenheimer%E2%80%93Volkoff_equation) Initial Data

## Authors: Phil Chang, Zach Etienne, & Leo Werneck
### Formatting improvements courtesy Brandon Clark

## This module sets up initial data for a [TOV](https://en.wikipedia.org/wiki/Tolman%E2%80%93Oppenheimer%E2%80%93Volkoff_equation) star in *spherical, isotropic coordinates*

**Notebook Status:**  Validated

**Validation Notes:** This module has been validated to exhibit convergence to zero of the Hamiltonian constraint violation at the expected order to the exact solution (see [start-to-finish TOV module](Tutorial-Start_to_Finish-BSSNCurvilinear-Setting_up_TOV_initial_data.ipynb) for full test). Note that convergence at the surface of the star is lower order due to the sharp drop to zero in $T^{\mu\nu}$.

### NRPy+ Source Code for this module: [TOV/TOV_Solver.py](../edit/TOV/TOV_Solver.py)

[comment]: <> (Introduction: TODO)

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

This notebook is organized as follows:

1. [Step 1](#initializenrpy): Initialize core Python/NRPy+ modules
1. [Step 2](#tov): The TOV Equations
1. [Step 3](#code_validation): Code Validation against `TOV.TOV_Solver` NRPy+ module
1. [Step 4](#latex_pdf_output): Output this notebook to $\LaTeX$-formatted PDF file

# Step 1: Initialize core Python/NRPy+ modules \[Back to [top](#toc)\]
$$\label{initializenrpy}$$

```python
# Step 1: Import needed Python/NRPy+ modules
import numpy as np                  # NumPy: A numerical methods module for Python
import scipy.integrate as si        # SciPy: Python module for mathematics, science, and engineering applications
import math, sys                    # Standard Python modules for math; multiplatform OS-level functions
import TOV.Polytropic_EOSs as ppeos # NRPy+: Piecewise polytrope equation of state support
```

# Step 2: The TOV equations \[Back to [top](#toc)\]
$$\label{tov}$$

The [TOV line element](https://en.wikipedia.org/wiki/Tolman%E2%80%93Oppenheimer%E2%80%93Volkoff_equation) in terms of the *Schwarzschild coordinate* $r$ is written (in the $-+++$ form):
$$
ds^2 = - c^2 e^\nu dt^2 + \left(1 - \frac{2Gm}{rc^2}\right)^{-1} dr^2 + r^2 d\Omega^2,
$$
where $m(r)$ is the mass-energy enclosed at a given $r$, and is equal to the total star's mass outside the stellar radius $r=R$.

In terms of the *isotropic coordinate* $\bar{r}$ with $G=c=1$ (i.e., the coordinate system and units we'd prefer to use), the ($-+++$ form) line element is written:
$$
ds^2 = - e^{\nu} dt^2 + e^{4\phi} \left(d\bar{r}^2 + \bar{r}^2 d\Omega^2\right),
$$
where $\phi$ here is the *conformal factor*.

Setting components of the above line element equal to one another, we get (in $G=c=1$ units):

\begin{align}
r^2 &= e^{4\phi} \bar{r}^2 \implies e^{4\phi} = \frac{r^2}{\bar{r}^2} \\
\left(1 - \frac{2m}{r}\right)^{-1} dr^2 &= e^{4\phi} d\bar{r}^2 \\
\implies \frac{d\bar{r}(r)}{dr} &= \left(1 - \frac{2m}{r} \right)^{-1/2} \frac{\bar{r}(r)}{r}.
\end{align}

The TOV equations provide radial ODEs for the pressure and $\nu$ (from [the Wikipedia article on the TOV solution](https://en.wikipedia.org/wiki/Tolman%E2%80%93Oppenheimer%E2%80%93Volkoff_equation)):

\begin{align}
\frac{dP}{dr} &= - \frac{1}{r} \left( \frac{\rho + P}{2} \right) \left(\frac{2 m}{r} + 8 \pi r^2 P\right) \left(1 - \frac{2 m}{r}\right)^{-1} \\
\frac{d \nu}{d r} &= \frac{1}{r}\left(1 - \frac{2 m}{r}\right)^{-1} \left(\frac{2 m}{r} + 8 \pi r^2 P\right) \\
\end{align}

Assuming a polytropic equation of state, which relates the pressure $P$ to the baryonic rest-mass density $\rho_B$,

$$
P(\rho_B) = K \rho_B^\Gamma,
$$
the specific internal energy will be given by
$$
\epsilon = \frac{P}{\rho_B (\Gamma - 1)},
$$

so the total mass-energy density $\rho$ is given by
$$
\rho = \rho_B (1 + \epsilon).
$$

Given this, the mass-energy $m(r)$ density is the solution to the ODE:
$$
\frac{dm(r)}{dr} = 4\pi r^2 \rho(r)
$$

Thus the full set of ODEs that need to be solved is given by

$$
\boxed{
\begin{matrix}
\frac{dP}{dr} &=& - \frac{1}{r} \left( \frac{\rho + P}{2} \right) \left(\frac{2 m}{r} + 8 \pi r^2 P\right) \left(1 - \frac{2 m}{r}\right)^{-1} \\
\frac{d \nu}{d r} &=& \frac{1}{r}\left(1 - \frac{2 m}{r}\right)^{-1} \left(\frac{2 m}{r} + 8 \pi r^2 P\right) \\
\frac{m(r)}{dr} &=& 4\pi r^2 \rho(r) \\
\frac{d\bar{r}(r)}{dr} &=& \left(1 - \frac{2m}{r} \right)^{-1/2} \frac{\bar{r}(r)}{r}
\end{matrix}
}\ .
$$

The following code solves these equations, and was largely written by Phil Chang.

```python
# Step 2: The TOV equations

## TOV SOLVER FOR SINGLE AND PIECEWISE POLYTROPES
## Authors: Phil Chang, Zachariah B. Etienne, Leo Werneck

# Full documentation for this module may be found in the NRPy+ tutorial Jupyter notebook:
#  Tutorial-Start_to_Finish-BSSNCurvilinear-Setting_up_TOV_initial_data.ipynb

# Inputs:
# * Output data file name
# * rho_baryon_central, the central density of the TOV star.
# * n, the polytropic equation of state index. n=1 models cold, degenerate neutron star matter.
# * K_Polytrope, the polytropic constant.
# * Verbose output toggle (default = True)

# Output: An initial data file (default file name = "outputTOVpolytrope.txt") that well
#         samples the (spherically symmetric) solution both inside and outside the star.
#         It is up to the initial data module to perform the 1D interpolation to generate
#         the solution at arbitrary radius. The file has the following columns:
# Column 1: Schwarzschild radius
# Column 2: rho(r), *total* mass-energy density (as opposed to baryonic rest-mass density)
# Column 3: P(r), Pressure
# Column 4: m(r), mass enclosed
# Column 5: e^{nu(r)}, g_{tt}(r)
# Column 6: e^{4 phi(r)}, conformal factor g_{rr}(r)
# Column 7: rbar(r), Isotropic radius

# rbar refers to the isotropic radius, and
# R_Schw refers to the Schwarzschild radius

def TOV_Solver(eos,
               outfile = "outputTOVpolytrope.txt",
               rho_baryon_central = 0.129285,
               verbose = True,
               return_M_and_RSchw = False,
               accuracy = "medium",
               integrator_type = "default",
               no_output_File = False,
               pressure_renormalization=1): # reset the pressure to stellar oscillations studies

    def TOV_rhs(r_Schw, y) :
    # In \tilde units
    #
        P    = y[0]
        m    = y[1]
        # nu   = y[2] # nu is not needed as input into TOV_rhs
        rbar = y[3]

        # Compute rho_b and eps_cold, to be used below
        # to compute rho_(total)
        rho_baryon, eps_cold = ppeos.Polytrope_EOS__compute_rhob_and_eps_cold_from_P_cold(eos,P)

#         with open("rhob_P_cold_and_eps_cold.dat","a+") as file:
#             file.write(str(r_Schw).format("%.15e")+"  "+str(rho_baryon).format("%.15e")+"  "+str(P).format("%.15e")+"  "+str(eps_cold).format("%.15e")+"\n")

        # Compute rho, the *total* mass-energy density:
        # .------------------------------.
        # | rho = (1 + eps)*rho_(baryon) |
        # .------------------------------.
        # with eps = eps_cold, for the initial data.
        rho = (1.0 + eps_cold)*rho_baryon

#         m = 4*math.pi/3. * rho*r_Schw**3
        if( r_Schw < 1e-4 or m <= 0.):
            # From https://github.com/natj/tov/blob/master/tov.py#L33:
            # dPdr = -cgs.G*(eden + P/cgs.c**2)*(m + 4.0*pi*r**3*P/cgs.c**2)
            # dPdr = dPdr/(r*(r - 2.0*cgs.G*m/cgs.c**2))
            dPdrSchw = -(rho + P)*(4.*math.pi/3.*r_Schw*rho + 4.*math.pi*r_Schw*P)/(1.-8.*math.pi*rho*r_Schw*r_Schw)
            drbardrSchw = 1./(1. - 8.*math.pi*rho*r_Schw*r_Schw)**0.5
        else:
            dPdrSchw = -(rho + P)*(m + 4.*math.pi*r_Schw**3*P)/(r_Schw*r_Schw*(1.-2.*m/r_Schw))
            drbardrSchw = 1./(1. - 2.*m/r_Schw)**0.5*rbar/r_Schw

        dmdrSchw  =  4.*math.pi*r_Schw*r_Schw*rho
        dnudrSchw = -2./(P + rho)*dPdrSchw
        return [dPdrSchw, dmdrSchw, dnudrSchw, drbardrSchw]

    def integrateStar( eos, P, dumpData = False ):

        if accuracy == "medium":
            min_step_size = 1e-5
            max_step_size = 1e-2
            integrator    = 'dop853'
        elif accuracy == "low":
            min_step_size = 1e-3
            max_step_size = 1e-1
            integrator    = 'dopri5'
        elif accuracy == "verylow":
            min_step_size = 1e-1
            max_step_size = 5e-1
            integrator    = 'dopri5'
        elif accuracy == "high":
            min_step_size = 1e-5
            max_step_size = 1e-5
            integrator    = 'dop853'
        elif accuracy == "veryhigh":
            min_step_size = 1e-7
            max_step_size = 1e-6
            integrator    = 'dop853'
        else:
            print("Unknown accuracy option: "+str(accuracy))

        if integrator_type == "default":
            pass
        else:
            integrator = integrator_type

        integrator = si.ode(TOV_rhs).set_integrator(integrator)#,rtol=1e-4,atol=1e-4)
#         integrator = si.ode(TOV_rhs).set_integrator('dopri5',rtol=1e-4)
        y0 = [P, 0., 0., 0.]
        r_Schw = 0.
        integrator.set_initial_value(y0,r_Schw)
        dr_Schw = min_step_size
        P = y0[0]

        PArr      = []
        r_SchwArr = []
        mArr      = []
        nuArr     = []
        rbarArr   = []

        while integrator.successful() and P > 1e-19*y0[0] :
            P, m, nu, rbar = integrator.integrate(r_Schw + dr_Schw)
            # Update the value of r_Schw to the latest integrated value
            r_Schw += dr_Schw

            dPdrSchw, dmdrSchw, dnudrSchw, drbardrSchw = TOV_rhs( r_Schw, [P,m,nu,rbar])
            dr_Schw = 0.1*min(abs(P/dPdrSchw), abs(m/dmdrSchw))
            dr_Schw = min(dr_Schw, max_step_size)
            PArr.append(P)
            r_SchwArr.append(r_Schw)
            mArr.append(m)
            nuArr.append(nu)
            rbarArr.append(rbar)

        M = mArr[-1]
        R_Schw = r_SchwArr[-1]

        if no_output_File == True:
            return R_Schw, M

        # Apply integration constant to ensure rbar is continuous across TOV surface
        for ii in range(len(rbarArr)):
            rbarArr[ii] *= 0.5*(np.sqrt(R_Schw*(R_Schw - 2.0*M)) + R_Schw - M) / rbarArr[-1]

        nuArr_np = np.array(nuArr)
        # Rescale solution to nu so that it satisfies BC: exp(nu(R))=exp(nutilde-nu(r=R)) * (1 - 2m(R)/R)
        #   Thus, nu(R) = (nutilde - nu(r=R)) + log(1 - 2*m(R)/R)
        nuArr_np = nuArr_np - nuArr_np[-1] + math.log(1.-2.*mArr[-1]/r_SchwArr[-1])

        r_SchwArrExtend_np = 10.**(np.arange(0.01,5.0,0.01))*r_SchwArr[-1]

        r_SchwArr.extend(r_SchwArrExtend_np)
        mArr.extend(r_SchwArrExtend_np*0. + M)
        PArr.extend(r_SchwArrExtend_np*0.)
        exp2phiArr_np = np.append( np.exp(nuArr_np), 1. - 2.*M/r_SchwArrExtend_np)
        nuArr.extend(np.log(1. - 2.*M/r_SchwArrExtend_np))
        rbarArr.extend( 0.5*(np.sqrt(r_SchwArrExtend_np**2 - 2.*M*r_SchwArrExtend_np) + r_SchwArrExtend_np - M) )

        #phiArr_np = np.append( np.exp(nuArr_np), 1. - 2.*M/r_SchwArrExtend_np)

        # Appending to a Python array does what one would reasonably expect.
        #   Appending to a numpy array allocates space for a new array with size+1,
        #   then copies the data over... over and over... super inefficient.
        r_SchwArr_np     = np.array(r_SchwArr)
        PArr_np          = np.array(PArr)
        rho_baryonArr_np = np.array(PArr) # This is just to initialize the array
        for j in range(len(PArr_np)):
            # Compute rho_b from P
            rho_baryonArr_np[j] = ppeos.Polytrope_EOS__compute_rhob_from_P_cold(eos,PArr_np[j])

        mArr_np               = np.array(mArr)
        rbarArr_np            = np.array(rbarArr)
        confFactor_exp4phi_np = (r_SchwArr_np/rbarArr_np)**2

        # Compute the *total* mass-energy density (as opposed to the *baryonic* mass density)
        rhoArr_np = []
        for i in range(len(PArr)):
            rho_baryon, eps_cold = ppeos.Polytrope_EOS__compute_rhob_and_eps_cold_from_P_cold(eos,PArr[i])
            rho = (1.0 + eps_cold ) * rho_baryon
            rhoArr_np.append(rho)

        if verbose:
            print(len(r_SchwArr_np),len(rhoArr_np),len(rho_baryonArr_np),len(PArr_np),len(mArr_np),len(exp2phiArr_np))

        PArr_np *= pressure_renormalization # set for pressure renormalization studies

        # Special thanks to Leonardo Werneck for pointing out this issue with zip()
        if sys.version_info[0] < 3:
            np.savetxt(outfile, zip(r_SchwArr_np,rhoArr_np,rho_baryonArr_np,PArr_np,mArr_np,exp2phiArr_np,confFactor_exp4phi_np,rbarArr_np),
                       fmt="%.15e")
        else:
            np.savetxt(outfile, list(zip(r_SchwArr_np,rhoArr_np,rho_baryonArr_np,PArr_np,mArr_np,exp2phiArr_np,confFactor_exp4phi_np,rbarArr_np)),
                       fmt="%.15e")

        return R_Schw, M

    # Set initial condition from rho_baryon_central
    P_initial_condition = ppeos.Polytrope_EOS__compute_P_cold_from_rhob(eos, rho_baryon_central)

    # Integrate the initial condition
    R_Schw_TOV, M_TOV = integrateStar(eos, P_initial_condition, True)
    if verbose:
        print("Just generated a TOV star with R_Schw = %.15e , M = %.15e , M/R_Schw = %.15e ." %(R_Schw_TOV,M_TOV,(M_TOV / R_Schw_TOV)))

    if return_M_and_RSchw:
        return M_TOV, R_Schw_TOV

############################
# Single polytrope example #
############################
# Set neos = 1 (single polytrope)
neos = 1

# Set rho_poly_tab (not needed for a single polytrope)
rho_poly_tab = []

# Set Gamma_poly_tab
Gamma_poly_tab = [2.0]

# Set K_poly_tab0
K_poly_tab0 = 1. # ZACH NOTES: CHANGED FROM 100.

# Set the eos quantities
eos = ppeos.set_up_EOS_parameters__complete_set_of_input_variables(neos,rho_poly_tab,Gamma_poly_tab,K_poly_tab0)

# Set initial condition (Pressure computed from central density)
rho_baryon_central  = 0.129285

M_TOV, R_Schw_TOV = TOV_Solver(eos,outfile="outputTOVpolytrope.txt",rho_baryon_central=0.129285,verbose = True,
                               return_M_and_RSchw=True,accuracy="medium",integrator_type="default",
                               pressure_renormalization=1.0)
```

    1256 1256 1256 1256 1256 1256
    Just generated a TOV star with R_Schw = 9.566044579232513e-01 , M = 1.405030336771405e-01 , M/R_Schw = 1.468768334847266e-01 .

# Step 3: Code Validation \[Back to [top](#toc)\]
$$\label{code_validation}$$

Here, as a code validation check, we verify agreement in the SymPy expressions for these TOV initial data between

1. this tutorial and
2. the NRPy+ [TOV.TOV_Solver](../edit/TOV/TOV_Solver.py) module.

```python
# Step 3: Code Validation against TOV.TOV_Solver module

import filecmp

import TOV.TOV_Solver as TOV
TOV.TOV_Solver(eos,
               outfile="outputTOVpolytrope-validation.txt",
               rho_baryon_central=0.129285,
               verbose = True,
               accuracy="medium",
               integrator_type="default",
               no_output_File = False)

if filecmp.cmp('outputTOVpolytrope.txt',
               'outputTOVpolytrope-validation.txt') == False:
    print("ERROR: TOV initial data test FAILED!")
    sys.exit(1)
else:
    print("TOV initial data test PASSED.")
```

    1256 1256 1256 1256 1256 1256
    Just generated a TOV star with
    * M        = 1.405030336771405e-01 ,
    * R_Schw   = 9.566044579232513e-01 ,
    * R_iso    = 8.100085557410308e-01 ,
    * M/R_Schw = 1.468768334847266e-01

    TOV initial data test PASSED.

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

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

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

    Created Tutorial-ADM_Initial_Data-TOV.tex, and compiled LaTeX file to PDF
        file Tutorial-ADM_Initial_Data-TOV.pdf


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

# Worked Examples of Conditional Expectation and Mean Square Error Optimization
Brzezniak [[brzezniak1999basic]](#brzezniak1999basic) is a great book because it
approaches
conditional expectation through a sequence of exercises, which is
what we are
trying to do here. The main difference is that Brzezniak takes a
more abstract
measure-theoretic approach to the same problems. Note that you
*do* need to
grasp  measure theory for advanced areas in  probability, but for
what we have
covered so far, working the same problems in his text using our
methods is
illuminating. It always helps to have more than one way to solve
*any* problem.
I have numbered the examples corresponding to the book and tried
to follow its
notation.

## Example

This is Example 2.1 from Brzezniak.
Three coins, 10p, 20p and 50p are tossed.
The values of the coins that land
heads up are totaled. What is the expected
total given that two coins have
landed heads up?  In this case we have
we want to compute $\mathbb{E}(\xi|\eta)$
where

$$
\xi := 10 X_{10} + 20 X_{20} +50 X_{50}
$$

 where $X_i \in \{0,1\} $ and where $X_{10}$ is the
Bernoulli-distributed random
variable corresponding to the 10p coin (and so
on). Thus, $\xi$ represents the
total value of the heads-up coins. The $\eta$
represents the condition that only
two of the three coins are heads-up,

$$
\eta := X_{10} X_{20} (1-X_{50})+ (1-X_{10}) X_{20} X_{50}+ X_{10} (1-X_{20})
X_{50}
$$

 and is a function that is non-zero *only* when two of the three coins lands
heads-up. Each triple term catches each of these three possibilities. For
example,
the first term equals one when the 10p and 20p are heads up  and the
50p is
heads down. The  the remaining terms are zero.

To compute the
conditional expectation, we want to find a function $h$ of
$\eta$ that minimizes
the mean-squared-error (MSE),

$$
\mbox{MSE}= \sum_{X\in\{0,1\}^3} \frac{1}{2^3} (\xi-h(\eta))^2
$$

 where the sum is taken over all possible triples of outcomes for
$\{X_{10},X_{20} ,X_{50}\}$ because each
of the three coins has a $\frac{1}{2}$
chance of coming up heads.

Now, the question boils down to how can we
characterize the function $h(\eta)$?
Note that $\eta \mapsto \{0,1\}$ so $h$
takes on only two values.  So, the
orthogonal inner product condition is the
following:

$$
\langle \xi -h(\eta), \eta \rangle = 0
$$

 But, because are only interested in $\eta=1$, this simplifies to

$$
\begin{align*}
\langle \xi -h(1), 1 \rangle &= 0  \\\
\langle \xi,1  \rangle
&=\langle h(1),1  \rangle
\end{align*}
$$

 This doesn't look so hard to evaluate but we have to compute the
integral over
the set where $\eta=1$. In other words, we need the set of
triples
$\{X_{10},X_{20},X_{50}\}$ where $\eta=1$. That is, we can
compute

$$
\int_{\{\eta=1\}} \xi dX = h(1)  \int_{\{\eta=1\}} dX
$$

  which is what Brzezniak does. Instead, we can define
$h(\eta)=\alpha \eta$ and
then find $\alpha$. Re-writing the
orthogonal condition gives

$$
\begin{align*}
\langle \xi -\eta, \alpha\eta \rangle &= 0  \\\
\langle \xi,
\eta \rangle &= \alpha \langle \eta,\eta \rangle  \\\
\alpha &= \frac{\langle
\xi, \eta \rangle}{\langle \eta,\eta \rangle}
\end{align*}
$$

 where

$$
\langle \xi, \eta \rangle =\sum_{X\in\{0,1\}^3} \frac{1}{2^3}(\xi\eta)
$$

 Note that we can just sweep over all triples
$\{X_{10},X_{20},X_{50}\}$ because
the definition of $h(\eta)$ zeros out when
$\eta=0$ anyway.  All we have to do
is plug everything in and solve. This
tedious job is perfect for Sympy.

```python
import sympy as S
X10,X20,X50 = S.symbols('X10,X20,X50',real=True)
xi  = 10*X10+20*X20+50*X50
eta = X10*X20*(1-X50)+X10*(1-X20)*(X50)+(1-X10)*X20*(X50)
num=S.summation(xi*eta,(X10,0,1),(X20,0,1),(X50,0,1))
den=S.summation(eta*eta,(X10,0,1),(X20,0,1),(X50,0,1))
alpha=num/den
print(alpha) # alpha=160/3
```

    160/3

This means that

$$
\mathbb{E}(\xi|\eta) = \frac{160}{3} \eta
$$

 which we can check with a quick simulation

```python
import numpy as np
import pandas as pd
d = pd.DataFrame(columns=['X10','X20','X50'])
d.X10 = np.random.randint(0,2,1000)
d.X10 = np.random.randint(0,2,1000)
d.X20 = np.random.randint(0,2,1000)
d.X50 = np.random.randint(0,2,1000)
```

**Programming Tip.**

The code above creates an empty Pandas data frame with the
named columns.
The next four lines assigns values to each of the columns.
The code above simulates flipping the three coins 1000
times. Each column of the
dataframe is either `0` or `1`
corresponding to heads-down or heads-up,
respectively. The
condition is that two of the three coins have landed heads-up.
Next, we can group the columns according to their sums. Note that
the sum can
only be in $\{0,1,2,3\}$ corresponding to `0`
heads-up, `1` heads-up, and so on.

```python
grp=d.groupby(d.eval('X10+X20+X50'))
```

**Programming Tip.**

The `eval` function of the Pandas data frame takes the
named
columns and evaluates the given formula. At the time of this
writing, only
simple formulas involving primitive operations are
possible.

 Next, we can
get the `2` group, which corresponds to
exactly two coins having landed heads-
up, and then evaluate
the sum of the values of the coins. Finally, we can take
the mean
of these sums.

```python
grp.get_group(2).eval('10*X10+20*X20+50*X50').mean()
```

The result is close to `160/3=53.33` which supports
the analytic result. The
following code shows that we
can accomplish the same simulation using pure
Numpy.

```python
import numpy as np
from numpy import array
x=np.random.randint(0,2,(3,1000))
print(np.dot(x[:,x.sum(axis=0)==2].T,array([10,20,50])).mean())
```

In this case, we used the Numpy dot product to compute
the value of the heads-
up coins. The `sum(axis=0)==2` part selects
the columns that correspond to two
heads-up coins.

Still another way to get at the same problem is to forego the
random sampling part and just consider all possibilities
exhaustively using the
`itertools` module in Python's standard
library.

```python
import itertools as it
list(it.product((0,1),(0,1),(0,1)))
```

Note that we need to call `list` above in order to trigger the
iteration in
`it.product`. This is because the `itertools` module is
generator-based so does
not actually *do* the iteration until it is iterated
over (by `list` in this
case).   This shows all possible triples
$(X_{10},X_{20},X_{50})$ where `0` and
`1` indicate heads-down and heads-up,
respectively. The next step is to filter
out the cases that correspond to two
heads-up coins.

```python
list(filter(lambda i:sum(i)==2,it.product((0,1),(0,1),(0,1))))
```

Next, we need to compute the sum of the coins and combine
the prior code.

```python
list(map(lambda k:10*k[0]+20*k[1]+50*k[2],
         filter(lambda i:sum(i)==2,
         it.product((0,1),(0,1),(0,1)))))
```

The mean of the output is `53.33`, which is yet another way to get
the same
result. For this example, we demonstrated the full spectrum of
approaches made
possible using Sympy, Numpy, and Pandas. It is always valuable
to have multiple
ways of approaching the same problem and cross-checking
the result.

## Example
This is Example 2.2 from Brzezniak. Three coins, 10p, 20p and 50p are tossed as
before. What is the conditional expectation of the total amount shown by the
three coins given the total amount shown by the 10p and 20p coins only?  For
this problem,

$$
\begin{align*}
\xi := & 10 X_{10} + 20 X_{20} +50 X_{50}  \\\
\eta :=& 30
X_{10} X_{20} + 20 (1-X_{10}) X_{20} + 10 X_{10} (1-X_{20})
\end{align*}
$$

 which takes on four values $\eta \mapsto \{0,10,20,30\}$ and only
considers the
10p and 20p coins.  In contrast to the last problem, here we are
interested in
$h(\eta)$ for all of the values of $\eta$. Naturally, there are
only four values
for $h(\eta)$ corresponding to each of these four values.
Let's first consider
$\eta=10$. The orthogonal condition is then

$$
\langle\xi-h(10),10\rangle = 0
$$

 The domain for $\eta=10$ is $\{X_{10}=1,X_{20}=0,X_{50}\}$ which we
can
integrate out of the expectation below,

$$
\begin{align*}
\mathbb{E}_{\{X_{10}=1,X_{20}=0,X_{50}\}}(\xi-h(10)) 10  &=0
\\\
\mathbb{E}_{\{X_{50}\}}(10-h(10)+50 X_{50}) &=0  \\\
10-h(10) + 25 &=0
\end{align*}
$$

 which gives $h(10)=35$. Repeating the same process for $\eta \in
\{20,30\}$
gives $h(20)=45$ and $h(30)=55$, respectively.  This is the approach
Brzezniak
takes. On the other hand, we can just look at affine functions,
$h(\eta) = a
\eta + b $ and use brute-force calculus.

```python
from sympy.abc import a,b
h = a*eta + b
eta = X10*X20*30 + X10*(1-X20)*(10)+ (1-X10)*X20*(20)
MSE=S.summation((xi-h)**2*S.Rational(1,8),(X10,0,1),
                (X20,0,1),
                (X50,0,1))
sol=S.solve([S.diff(MSE,a),S.diff(MSE,b)],(a,b))
print(sol)
```

**Programming Tip.**

The `Rational` function from Sympy code expresses a
rational number that Sympy
is able to manipulate as such. This is different that
specifying a fraction
like `1/8.`, which Python would automatically compute as a
floating point
number (i.e., `0.125`). The advantage of using `Rational` is that
Sympy can
later produce rational numbers as output, which are sometimes easier
to make
sense of.

  This means that

$$
\begin{equation}
\mathbb{E}(\xi|\eta) = 25+\eta
\label{_auto1} \tag{1}
\end{equation}
$$

  since $\eta$ takes on only four values, $\{0,10,20,30\}$, we can
write this
out explicitly as

$$
\begin{equation}
\mathbb{E}(\xi|\eta) =
\begin{cases}
25 & \text{for}\: \eta=0
\\\
35 & \text{for}\: \eta=10 \\\
45 & \text{for}\: \eta=20 \\\
55 &
\text{for}\: \eta=30
\end{cases}
\end{equation}
\label{eq:ex21sol} \tag{2}
$$

 Alternatively, we can use orthogonal inner products to write out
the following
conditions for the postulated affine function:

$$
\begin{equation}
\label{eq:ex22a} \tag{3}
\langle \xi-h(\eta), \eta \rangle = 0
\end{equation}
$$

$$
\begin{equation}
\label{eq:ex22b} \tag{4}
\langle \xi-h(\eta),1\rangle = 0
\end{equation}
$$

 Writing these out and solving for $a$ and  $b$ is tedious and
a perfect job for
Sympy. Starting with Equation [3](#eq:ex22a),

```python
expr=S.expand((xi-h)*eta)
print(expr)
```

and then because $\mathbb{E}(X_i^2)=1/2=\mathbb{E}(X_i)$, we make the
following
substitutions

```python
expr.xreplace({X10**2:0.5, X20**2:0.5,X10:0.5,X20:0.5,X50:0.5})
```

We can do this for the other orthogonal inner product in Equation
[4](#eq:ex22b) as follows,

**Programming Tip.**

Because Sympy symbols are
hashable, they can be used as keys in Python
dictionaries as in the `xreplace`
function above.

```python
S.expand((xi-h)*1).xreplace({X10**2:0.5,
                             X20**2:0.5,
                             X10:0.5,
                             X20:0.5,
                             X50:0.5})
```

Then, combining this result with the previous one and solving
for `a` and `b`
gives,

```python
S.solve([-350.0*a-15.0*b+725.0,-15.0*a-b+40.0])
```

which again gives us the final solution,

$$
\mathbb{E}(\xi|\eta) = 25+ \eta
$$

  The following is a quick simulation to demonstrate this. We can
build on the
Pandas dataframe we used for the last example and create
a new column for the
sum of the 10p and 20p coins, as shown below.

```python
d['sm'] = d.eval('X10*10+X20*20')
```

We can group this by the values of this sum,

```python
d.groupby('sm').mean()
```

But we want the expectation of the value of the coins

```python
d.groupby('sm').mean().eval('10*X10+20*X20+50*X50')
```

which is very close to our analytical result in Equation [2](#eq:ex21sol).

##
Example

This is Example 2.3 paraphrased from Brzezniak. Given $X$ uniformly
distributed
on $[0,1]$, find $\mathbb{E}(\xi|\eta)$ where

$$
\xi(x) = 2 x^2
$$

$$
\eta(x) =
\begin{cases}
1 & \mbox{if } x \in [0,1/3]  \\\
2 & \mbox{if } x
\in (1/3,2/3)  \\\
0 & \mbox{if } x \in (2/3,1]
\end{cases}
$$

 Note that this problem is different from the previous two because the
sets that
characterize $\eta$ are intervals instead of discrete points.
Nonetheless, we
will eventually have three values for $h(\eta)$ because $\eta
\mapsto
\{0,1,2\}$. For $\eta=1$, we have the orthogonal conditions,

$$
\langle \xi-h(1),1\rangle = 0
$$

 which boils down to

$$
\mathbb{E}_{\{x \in [0,1/3]\}}(\xi-h(1))=0
$$

$$
\int_0^{\frac{1}{3}}(2 x^2-h(1))dx = 0
$$

 and then by solving this for $h(1)$ gives $h(1)=2/24$. This is the way
Brzezniak works this problem. Alternatively, we can use $h(\eta) =  a + b\eta
+
c\eta^2$ and brute force calculus.

```python
x,c,b,a=S.symbols('x,c,b,a')
xi = 2*x**2

eta=S.Piecewise((1,S.And(S.Gt(x,0),
                         S.Lt(x,S.Rational(1,3)))), #  0 < x < 1/3
                (2,S.And(S.Gt(x,S.Rational(1,3)),
                         S.Lt(x,S.Rational(2,3)))), # 1/3 < x < 2/3,
                (0,S.And(S.Gt(x,S.Rational(2,3)),
                         S.Lt(x,1))))               # 1/3 < x < 2/3
h = a + b*eta + c*eta**2
J=S.integrate((xi-h)**2,(x,0,1))
sol=S.solve([S.diff(J,a),
             S.diff(J,b),
             S.diff(J,c),
            ],
            (a,b,c))
```

```python
print(sol)
print(S.piecewise_fold(h.subs(sol)))
```

Thus, collecting this result gives:

$$
\mathbb{E}(\xi|\eta) = \frac{38}{27} - \frac{20}{9}\eta + \frac{8}{9} \eta^2
$$

  which can be re-written as a piecewise function of x,

$$
\begin{equation}
\mathbb{E}(\xi|\eta(x)) =\begin{cases} \frac{2}{27} &
\text{for}\: 0 < x < \frac{1}{3} \\\frac{14}{27} & \text{for}\: \frac{1}{3} < x
< \frac{2}{3} \\\frac{38}{27} & \text{for}\: \frac{2}{3}<x < 1 \end{cases}
\end{equation}
\label{eq:ex23a} \tag{5}
$$

Alternatively, we can use the orthogonal inner product conditions directly by
choosing $h(\eta)=c+\eta b +\eta^2 a$,

$$
\begin{align*}
\langle \xi-h(\eta),1\rangle = 0 \\\
\langle
\xi-h(\eta),\eta\rangle = 0 \\\
\langle \xi-h(\eta),\eta^2\rangle = 0
\end{align*}
\label{eq:ex23b} \tag{6}
$$

 and then solving for $a$,$b$, and $c$.

```python
x,a,b,c,eta = S.symbols('x,a,b,c,eta',real=True)
xi  = 2*x**2
eta=S.Piecewise((1,S.And(S.Gt(x,0),
                     S.Lt(x,S.Rational(1,3)))),  #  0 < x < 1/3
            (2,S.And(S.Gt(x,S.Rational(1,3)),
                     S.Lt(x,S.Rational(2,3)))), # 1/3 < x < 2/3,
            (0,S.And(S.Gt(x,S.Rational(2,3)),
                     S.Lt(x,1)))) # 1/3 < x < 2/3
h = c+b*eta+a*eta**2
```

Then, the orthogonal conditions become,

```python
S.integrate((xi-h)*1,(x,0,1))
S.integrate((xi-h)*eta,(x,0,1))
S.integrate((xi-h)*eta**2,(x,0,1))
```

Now, we just combine the three equations and solve
for the parameters,

```python
eqs=[ -5*a/3 - b - c + 2/3,
  -3*a - 5*b/3 - c + 10/27,
  -17*a/3 - 3*b - 5*c/3 + 58/81]
sol=S.solve(eqs)
print(sol)
```

We can assemble the final result by substituting in the solution,

```python
print(S.piecewise_fold(h.subs(sol)))
```

which is the same as our analytic result in Equation [5](#eq:ex23a),
just in
decimal format.

**Programming Tip.**

The definition of Sympy's piecewise
function is verbose because of the way
Python parses inequality statements. As
of this writing, this has not been
reconciled in Sympy, so we have to use the
verbose declaration.

To reinforce our result, let's do a quick simulation
using Pandas.

```python
d = pd.DataFrame(columns=['x','eta','xi'])
d.x = np.random.rand(1000)
d.xi = 2*d.x**2
d.xi.head()
```

Now, we can use the `pd.cut` function to group the `x`
values in the following,

```python
pd.cut(d.x,[0,1/3,2/3,1]).head()
```

Note that the `head()` call above is only to limit the printout shown.
The
categories listed are each of the intervals for `eta` that we specified
using
the `[0,1/3,2/3,1]` list. Now that we know how to use `pd.cut`, we
can just
compute the mean on each group as shown below,

```python
d.groupby(pd.cut(d.x,[0,1/3,2/3,1])).mean()['xi']
```

which is pretty close to our analytic result in Equation
[5](#eq:ex23a).
Alternatively, `sympy.stats` has some limited tools for the same
calculation.

```python
from sympy.stats import E, Uniform
x=Uniform('x',0,1)
E(2*x**2,S.And(x < S.Rational(1,3), x > 0))
E(2*x**2,S.And(x < S.Rational(2,3), x > S.Rational(1,3)))
E(2*x**2,S.And(x < 1, x > S.Rational(2,3)))
```

which again gives the same result still another way.

## Example

This is
Example 2.4 from Brzezniak. Find $\mathbb{E}(\xi|\eta)$ for

$$
\xi(x) = 2 x^2
$$

$$
\eta =
\begin{cases}2 & \mbox{if } 0 \le x < \frac{1}{2} \\ x & \mbox{if } \frac{1}{2}
< x \le 1 \end{cases}

\label{eq:ex24} \tag{7}
$$

 Once again, $X$ is uniformly distributed on the unit interval. Note
that $\eta$
is no longer discrete for every domain. For the domain $0 <x <
1/2$, $h(2)$
takes on only one value, say, $h_0$. For this domain, the
orthogonal condition
becomes,

$$
\mathbb{E}_{\{\eta=2\}}((\xi(x)-h_0)2)=0
$$

 which simplifies to,

$$
\begin{align*}
\int_0^{1/2} 2 x^2-h_0 dx &= 0 \\\
\int_0^{1/2} 2 x^2 dx &=
\int_0^{1/2} h_0 dx \\\
h_0 &= 2 \int_0^{1/2} 2 x^2 dx \\\
h_0 &= \frac{1}{6}
\end{align*}
$$

For the other domain where $\{\eta=x\}$ in Equation [7](#eq:ex24), we again
use
the orthogonal condition,

$$
\begin{align*}
\mathbb{E}_{\{\eta=x\}}((\xi(x)-h(x))x)&=0 \\\
\int_{1/2}^1
(2x^2-h(x)) x dx &=0 \\\
h(x) &= 2x^2
\end{align*}
$$

 Assembling the solution gives,

$$
\mathbb{E}(\xi|\eta(x)) =\begin{cases} \frac{1}{6} & \text{for}\: 0 \le x <
\frac{1}{2} \\ 2 x^2 & \text{for}\: \frac{1}{2} < x \le 1 \end{cases}
$$

 although  this result is not explicitly written as a function of $\eta$.

##
Example

This is Exercise 2.6 in Brzezniak. Find $\mathbb{E}(\xi|\eta)$ where

$$
\xi(x) = 2 x^2
$$

$$
\eta(x) = 1 - \lvert 2 x-1 \rvert
$$

 and $X$ is uniformly distributed in the unit interval. We
can write this out as
a piecewise function in the following,

$$
\eta =\begin{cases} 2 x & \text{for}\: 0 \le x < \frac{1}{2} \\ 2 -2x &
\text{for}\: \frac{1}{2} < x \le 1 \end{cases}
$$

 The discontinuity is at $x=1/2$. Let's start with the $\{\eta=2x\}$ domain.

$$
\begin{align*}
\mathbb{E}_{\{\eta=2x\}}((2 x^2-h(2 x)) 2 x)& = 0 \\\
\int_{0}^{1/2} (2x^2-h(2 x) ) 2 x dx &=0
\end{align*}
$$

 We can make this explicitly a function of $\eta$ by a change
of variables
($\eta=2x$) which gives

$$
\int_{0}^{1} (\eta^2/2-h(\eta))\frac{\eta}{2} d\eta =0
$$

 Thus, for this domain, $h(\eta)=\eta^2/2$. Note that due to the
change of
variables, $h(\eta)$ is valid defined over $\eta\in[0,1]$.

For the other domain
where $\{\eta=2-2x\}$, we have

$$
\begin{align*}
\mathbb{E}_{\{\eta=2-2x\}}((2 x^2-h(2-2x)) (2-2x))& = 0 \\\
\int_{1/2}^{1} (2 x^2-h(2-2x) ) (2-2x) dx &=0
\end{align*}
$$

 Once again, a change of variables makes the $ \eta$ dependency
explicit using
$\eta=2-2x$ which gives

$$
\begin{align*}
\int_{0}^{1} ((2-\eta)^2/2-h(\eta) ) \frac{\eta}{2} d\eta &=0
\\\
h(\eta) &= (2-\eta)^2/2
\end{align*}
$$

 Once again, the change of variables means this solution is valid
over
$\eta\in[0,1]$. Thus, because both pieces are valid over the
same domain
($\eta\in[0,1]$), we can just add them to get the final solution,

$$
h(\eta) = \eta^2-2\eta+2
$$

 A quick simulation can help bear this out.

```python
from pandas import DataFrame
import numpy as np
d = DataFrame(columns=['xi','eta','x','h','h1','h2'])
# 100 random samples
d.x = np.random.rand(100)
d.xi = d.eval('2*x**2')
d.eta =1-abs(2*d.x-1)
d.h1=d[(d.x<0.5)].eval('eta**2/2')
d.h2=d[(d.x>=0.5)].eval('(2-eta)**2/2')
d.fillna(0,inplace=True)
d.h = d.h1+d.h2
d.head()
```

Note that we have to be careful where we apply the individual
solutions using
the slice `(d.x<0.5)` index. The `fillna` part ensures that the
default `NaN`
that fills out the empty row-etries is replaced with zero before
combining the
individual solutions. Otherwise, the `NaN` values would circulate
through the
rest of the computation. The following is the
essential code that draws
[Figure](#fig:Conditional_expectation_MSE_005).

```python
%matplotlib inline

from matplotlib.pyplot import subplots
fig,ax=subplots()
ax.plot(d.xi,d.eta,'.',alpha=.3,label='$\eta$')
ax.plot(d.xi,d.h,'k.',label='$h(\eta)$')
ax.legend(loc=0,fontsize=18)
ax.set_xlabel('$2 x^2$',fontsize=18)
ax.set_ylabel('$h(\eta)$',fontsize=18)
```

**Programming Tip.**

Basic LaTeX formatting works for the labels in
[Figure](#fig:Conditional_expectation_MSE_005).  The `loc=0` in the `legend`
function is the code for the *best* placement for the labels in the legend. The
individual labels should be specified when the elements are drawn individually,
otherwise they will be hard to separate out later. This is accomplished using
the `label` keyword in the `plot` commands.

```python
from matplotlib.pyplot import subplots
from pandas import DataFrame
import numpy as np

d = DataFrame(columns=['xi','eta','x','h','h1','h2'])
# 100 random samples
d.x = np.random.rand(100)
d.xi = d.eval('2*x**2')
d.eta =1-abs(2*d.x-1)
d.h1=d[(d.x<0.5)].eval('eta**2/2')
d.h2=d[(d.x>=0.5)].eval('(2-eta)**2/2')
d.fillna(0,inplace=True)
d.h = d.h1+d.h2

fig,ax=subplots()
_=ax.plot(d.xi,d.eta,'.k',alpha=.3,label=r'$\eta$')
_=ax.plot(d.xi,d.h,'ks',label=r'$h(\eta)$',alpha=.3)
_=ax.set_aspect(1)
_=ax.legend(loc=0,fontsize=18)
_=ax.set_xlabel(r'$\xi=2 x^2$',fontsize=24)
_=ax.set_ylabel(r'$h(\eta),\eta$',fontsize=24)
fig.tight_layout()
fig.savefig('fig-probability/Conditional_expectation_MSE_Ex_005.png')
```

The diagonal line shows where the conditional expectation equals the $\xi$
function.

[Figure](#fig:Conditional_expectation_MSE_005)
shows the $\xi$ data plotted
against $\eta$ and $h(\eta) =
\mathbb{E}(\xi|\eta)$. Points on the diagonal
are points where $\xi$ and
$\mathbb{E}(\xi|\eta)$ match. As shown by the
dots, there is no agreement
between the raw $\eta$ data and $\xi$. Thus, one
way to think about the
conditional expectation is as a functional transform
that bends the curve onto
the diagonal line. The black dots plot $\xi$
versus $\mathbb{E}(\xi|\eta)$ and
the two match everywhere along the diagonal
line. This is to be expected because
the conditional expectation is the MSE
best estimate for $\xi$ among all
functions of $\eta$.

## Example

This is Exercise 2.14 from Brzezniak. Find
$\mathbb{E}(\xi|\eta)$ where

$$
\xi(x) = 2 x^2
$$

$$
\eta =
\begin{cases} 2x & \mbox{if } 0 \le x < \frac{1}{2} \\ 2x-1 & \mbox{if
} \frac{1}{2} < x \le 1 \end{cases}
$$

 and $X$ is uniformly distributed in the unit interval.  This is the
same as the
last example and the only difference here is that $\eta$ is not
continuous at
$x=\frac{1}{2}$, as before. The first part is exactly the same as
the first part
of the prior example so we will skip it here. The second part
follows the same
reasoning as the last example, so we will just write the
answer for the $\{\eta
= 2x-1\}$ case as the following

$$
h(\eta)=\frac{(1+\eta)^2}{2} , \: \forall \eta \: \in [0,1]
$$

  and then adding these up as before gives the full solution:

$$
h(\eta)= \frac{1}{2} +\eta + \eta^2
$$

 The interesting part about this example is shown in
[Figure](#fig:Conditional_expectation_MSE_006). The dots show where $\eta$ is
discontinuous and yet the $h(\eta)=\mathbb{E}(\xi|\eta)$ solution is equal to
$\xi$ (i.e., matches the diagonal). This illustrates the power of the orthogonal
inner product technique, which does not need continuity or complex
set-theoretic
arguments to calculate solutions. By contrast, I urge you to
consider
Brzezniak's solution to this problem which requires such  methods.

```python
d = DataFrame(columns=['xi','eta','x','h','h1','h2'])
d.x = np.random.rand(100) # 100 random samples
d.xi = d.eval('2*x**2')
d['eta']=(d.x<0.5)*(2*d.x)+(d.x>=0.5)*(2*d.x-1)
d.h1=d[(d.x<0.5)].eval('eta**2/2')
d.h2=d[(d.x>=0.5)].eval('(1+eta)**2/2')
d.fillna(0,inplace=True)
d.h = d.h1+d.h2
fig,ax=subplots()
_=ax.plot(d.xi,d.eta,'.k',alpha=.3,label='$\eta$')
_=ax.plot(d.xi,d.h,'ks',label='$h(\eta)$',alpha=0.3)
_=ax.set_aspect(1)
_=ax.legend(loc=0,fontsize=18)
_=ax.set_xlabel('$2 x^2$',fontsize=24)
_=ax.set_ylabel('$h(\eta),\eta$',fontsize=24)
fig.tight_layout()
fig.savefig('fig-probability/Conditional_expectation_MSE_Ex_006.png')
```

The diagonal line shows where the conditional expectation equals the $\xi$
function.

Extending projection methods to random
variables provides multiple ways for
calculating solutions to conditional
expectation problems. In this section, we
also worked out corresponding
simulations using a variety of Python modules. It
is always advisable to have
more than one technique at hand to cross-check
potential solutions. We worked
out some of the examples in Brzezniak's
book using our methods as a way to show
multiple ways to solve the same
problem. Comparing Brzezniak's measure-theoretic
methods to our less abstract
techniques is a great way to get a handle on both
concepts, which are important
for advanced study in stochastic process.


This IPython-Notebook serves as an illustration for the conference paper

*"Criterion for the existence of a separated coordinate representation for underactuated mechanical systems"*

submitted to "IEEE 2019 International Conference on Mechatronics"

---

The source code depends on the following modules:

* [`symbtools`](https://github.com/TUD-RST/symbtools)
  (Basic functionality for symbolic calculations w.r.t control theory)

* [`pycartan`](https://github.com/TUD-RST/pycartan)
  (Fucntionality for calculations with differential forms)

* [`ipydex`](https://github.com/cknoll/ipydex)
 (Contains the `displaytools`-jupyter-notebook-extension. This enables "magic" comments (e.g. `##:`) which achieve the diplay of the result of an assignment.)

[//]: # (custom_config_start)
$$
\newcommand{\col}{\mathrm{col}}
\newcommand{\opspan}{\operatorname{span}}
\newcommand{\bs}{\boldsymbol}
\newcommand{\mb}{\mathbf}
\newcommand{\nx}{n_{\boldsymbol x}}
\newcommand{\Ddt}{\left(\tfrac{d}{dt}\right)}
$$

$$
\newcommand{\r}{\mathbf{r}}
\newcommand{\F}{\mathbf{F}}
\newcommand{\h}{\mathbf{h}}
\newcommand{\eps}{\varepsilon}
%
\newcommand{\B}{\mathbf{B}}
\newcommand{\M}{\mathbf{M}}
\newcommand{\C}{\mathbf{C}}
\newcommand{\K}{\mathbf{K}}
\newcommand{\btheta}{\boldsymbol{\theta}}
\newcommand{\btau}{\boldsymbol{\tau}}
$$

[//]: # (custom_config_end)

**Considered System**: Underactuated four-joint-manipulator with passive joints and forces at the endeffetor regarded as the systems's input .<br>

**Considered tasks**:
* Generation of equations of motion
* Check of the criterion for the existence of a separated coordinate representation

```python
%load_ext ipydex.displaytools

import sympy as sp
from sympy import sin, cos, pi
from sympy.interactive import printing
import symbtools as st
import symbtools.modeltools as mt
from symbtools.modeltools import Rz # rotation matrix

printing.init_printing(1)
```

```python
t = sp.Symbol('t')
np = 4
nq = 0
n = np + nq
pp = st.symb_vector("p1:{0}".format(np+1))
qq = st.symb_vector("q1:{0}".format(nq+1))

aa = st.symb_vector("a1:{0}".format(nq+1))

ttheta = st.row_stack(pp, qq) ##:T
tthetad = st.time_deriv(ttheta, ttheta) ##:T
tthetadd = st.time_deriv(ttheta, ttheta, order=2) ##:T
st.make_global(ttheta, tthetad)
```

```python
params = sp.symbols('s1, s2, s3, s4, m1, m2, m3, m4, J1, J2, J3, J4, l1, l2, l3, l4, kappa')
st.make_global(params)

#
# QQ = st.symb_vector("Q1, Q2, Q3, Q4")

# forces at the end effector
tau1, tau2 = ttau = st.symb_vector("tau1, tau2")
```

###  Geometry of the mechanical System

```python
Rz(p1) # Rotationsmatirx
```

```python
# unit vectors

ex = sp.Matrix([1, 0])
ey = sp.Matrix([0, 1])

# coordinates of the centers of masses (S)and joints (G)
S1 = Rz(p1)*ex*s1
G1 = Rz(p1)*ex*l1
S2 = G1 + Rz(p1 + p2)*ex*s2
G2 = G1 + Rz(p1 + p2)*ex*l2
S3 = G2 + Rz(p1 + p2 + p3)*ex*s2
G3 = G2 + Rz(p1 + p2 + p3)*ex*l3
S4 = G3 + Rz(p1 + p2 + p3 + p4)*ex*s2
EE = G3 + Rz(p1 + p2 + p3 + p4)*ex*l4

# time derivative of the velocities of the center of masses
Sd1, Sd2, Sd3, Sd4, EEd = st.col_split(st.time_deriv(st.col_stack(S1, S2, S3, S4, EE), ttheta)) ##
```

```python
# kinetic energy
T_rot = (J1*pdot1**2)/2 + (J2*(pdot1 + pdot2)**2)/2 + (J3*(pdot1 + pdot2 + pdot3)**2)/2 + (J4*(pdot1 + pdot2 + pdot3 + pdot4)**2)/2
T_trans = ( m1*Sd1.T*Sd1  +  m2*Sd2.T*Sd2 +  m3*Sd3.T*Sd3 +  m4*Sd4.T*Sd4 )/2

T = T_rot + T_trans[0] ##:

# potential energy
V = 0
```

```python
if 0:
    # Kinetische Energie mit Platzhaltersymbolen einführen:

    M1, M2, M3 = MM = st.symb_vector('M1:4')
    MM_subs = [(J1 + m1*s1**2 + m2*l1**2, M1), (J2 + m2*s2**2, M2), (m2*l1*s2, M3)]
    MM_rplm = st.rev_tuple(MM_subs) # Umkehrung der inneren Tupel -> [(M1, J1+... ), ...]

    T_plh = M1*qdot1**2/2 + M2*pdot1**2/2 + M2*pdot1*qdot1 + M2*qdot1**2/2 + M3*pdot1*qdot1*cos(p1) + M3*qdot1**2*cos(p1)

    # Probe:
    assert sp.simplify(T_plh.subs(MM_rplm) - T) == 0
```

```python
# absolute coordinates
a1 = p1
a2 = a1 + p2
a3 = a2 + p3
a4 = a3 + p4
```

Definition of virtual Work: $\delta W = \sum_{j=1}^N \F_j \delta \r_j$, where $\F_j$ are external forces and $ \delta \r_j$ are virtual displacements of point-mass $j=1, \ldots, N$. See also https://en.wikipedia.org/wiki/Generalized_forces.

We have in general
$$
\delta \mathbf{r}_j = \sum_{i=1}^n \frac {\partial \mathbf {r}_j} {\partial \theta_i} \delta \theta_i,\quad j=1,\ldots, N
$$

$$
\delta W = \underbrace{\sum_{j=1}^N \mathbf {F}_{j} \cdot \frac {\partial \mathbf {r}_j} {\partial \theta_1}}_{Q_1} \delta \theta_1 +
\ldots +
\underbrace{\sum_{j=1}^N \mathbf {F}_{j} \cdot \frac {\partial \mathbf {r}_j} {\partial \theta_m}}_{Q_m} \delta \theta_m = Q_1\delta \theta_1 + \ldots + Q_m\delta \theta_m.
$$
with
$
   Q_i = \sum_{j=1}^N \mathbf {F}_{j} \cdot \frac {\partial \mathbf {r}_j} {\partial \theta_i},\quad i=1,\ldots, m,
$

---

In the present situation we have $N=1$ (external force acts only at end effector) and $\theta_i = p_i$ for $i=1, \ldots, n$ with $n=4$. Thus we have:

$$
 Q_i =  \mathbf {F}_{EE} \cdot \frac {\partial \mathbf {r_{EE}}} {\partial p_i},\quad i=1,\ldots, 4
$$

```python
# virtual work

ttau = st.symb_vector('tau_t, tau_n')

# x-y-coordinates of the Vector of external forces
tau_vect = Rz(a4)*ttau  ##:

```

```python
QQ = (EE.jacobian(pp).T*tau_vect).smplf ##:
```

Calculate the right hand side of

$$
\M(\btheta) + \C(\btheta, \dot \btheta) + \K(\btheta, \dot \btheta) = \B(\btheta) \btau.
$$

```python
B = QQ.jacobian(ttau) ##:

```

### Check integrability with differential form approach

```python
import pycartan as pc

w1, w2 = ww = pc.VectorDifferentialForm(1, ttheta, coeff=B.T)
```

```python
res1 = w1.d^w1^w2
res2 = w2.d^w1^w2
```

```python
res2 = w2.d^w1^w2
```

```python
res1.coeff.smplf ##:
res2.coeff.smplf ##:
```

#### Conclusion:

Both 4-Forms vanish, thus the integrability is given. A separated cooedinate representation exists.

---

### Vector-Field approach (Example 2)

```python
nsm = st.nullspaceMatrix(B.T) ##:

nsm.shape ##:

v1, v2 = st.col_split(nsm)
```

```python
# check that null space Matrix is correct

test = (nsm.T * B).smplf ##:
```

```python
v3 = st.lie_bracket(v1, v2, pp).smplf ##:

Mtest = st.col_stack(v1, v2, v3)

st.generic_rank(Mtest) ##:
```

#### Conlcusion
Distribution is involutive (consistently with the result above).

----

Now consider only one input: either $\tau_\mathrm t$ or $\tau_\mathrm n$. This is equivalent to drop either $\omega_2$ or $\omega_1$.

```python
eta_1 = w1.d^w1
eta_2 = w2.d^w2

eta_1.coeff.simplify()
eta_2.coeff.simplify()

eta_1 ##:
eta_2 ##:
```

    eta_1  := (-l1*l2*sin(p2))dp1^dp2^dp3  +  (-l1*(l2*sin(p2) + l3*sin(p2 + p3)))dp1^dp2^dp4  +  (-l3*(l1*sin(p2 + p3) + l2*sin(p3)))dp1^dp3^dp4  +  (-l2*l3*sin(p3))dp2^dp3^dp4

    ___

    eta_2  := (-l1*l2*sin(p2))dp1^dp2^dp3  +  (-l1*(l2*sin(p2) + l3*sin(p2 + p3)))dp1^dp2^dp4  +  (-l3*(l1*sin(p2 + p3) + l2*sin(p3)))dp1^dp3^dp4  +  (-l2*l3*sin(p3))dp2^dp3^dp4

    ___

```python
eta_1.coeff[0] ##:

eta_2.coeff[0] ##:
```

### Vectorfield approach

```python
# abbreviations:

sscc = st.symb_vector("s_{234}, s_{34}, s_{4}, c_{234}, c_{34}, c_{4}, s_{2}, s_{3}, s_{23}, c_{2}, c_{3}, c_{23}") ##:T

rplmsscc = list(zip([sin(p2+ p3 + p4), sin(p3 + p4), sin(p4), cos(p2+ p3 + p4), cos(p3 + p4), cos(p4),
                     sin(p2), sin(p3), sin(p2 + p3), cos(p2), cos(p3), cos(p2 + p3)], sscc))
```

```python
nsm1 = st.nullspaceMatrix(B[:, 0].T) ##:
nsm2 = st.nullspaceMatrix(B[:, 1].T) ##:

lb1 = st.lie_bracket(nsm1[:, 0], nsm1[:, 1], pp)
lb2 = st.lie_bracket(nsm2[:, 0], nsm2[:, 1], pp)

st.generic_rank(st.col_stack(nsm1, lb1)) ##:
st.generic_rank(st.col_stack(nsm2, lb2)) ##:

det1 = st.col_stack(nsm1, lb1).det().smplf.subs(rplmsscc) ##:
det2 = st.col_stack(nsm2, lb2).det().subs(rplmsscc) ##:
```

### Conclusion
For these two modified systems (either without $\tau_\mathrm t$ or without $\tau_\mathrm n$) a separated coordinate representaion does not exist.


# Mathematique for A to Z

# Operation

| Operation | neutral element |cancel by   | cancel action  |
|-----------|-----------------|------------|----------------|
| addition  | 0               |substraction| opposite       |
| multiplication  | 1         |division    | inverte        |
| power x   | 1               |racine x    | power -x       |

## Addition

```python
from sympy import *

a, b = symbols('a b')

print("Addition neutral element:")
eq1 = a + UnevaluatedExpr(0)
eq2 = a
if eq1.doit() == eq2:  # Test if equation equality
    pprint(relational.Eq(eq1, eq2))

print("Cancel by substraction:")
eq1 = a - UnevaluatedExpr(a)
eq2 = 0
if eq1.doit() == eq2:  # Test if equation equality
    pprint(relational.Eq(eq1, eq2))

print("Addition Comutatif:")
eq1 = a + UnevaluatedExpr(b)
eq2 = b + a
if eq1.doit() == eq2:  # Test if equation equality
    pprint(relational.Eq(eq1, eq2))

print("Soustraction Comutatif:")
eq1 = a - UnevaluatedExpr(b)
eq2 = b - a
if eq1.doit() != eq2:  # Test if equation equality
    pprint(Ne(eq1, eq2))

```

    Addition neutral element:
    a + 0 = a
    Cancel by substraction:
    a - a = 0
    Addition Comutatif:
    a + b = a + b
    Soustraction Comutatif:
    a - b ≠ -a + b

```python
import ipympl
import matplotlib.pyplot as plt

plt.plot([0, 1, 2, 2])
plt.show()
```

```python
print("proof")
print("addition neutral element:")
print("8+0 = {}".format(8+0))
print("addition cancel by substraction:")
print("8+5-5 = {}".format(8+5-5))
print("multiplication neutral element:")
print("8*1 = {}".format(8*1))
print("multiplication cancel by division:")
print("8*5/5 = {}".format(8*5/5))

print("power x neutral element:")
print("8**3 = {}".format(8**3))
print("power x cancel by power 1/x:")
print("8**3**-3 = {}".format(8**3**-3))

```

    proof
    addition neutral element:
    8+0 = 8
    addition cancel by substraction:
    8+5-5 = 8
    multiplication neutral element:
    8*1 = 8
    multiplication cancel by division:
    8*5/5 = 8.0
    power x neutral element:
    8**3 = 512
    power x cancel by power 1/x:
    8**3**-3 = 1.080059738892306

```python
import cairo
from cairo import SVGSurface, Context, Matrix
s = SVGSurface('example1.svg', WIDTH, HEIGHT)
c = Context(s)

# Transform to normal cartesian coordinate system
m = Matrix(yy=-1, y0=HEIGHT)
c.transform(m)

# Set a background color
c.save()
c.set_source_rgb(0.3, 0.3, 1.0)
c.paint()
c.restore()

# Draw some lines
c.move_to(0, 0)
c.line_to(2 * 72, 2* 72)
c.line_to(3 * 72, 1 * 72)
c.line_to(4 * 72, 2 * 72)
c.line_to(6 * 72, 0)
c.close_path()
c.save()
c.set_line_width(6.0)
c.stroke_preserve()
c.set_source_rgb(0.3, 0.3, 0.3)
c.fill()
c.restore()

# Draw a circle
c.save()
c.set_line_width(6.0)
c.arc(1 * 72, 3 * 72, 0.5 * 72, 0, 2 * pi)
c.stroke_preserve()
c.set_source_rgb(1.0, 1.0, 0)
c.fill()
c.restore()
```


## Computer Science Intensive Course - MindX

# LAB 1. ÔN TẬP PYTHON

```python
# run this cell FIRST
%run test_cases_1.ipynb
```

## Bài 1. Ví Dụ
**Input**: Một chuỗi ký tự có độ dài > 1.
**Output**: Chuỗi ký tự được in hoa.
**Hướng dẫn**: Hiện thực hàm uppercase(), nhận một tham số string là chuỗi ký tự được nhập.
**Ví dụ**:
- Input: This is a string
- Output: result = "THIS IS A STRING"

```python
def uppercase(input_str):
    return input_str.upper()

# do some test
uppercase("This is a string")
```

    'THIS IS A STRING'

Kiểm tra kết quả:

```python
# !!! DO NOT MODIFY THIS CELL
# Check result on test cases
test1(uppercase)
```

    Testing on 5 cases.
    - Test 1 PASSED.
    - Test 2 PASSED.
    - Test 3 PASSED.
    - Test 4 PASSED.
    - Test 5 PASSED.
    CONGRATULATIONS! All test cases passed!

## Bài 2. Đường Chéo Hình Vuông
**Input**: Một số thực *a* là độ dài một cạnh của hình vuông.
**Output**: Độ dài đường chéo của hình vuông đó.
Biết:
- Công thức Pytago cho tam giác ABC vuông tại A:
$\begin{equation} AB^2 + AC^2 = BC^2 \end{equation}$
- Hàm tính căn bậc 2 trong thư viện *math*: *sqrt()*

**Ví dụ**:
- Input: a = 2
- Output: 2.8284271247461903

```python
import math

def cal_diagonal(edge_length):
    pass
```

```python
# !!! DO NOT MODIFY THIS CELL
# Check result on test cases
test2(cal_diagonal)
```

    Testing on 7 cases.
    - Test 1 PASSED.
    - Test 2 PASSED.
    - Test 3 PASSED.
    - Test 4 PASSED.
    - Test 5 PASSED.
    - Test 6 PASSED.
    - Test 7 PASSED.
    CONGRATULATIONS! All test cases passed!

## Bài 3. Tổng Chữ Số
**Input**: Một số nguyên *n* có 4 chữ số
**Output**: Tổng các chữ số trong *n*
**Ví dụ**:
- Input: n = 1234
- Output: result = 10
- Giải thích: result = 1+2+3+4 = 10

```python
def sum_digits(num):
    pass

sum_digits(1234)
```

    10

```python
# !!! DO NOT MODIFY THIS CELL
# Check result on test cases
test3(sum_digits)
```

    Testing on 6 cases.
    - Test 1 PASSED.
    - Test 2 PASSED.
    - Test 3 PASSED.
    - Test 4 PASSED.
    - Test 5 PASSED.
    - Test 6 PASSED.
    CONGRATULATIONS! All test cases passed!

## Bài 4. Thứ Trong Tuần
**Input**: Một số nguyên *2 <= n <= 8*.
**Output**: Tên của thứ tương ứng trong tuần.
**Ví dụ**:
Ví dụ 1:
- Input: n = 2
- Output: Monday

Ví dụ 2:
- Input: n = 8
- Output: Sunday

```python
def day_in_week(day_int):
    pass
```

```python
# !!! DO NOT MODIFY THIS CELL
# Check result on test cases
test4(day_in_week)
```

    Testing on 7 cases.
    - Test 1 PASSED.
    - Test 2 PASSED.
    - Test 3 PASSED.
    - Test 4 PASSED.
    - Test 5 PASSED.
    - Test 6 PASSED.
    - Test 7 PASSED.
    CONGRATULATIONS! All test cases passed!

## Bài 5. Tìm Dãy Con
Một dãy con là một dãy liên tiếp các ký tự trong một chuỗi ký tự (string).
**Input**: Một chuỗi ký tự S và một chuỗi ký tự sub_s có độ dài >= 1.
**Output**: Index đầu tiên của sub_s trong S. Trả về -1 nếu không tìm thấy.
**Ví dụ**:
Ví dụ 1:
- Input: S = "abcdef", s = "bc"
- Output: result = 1
- Giải thích: Dãy con "bc" được tìm thấy tại vị trí S[1]

Ví dụ 2:
- Input: S = "abcdefabcdef", s = "bc"
- Output: result = 1
- Giải thích: Dãy con "bc" được tìm thấy tại vị trí S[1] và S[7], tuy nhiên ta chỉ cần in ra vị trí đầu tiên tìm thấy.

Ví dụ 3:
- Input: S = "ABCDEF", s = "bc"
- Output: result = -1

```python
def find_substring(string, substring):
    pass
```

```python
# !!! DO NOT MODIFY THIS CELL
# Check result on test cases
test5(find_substring)
```

    Testing on 7 cases.
    - Test 1 PASSED.
    - Test 2 PASSED.
    - Test 3 PASSED.
    - Test 4 PASSED.
    - Test 5 PASSED.
    - Test 6 PASSED.
    - Test 7 PASSED.
    CONGRATULATIONS! All test cases passed!

```python

```