# Software profesional en Acústica 2020-21 (M2i)

*This notebook contains a modification of the notebook [FEM_Helmholtz_equation_Robin](https://github.com/spatialaudio/computational_acoustics/blob/master/FEM_Helmholtz_equation_Robin.ipynb), created by Sascha Spors, Frank Schultz, Computational Acoustics Examples, 2018. The text/images are licensed under [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/). The code is released under the [MIT license](https://opensource.org/licenses/MIT).*

# Numerical Solution of the Helmholtz Equation in unbounded domains with Perfectly Matched Layers and a Finite Element Method

This notebook illustrates the numerical solution of the wave equation for an harmonic excitation stated in an unbounded domain using a Finite Element Method. To truncate the unbounded domain, the Perfectly Matched Layer (PML) technique is used.

## Problem Statement

The homogeneous Helmholtz equation is given as

\begin{equation}
c^2\Delta P(\mathbf{x}) + \omega^2 P(\mathbf{x}) = 0\qquad \text{for }\quad\mathbf{x}=(x,y)\in\Omega_F
\end{equation}

where $\Omega_F$ is the fluid subdomain which where the numerical solution is computed. This subdomain $\Omega_F$ is surrounded by an artificial sponge layer, where the PML goberning equations are stated:

\begin{equation}
c^2\mathrm{div}(C\nabla P(\mathbf{x}) + \omega^2 M P(\mathbf{x}) = 0\qquad \text{for }\quad\mathbf{x}=(x,y)\in\Omega_{PML}
\end{equation}

where
$$
C=
\begin{pmatrix}
\frac{\gamma_y}{\gamma_x} & 0\\
0 & \frac{\gamma_x}{\gamma_y}
\end{pmatrix},
\qquad
M = \gamma_x\gamma_y
$$

with
$$
\gamma_{x}(x)=1+\frac{i}{\omega}\sigma_{x}(s),\qquad \gamma_{y}(y)=1+\frac{i}{\omega}\sigma_{y}(y)
$$

The function $\sigma_x$ and $\sigma_y$ are the so-called PML absorption profiles. These functions can be constant, quadratic or even singular. The only requirements are: $\sigma_x$ and $\sigma_y$ are positive and monotonically increasing.

The set of governing equations must be completed with boundary conditions in the fluid subdomain (in this case, Dirichlet boundary conditions will be used $P=1$ in the interior fluid boundary), and also null Dirichlet boundary conditions will be used in the exterior boundary of the PML subdomain $P=0$. Finally, to couple both fluid and PML subdomains, it is imposed
\begin{align}
u|_{\Omega_{F}}=u|_{\Omega_{PML}}\qquad \text{on }\quad\partial\Omega_{F}\cap \partial\Omega_{PML},\\
\nabla u|_{\Omega_{F}}\cdot \mathbf{n}=C\nabla u|_{\Omega_{PML}}\cdot \mathbf{n}\qquad \text{on }\quad\partial\Omega_{F}\cap \partial\Omega_{PML},\\
\end{align}

which are the natural coupling boundary conditions to avoid any reflection between both subdomains.

## Variational Formulation

If we extend the definition of the absorption PML profiles in the fluid subdomain as $\sigma_x(x)=0$ and $\sigma_y(y)=0$ for $\mathbf{x}=(x,y)\in\Omega_{F}$, then the Helmholtz equation and the PML equation can be written in the same manner in $\Omega=\Omega_F\cup\Omega_{PML}$. Again, as it has been made before, separating the real and the imaginary part of the equations and using a Green's formula, it holds that the pressure field $P=P_{re}+iP_{im}$

\begin{align}
&c^2\int_\Omega (C_{re}\nabla P_{re}-C_{im}\nabla P_{im})\cdot \nabla Q_{re} \mathrm{d}x
 -\omega^2\int_{\Omega}(M_{re}P_{re}-M_{im}P_{im})Q_{re} \mathrm{d}x, \\
&c^2\int_\Omega (C_{im}\nabla P_{re}+C_{im}\nabla P_{re})\cdot \nabla Q_{im} \mathrm{d}x
 -\omega^2\int_{\Omega}(M_{im}P_{re}+M_{im}P_{re})Q_{im} \mathrm{d}x,
\end{align}

for all test functions $Q=Q_{re}+iQ_{im}$ and
where the PML tensor C is given by $C=C_{re}+iC_{im}$ and $M=M_{re}+iM_{im}$.
It is common to express this integral equation in terms of the bilinear $a((P_{re},P_{im}, (Q_{re},Q_{im}))$ and linear $L((Q_{re},Q_{im}))$ forms

\begin{multline}
a((P_{re},P_{im}, (Q_{re},Q_{im})) = c^2\int_\Omega (C_{re}\nabla P_{re}-C_{im}\nabla P_{im})\cdot \nabla Q_{re} \mathrm{d}x
 c^2\int_\Omega (C_{im}\nabla P_{re}+C_{im}\nabla P_{re})\cdot \nabla Q_{im} \mathrm{d}x \\
 -\omega^2\int_{\Omega}(M_{re}P_{re}-M_{im}P_{im})Q_{re} \mathrm{d}x-\omega^2\int_{\Omega}(M_{im}P_{re}+M_{im}P_{re})Q_{im} \mathrm{d}x
\end{multline}

\begin{equation}
L((Q_{re},Q_{im})) = \int_\Omega f_{re}Q_{re}\mathrm{d}x + \int_\Omega f_{im}Q_{im}\mathrm{d}x ,
\end{equation}

where

\begin{equation}
a(P, V) = L(V).
\end{equation}

In this case, the source term $f=f_{re}+if_{im}=0$.

## Numerical Solution

The numerical solution of the variational problem is based on [FEniCS](https://fenicsproject.org/), an open-source framework for numerical solution of PDEs.
Its high-level Python interface `dolfin` is used in the following to define the problem and compute the solution.
The implementation is based on the variational formulation derived above.
It is common in the FEM to denote the real and imaginay part of the FEM solution of the problem by $u_{re}$, $u_{im}$ and the corresponding test functions by $v_{re}$ and $v_{im}$.
The definition of the problem in FEniCS is very close to the mathematical formulation of the problem.

#### Import modules and definte the physical data and the geometrical setting

```python
import numpy as np
import scipy.special as spe
from dolfin import *
from mshr import *
import matplotlib.pyplot as plt

# Parameter values
amplitude = 1.0 # amplitude [Pa]
degree = 0  # number of the Fourier mode in the exact solution
omega = 2*np.pi*200. # angular frequency [rad/s]
vel = 340 # sound speed [m/s]

# Geometrical setting
Radius = 1.0 # radius of a circular obstacle center at (0,0)
Lx = 2.0; Ly = 2.0; # dimensions of the fluid computational domain
th = 0.75 *vel/(omega/(2.*np.pi)) # PML thickness = 1.5*wavelength
```

#### Compute mesh

```python
# Computational domain
domain = Rectangle(Point(-Lx-th,-Ly-th),Point(Lx+th,Ly+th)) - Circle(Point(0, 0), Radius)

# Set PML subdomains (only for obtaining conformal meshes with the inner PML boundaries and identify the fluid domain)
domain.set_subdomain(1, domain)
domain.set_subdomain(2, Rectangle(Point(-Lx-th,-Ly-th),Point(-Lx,Ly+th)) + Rectangle(Point(Lx,-Ly-th),Point(Lx+th,Ly+th)))
domain.set_subdomain(3, Rectangle(Point(-Lx-th,-Ly-th),Point(Lx+th,-Ly)) + Rectangle(Point(-Lx-th,Ly),Point(Lx+th,Ly+th)))
domain.set_subdomain(4, Rectangle(Point(-Lx-th,-Ly-th),Point(-Lx,-Ly)) + Rectangle(Point(Lx,-Ly-th),Point(Lx+th,-Ly))
                         +Rectangle(Point(-Lx-th,Ly),Point(-Lx,Ly+th)) + Rectangle(Point(Lx,Ly),Point(Lx+th,Ly+th)))

# Create mesh
mesh = generate_mesh(domain, 60) # Values 30,60,120,240
print("Number of vertices: ",mesh.coordinates().shape[0])
print("Mesh h_min, h_max: ", mesh.hmin(), mesh.hmax())

# Plot mesh
plot(mesh)
plt.show()
```

#### Compute boundary and sudomain markers

```python
# Initialize subdomain and boundary markers
tol = 1e-3
PML_boundary = CompiledSubDomain('on_boundary and (near(fabs(x[0]),Lx+th,tol) or near(fabs(x[1]),Ly+th,tol))', Lx=Lx, Ly=Ly, th=th, tol=tol)
circle_boundary = CompiledSubDomain('on_boundary and pow(x[0],2)+pow(x[1],2) < pow(R,2) + tol', R=Radius, tol=tol)

# Initialize mesh function for boundary
boundary_markers = MeshFunction('size_t', mesh, mesh.topology().dim() - 1)
boundary_markers.set_all(0)
circle_boundary.mark(boundary_markers, 1)
PML_boundary.mark(boundary_markers, 2)

# Initialize mesh function for the physical domain
domain_markers = MeshFunction('size_t', mesh, 2, mesh.domains())

# Plot subdomain markers
plot(domain_markers)
plt.show()
```

#### Define the functional spaces and the integral measures

```python
# Define new measures associated with each exterior boundaries
dx = Measure('dx', domain=mesh, subdomain_data=domain_markers)
ds = Measure('ds', domain=mesh, subdomain_data=boundary_markers)

# Define function space (Lagrange 1st polynomials)
P1 = FiniteElement("Lagrange", mesh.ufl_cell(), 1)
Q = FunctionSpace(mesh, P1)
V = FunctionSpace(mesh, P1 * P1)

# Define variational unknowns (potential, Lagrange multiplier and 6 RAOs)
(u_re, u_im) = TrialFunctions(V)
(v_re, v_im) = TestFunctions(V)
```

#### Define source terms and Dirichlet boundary data

```python
# Define source term for the real and the imaginary part and null boundary conditions
zero = Constant("0.0")
f_re = zero
f_im = zero

# Define boundary conditions in the whole boundary for the real and the imaginary part
# Use homogeneous boundary conditions on the exterior boundary of the PML
g_re = Expression('cos(n*atan2(x[1],x[0]))', n=degree, degree=5)
g_im = Expression('sin(n*atan2(x[1],x[0]))', n=degree, degree=5)
bcs = [DirichletBC(V.sub(0), g_re, boundary_markers, 1), DirichletBC(V.sub(1), g_im, boundary_markers, 1),
       DirichletBC(V.sub(0), zero, boundary_markers, 2), DirichletBC(V.sub(1), zero, boundary_markers, 2)]
```

#### Define the fluid and the PML coefficients

```python
# Define the absorption PML profile
sigma0=2e3
sx = Expression('fabs(x[0]) > Lx ? s0*pow(fabs(x[0])-Lx,2)/w : 0.', Lx=Lx, w=omega, s0=sigma0, degree=2)
sy = Expression('fabs(x[1]) > Ly ? s0*pow(fabs(x[1])-Ly,2)/w : 0.', Ly=Ly, w=omega, s0=sigma0, degree=2)

# Define the PML coefficients
gammax_div_gammay_re = 1/(sy*sy + 1) + (sx*sy)/(sy*sy + 1)
gammax_div_gammay_im = sx/(sy*sy + 1) - sy/(sy*sy + 1)
gammay_div_gammax_re = 1/(sx*sx + 1) + (sx*sy)/(sx*sx + 1)
gammay_div_gammax_im = sy/(sx*sx + 1) - sx/(sx*sx + 1)
gammax_dot_gammay_re = 1 - sx*sy
gammax_dot_gammay_im = sx + sy

# Define PDE coefficients
c2 = Expression('pow(vel,2)', vel=vel, degree=0)
w2 = Expression('pow(omega,2)', omega=omega, degree=0)

# Define PML matrices
C_re = as_matrix(((gammay_div_gammax_re, zero), (zero, gammax_div_gammay_re)))
C_im = as_matrix(((gammay_div_gammax_im, zero), (zero, gammax_div_gammay_im)))
M_re = gammax_dot_gammay_re
M_im = gammax_dot_gammay_im
```

#### Define the variational problem and compute the FEM solution

```python
# Define variational formulation in the PML and fluid subdomains
a = c2*inner((C_re*grad(u_re)-C_im*grad(u_im)), grad(v_re))*dx + c2*inner((C_im*grad(u_re)+C_re*grad(u_im)), grad(v_im))*dx \
    -w2*(M_re*u_re-M_im*u_im)*v_re*dx - w2*(M_im*u_re+M_re*u_im)*v_im*dx
L = f_re*v_re*dx + f_im*v_im*dx

# Assembly matrix and source vector
A=assemble(a)
b=assemble(L)

# Apply boundary conditions to matrix and source vector
for bc in bcs:
    bc.apply(A)
    bc.apply(b)

# Compute solution and get real and imaginary parts
w = Function(V)
solve(A, w.vector(), b)
(u_re, u_im) = w.split(True)
u_re.rename("Re(u)", "Real FE approx.")
u_im.rename("Im(u)", "Imag. FE approx.")
```

```python
def plot_soundfield(u):
    '''plots solution of FEM-based simulation'''
    fig = plt.figure(figsize=(10,10))
    fig = dolfin.plot(u)
    plt.xlabel(r'$x$ in m')
    plt.ylabel(r'$y$ in m')
    plt.colorbar(fig, fraction=0.038, pad=0.04);

# plot sound field
plot_soundfield(u_re)
plt.title(r'$P_{re}$')
plot_soundfield(sqrt(u_re**2+u_im**2))
plt.title(r'$|P|$');
```

#### Define the exact solution and compute the $L^2$- relative error in the fluid subdomain

In this case, since the Dirichlet data is an harmonic expression (this is, a complex-exponential $e^{in\theta}$) on the interior circle, the solution is given by
$$
P(r,\theta)=e^{in\theta}H^{(1)}_{n}(kr)
$$
with $k=\omega/c$, $r=\sqrt{x^2+y^2}$, and $\theta=\mathrm{arctan}(y/x)$.

```python
# Define the exact solution based on the Hankel function of first kind and order "degree"
# Real part of the exact solution
class Exact_re(UserExpression):
    def __init__(self, Radius, A, k, n, **kwargs):
        self.Radius = Radius
        self.A = A
        self.k = k
        self.n = n
        super().__init__(**kwargs)

    def eval(self, value, x):
        r=np.sqrt(x[0]**2+x[1]**2)
        theta=np.arctan2(x[1],x[0])
        val = spe.hankel1(self.n, self.k*self.Radius)
        value[0] = np.real(np.exp(1j*self.n*theta)*spe.hankel1(self.n, self.k*r)/val)

    def value_shape(self):
        return ()

class Exact_im(UserExpression):
    def __init__(self, Radius, A, k, n, **kwargs):
        self.Radius = Radius
        self.A = A
        self.k = k
        self.n = n
        super().__init__(**kwargs)

    def eval(self, value, x):
        r=np.sqrt(x[0]**2+x[1]**2)
        theta=np.arctan2(x[1],x[0])
        val = spe.hankel1(self.n, self.k*self.Radius)
        value[0] = np.imag(np.exp(1j*self.n*theta)*spe.hankel1(self.n, self.k*r)/val)

    def value_shape(self):
        return ()

# Expressions of the exact solution and interpolation
uex_re = Exact_re(Radius=Radius, A=amplitude, k=omega/vel, n=degree, degree=3)
uex_im = Exact_im(Radius=Radius, A=amplitude, k=omega/vel, n=degree, degree=3)
uex_re_interp = interpolate(uex_re, Q)
uex_im_interp = interpolate(uex_im, Q)
uex_re_interp.rename("Re(u_ex)", "Real exact")
uex_im_interp.rename("Im(u_ex)", "Imag. exact")

# Compute relative error in L2-norm of the complex-value functions only in the fluid domain
err_re = Function(Q)
err_im = Function(Q)
err_re.vector().set_local(uex_re_interp.vector().get_local()-u_re.vector().get_local())
err_im.vector().set_local(uex_im_interp.vector().get_local()-u_im.vector().get_local())
error_rel=np.sqrt(assemble(err_re**2*dx(1))+assemble(err_im**2*dx(1)))/np.sqrt(assemble(uex_re_interp**2*dx(1))+assemble(uex_im_interp**2*dx(1)))
print("L2-relative error (%): ", error_rel*100.)

```

    L2-relative error (%):  1.177577722523559

**Copyright**

This notebook is provided as [Open Educational Resource](https://en.wikipedia.org/wiki/Open_educational_resources). Feel free to use the notebook for your own purposes. The text is licensed under [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/), the code of the IPython examples under the [MIT license](https://opensource.org/licenses/MIT).


# Program Flow in Python

## Table of Contents
- [What is a computer Program](#def)
- [Functions](#F)
    * [Why Functions?](#WF)
    * [Syntax](#S)
- [Conditional & Logic](#C&L)
- [Iteration](#I)
    * [Aside (Recursion)](#AR)
- [Puting it all together](#PIAT)
- [More about Functions](#MAF)
- [Some topics we haven't discussed, but have used](#last)

# What is a computer Program
[Go back to the main page](#main)

    At its simplest a program is a list of instructions that a computer carries out in order. A program could be long and complicated, but it is built of simple parts. Let's look at some simple operations in python, and think about what the computer does for each one.

```python
1 + 1
```

    2

```python
2 * 4.4
```

    8.8

```python
1 + 1
2 * 4.4
```

    8.8

In the first cell we compute `1 + 1`, and Python returns the results `2`. We can think of this as a very sort program. Similarly, in the second cell we compute `2 * 4.4`, and Python returns the results `8.8`.

However, In the third cell, when we combine these to statements as squential lines, we only see Python return `8.8`.

This is because, Python can only return one result at the end of the cell, so the first line is evaluated, but we never se the result. One way can report intermdiate results is using `print`.

```python
print(1 + 1)
print(2 * 4.4)
```

    2
    8.8

we can also includes **comments** in our lines of code which computer will not execute.These lines can be used to add notes and explanations for the code. We use `#` to start a comment in our code.

```python
# this is a comment and it will not be executed
# print 1 + 1
print(f"1 + 1 is {1+1}")
# print 2 * 4.4
print(f"2 * 4.4 is {2*4.4}")
# print 'completed'
print('completed')
```

    1 + 1 is 2
    2 * 4.4 is 8.8
    completed

While writing a code often we not only want to print intermediate results, but *store* them for future use in the code. We can store a result in computer's memory by assigning it to a **variable**.

```python
first = 1 + 1
final = first * 4.4

print(final)
```

    8.8

Here we are able to use the result of the first calculation (stored in `first`) in our second calculation. We store second calculation in `final`, which ww print in third expression at the end of the cell.

Variables help us to keep track of the information we need to successfully excute a program. Variables can be used to store a variety of information.

```python
my_name = 'Vishwas'
my_age = 22
my_favourite_number = 34
has_pet = False

print(f"My name is {my_name}")
print(f"My age is {my_age}")
print(f"My favourite number is {my_favourite_number}")
print(f"Do I have a Pet: {has_pet}")
```

    My name is Vishwas
    My age is 22
    My favourite number is 34
    Do I have a Pet: False

Since variables are used to store information, it's good idea to give them discriptive names. This increase code readibility, which helps in fixing mistakes, or colloboration ith others

```python
print(type(my_name))
print(type(my_age))
print(type(my_favourite_number))
print(type(has_pet))
```

    <class 'str'>
    <class 'int'>
    <class 'int'>
    <class 'bool'>

* A **String** is a sequence of characters.
* An **integer** is same as mathematics (i.e. "negative & positive whole numbers"
* A **float** or **floating point number** are the decimal numbers (i.e. "real numbers" in mathematics). It is called float because the decimal point is alloed to "float" through the digits, allowing us to rpresent both big numbers (eg. 112334.112) and small numbers (eg. 0.000045).
* A **bool** or **boolean** refers to a variable that is either True or False.

These are some of the few data we will encounter whiach are mainly used during programing.

In Python, we can assign any type of data to a variable without declaring what type the variable will be in advance.

# Functions
[Go back to the main page](#main)

Here is a mathematical function defined by
$$ f(x) = 3x - 5 $$
we give function an input and gets an output
$$ f(6)-> 13 $$
if we have a variable
$$ y = 3 $$
and we evaluate function on y we get same result
$$ f(y)-> 4 $$

Many programs react to user input. Function allows us to dfine a task a computer will carry out based on a given input. A simply Pyhton function look like this:

```python
def cube(number):
    return number**3
```

We define function using the `def`. Then comes the name of the function, hich in this case is `cube`. we then enclose the function input in paranthesis, in this case `number`. We use `:` to indicate the Python the starting of the body of the function

In this case th body of the function is vary simple, we return the cube of `number` (we use `**` for exponents in Python). The keyword `return` signals that the function will generate some output. Not every function will have a `return` statement. A `return` statement ends a function

```python
# we can store the function output in variables
cube_6 = cube(6)

print(cube_6)

my_input = 3.4
# we can also use variables as function input
print(cube(my_input))
```

    216
    39.303999999999995

We can pass different input to the `cube` function, including variables. When we passed a float to `cube`, float is returned. When we passed an integer, `cube` returned integer. In both cases the input was interpretd by the function as the  argument `number`

Not all possible inputs are valid.

```python
print(cube('apple'))
```

We ran into an error because `apple` is a string, not a number. This is because exponent doesn't work on `string`. W should be careful t omake sure that the input for a function makes sense for the function's purpose

We can test the function using assertion weather the oupput is equal to the given value

```python
assert cube(3) == 23
```

## Why Functions?
[Go back to the main page](#main)

On other use of function apart from handling the user input is performing an action multiple times on different input. If we want to perform square a bunch of numbers, in particular the number between 1 to 9, I can do this pretty easily (later we will learn about iteration which will make this even easier!)

```python
1**2
2**2
3**2
4**2
5**2
6**2
7**2
8**2
9**2
```

    81

```python
print(1**2)
print(2**2)
print(3**2)
print(4**2)
print(5**2)
print(6**2)
print(7**2)
print(8**2)
print(9**2)
```

    1
    4
    9
    16
    25
    36
    49
    64
    81

What if we want to go back and add `3` to all the results? Clearly we can see the problem here. Lets instead define a function to do our work

```python
def my_func(x):
    print(x**2 + 3)
```

Now we can just call the function on every element. If  want to change the output, we just need to change the function

```python
my_func(1)
my_func(2)
my_func(3)
my_func(4)
my_func(5)
my_func(6)
my_func(7)
my_func(8)
my_func(9)
```

    4
    7
    12
    19
    28
    39
    52
    67
    84

Splitting out the work into function is often a way to make code more modular and understandable. It also helps ensure your cod is correct. If write a function and test it correct, we kno it will be correct every time we use it. if we don't break out code into a function, it is very easy to make typos or other errors which will cause our progrmas to break

## Syntax
[Go back to the main page](#main)

As our instructions to the computer become more complicated, we will need to organize them in a way the computer understands. We've already seen an example of `cube` function. There is a specific order to the words and specific symbols we had to use to let Python know which part of the function we the defination and hich part is the body, or which part is the name of the function and which part is th argument. We call the rules for organizing code the programming language's **Syntax**

Python' syntax is very streamlined so that code is readable and intutive. Python uses White space to organize code. For Example:

```python
def ex_1():
    print('ex_1 is running')
    print('returning value "a"')
    return 'a'

ex_1()
```

    ex_1 is running
    returning value "a"

    'a'

```python
def ex_2():
    print('ex_2 is running')
    print('exiting without returing a value')

ex_2()
```

    ex_2 is running
    exiting without returing a value

The function `ex_1` ends with a return statement, but `ex_2` has no return statement. So how does Python know where `ex_2` ends? We use indentation to indicate which lines are part of th function and which aren't. The indented lines are all grouped together undr the function defination.

# Conditional & Logic
[Go back to the main page](#main)

We'll often want the computer only to take an action under certain situations. For Example, we might want a game to print the message 'High Score!', but only if the player's high score is higher than the previous high score. We can write this as a formal logical statement: if the player's score is higher than the previous high scor thn print 'High Score!'

The syntax for exprssing this logic in python is vry similar. Let's define a function that accepts the player's score and the previous high score as arguments. If the player's score is higher, then it will print 'High score!'. Finally, it will return the new high score (whichever one that is).

```python
def test_high_score(player_score, high_score):
    if player_score > high_score:
        print('High score!')
        high_score = player_score

    return high_score
```

```python
print(test_high_score(83, 98))
```

    98

```python
print(test_high_score(95, 93))
```

    High score!
    95

With if statements we use a similar syntax as we used for organizing functions. With functions we had a `def` statement ending with `:`, and an indented body. Similarly for a conditional, we have an if statement ending with `:`, and an indented body.

Conditional statements are used to control program flow. We can visualize our example, `test_high_score`, in a decision tree

We can nest `if` statements to make more complicatd trees

```python
def nested_example(x):
    if x < 50:
        if x % 2 == 0:
            return 'branch_a'
        else:
            return 'branch_b'
    else:
        return 'branch_c'

print(nested_example(42))
print(nested_example(51))
print(nested_example(37))
```

    branch_a
    branch_c
    branch_b

In this example, we have an `if` statement nested under another `if` statement. As we change the input, we end up on different branches of the tree.

The statement that follows the `if` is called the **condition**. Th condition can be either true or false. If the condition is true, then we execute the statements under the `if`.If the condition is false, then we execute the statements under the `else` (or if there is no `else`, then we do nothing).

Conditions themselves are instructions that Python can interpret.

```python
print(50 > 10)
print(2 + 2 == 4)
print(-3 > 2)
```

    True
    True
    False

Conditions are evaluated as booleans, which are `True` or `False`. We can combine conditions by asking of condition A _and_ condition B are true. We could also ask if condition A *or* condition B are true. Let's consider whether such statements are true overall based on the possible values of condition A and condition B.

|Condition A|Condition B|Condition A and Condition B|Condition A or Condition B|
|:---------:|:---------:|:-------------------------:|:------------------------:|
|True|True|True|True|
|True|False|False|True|
|False|True|False|True|
|False|False|False|False|

```python
print(True and True)
print(True and False)
print(False and True)
print(False and False)
```

    True
    False
    False
    False

```python
print(True or True)
print(True or False)
print(False or True)
print(False or False)
```

    True
    True
    True
    False

```python
x = 5
y = 3

print(x > 4 and y > 2)
print(x > 7 and y > 2)
print(x > 7 or y > 2)
```

    True
    False
    True

The keywords `or` and `and` are called **logical operations** (in the same sense that we call `+`, `-`, `*`, etc. arithmetic operations). The last logical operation is `not`: `not True` is `False`, `not False` is `True`

```python
print(not True)
print(not False)
```

    False
    True

```python
x = 10
y = 8

print(x > 7 or y < 7)
print(not x > 7 or y < 7)
print(not x > 7 or not y < 7)
print(not (x > 7 or y < 7))
```

    True
    False
    True
    False

# Iteration
[Go back to the main page](#main)

Conditions are very useful because theey allows our programs to make dcisions based on some information. These decision control the flow of the program (i.e. which statments get executed). We have on other major tool for controlling program flow, which is repetition. In programming, we will use repetitive loops to execute the same code many times. This is called `iteration`. The most basic kind of interation is the `while` loop. A `while` loop will keep executing so long as the condition after the `while` is `True`

```python
x = 0
while x < 5:
    print(x)
    x = x + 1
```

    0
    1
    2
    3
    4

We will often use interation to prform a task a certain numbers of times, but we might also use it to carry out a process to a certain stage of completition.

As an example of these different cases, we'll considr the Fibonacci sequence.The Fibonacci numbers are the sequence of numbers $\{F_n\}_{n=1}^\infty$ defined by the linear recurrence equation

$$ \begin{equation}
F_n=F_{n-1}+F_{n-2}
\end{equation}$$

with $F_1=F_2=1$. As a result of the above definition, it is conventional to define $F_0=0$.

The Fibonacci numbers for $n=1, 2, ...$ are $1, 1, 2, 3, 5, 8, 13, 21, ...$ (OEIS [A000045](https://oeis.org/A000045)).

The above cartoon (Amend 2005) shows an unconventional sports application of the Fibonacci numbers (left two panels). (The right panel instead applies the [Perrin sequence](https://mathworld.wolfram.com/PerrinSequence.html)).

Below we define two functions to compute part of the Fibonacci sequence; the first function computes the first `n` terms, while the second function computes all the terms less than an upper limit, `x`.

```python
def first_n_fibonacci(n):
    prev_num = 0
    curr_num = 1
    count = 2

    print(prev_num)
    print(curr_num)

    while count <= n:
        next_num = curr_num + prev_num
        print(next_num)
        prev_num = curr_num
        curr_num = next_num
        count += 1

def below_x_fibonacci(x):
    prev_num = 0
    curr_num = 1

    if curr_num < x:
        print(prev_num)
        print(curr_num)
    elif prev_num < x:
        print(prev_num)

    while curr_num + prev_num < x:
        next_num = curr_num + prev_num
        print(next_num)
        prev_num = curr_num
        curr_num = next_num
```

```python
m = 7
print(f'First {m} Fibonacci numbers')
first_n_fibonacci(m)
```

    First 7 Fibonacci numbers
    0
    1
    1
    2
    3
    5
    8
    13

```python
y = 40
print(f'Fibonacci numbers below {y}')
below_x_fibonacci(y)
```

    Fibonacci numbers below 40
    0
    1
    1
    2
    3
    5
    8
    13
    21
    34

Sometimes we will want our program to take a repeated action, but we won't know how many repetitions we will have to do, or it might be difficult to know ahead of time when the program should stop. For example, we could write a program that prints out cooking instructions. We don't know in advance how many instructions there will be in the recipe (some meals take a long time to cook and have many steps, while others are short and simple to make). We also don't know what the last instruction might be, so it would be difficult to write a conditional telling the program when to stop. How are we going to solve the problem? Let's look at an example.

Instructions for making bread:
1) Dissolve salt in water
2) Mix yeast into water
3) Mix water with flour to form dough
4) Knead dough
5) Let dough rise
6) Shape dough
7) Bake

The recipe has an ordered `list` of instructions. In Python we can use a list of strings to represent the instructions.

```python
bread_recipe = ['Dissolve salt in water', 'Mix yeast into water', 'Mix water with flour to form dough',
                'Knead dough', 'Let dough rise', 'Shape dough', 'Bake']
```

We could store different recipes in different lists.

```python
soup_recipe = ['Dissolve salt in water', 'Boil  water', 'Add bones to boiling water', 'Chop onions',
               'Chop garlic', 'Chop carrot', 'Chop celery', 'Remove bones from water',
               'Add vegetables to boiling water', 'Add meat to boiling water']

beans_recipe = ['Soak beans in water', 'Dissolve salt in water', 'Heat water and beans to boil',
                'Drain beans when done cooking']
```

Each of these lists has different instructions, and they are not all the same length. The beans recipe has four stps while the soup rcipe has ten. It ould b hard to write a `while` loop to print out each step. It is much easier to do it using a `for` loop.

A `for` loop does an action for each item in a `list` (or more precisely, in an **iterable**)

```python
def print_recipe(instructions):
    count = 1
    for step in instructions:
        print(f'{count}. {step}')
        count += 1
```

```python
print_recipe(soup_recipe)
```

    1. Dissolve salt in water
    2. Boil  water
    3. Add bones to boiling water
    4. Chop onions
    5. Chop garlic
    6. Chop carrot
    7. Chop celery
    8. Remove bones from water
    9. Add vegetables to boiling water
    10. Add meat to boiling water

```python
print_recipe(bread_recipe)
```

    1. Dissolve salt in water
    2. Mix yeast into water
    3. Mix water with flour to form dough
    4. Knead dough
    5. Let dough rise
    6. Shape dough
    7. Bake

```python
print_recipe(beans_recipe)
```

    1. Soak beans in water
    2. Dissolve salt in water
    3. Heat water and beans to boil
    4. Drain beans when done cooking

We can also use a `for` loop to repeat a task a certain number of times, like printing out the first `n` numbers in the Fibonacci sequence. Compare these two Fibonacci functions

```python
def first_n_fibonacci_while(n):
    prev_num = 0
    curr_num = 1
    count = 2

    print(prev_num)
    print(curr_num)

    while count <= n:
        next_num = curr_num + prev_num
        print(next_num)
        prev_num = curr_num
        curr_num = next_num
        count += 1

def first_n_fibonacci_for(n):
    prev_num = 0
    curr_num = 1

    print(prev_num)
    print(curr_num)

    for count in range(2, n+1):
        next_num = curr_num + prev_num
        print(next_num)
        prev_num = curr_num
        curr_num = next_num
```

```python
first_n_fibonacci_while(7)
```

    0
    1
    1
    2
    3
    5
    8
    13

```python
first_n_fibonacci_for(7)
```

    0
    1
    1
    2
    3
    5
    8
    13

## Aside (Recursion)
[Go back to the main page](#main)

Another way to get something like interation is called *recursion*
which is when ew define a function in terms of itself. Lets write the Fibonacci sequence recursively. This will be slightly different that it will only calculate the nth Fibonacci number

```python
def fibonacci_recursive(n):
    if n == 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fibonacci_recursive(n-1) + fibonacci_recursive(n-2)
```

```python
fibonacci_recursive(4)
```

    3

Here we make use of the fact that a Fibonacci number $F_n$ can be defined in terms of $F_{N-1}$ and $F_{N-2}$ with some base cases $F_0=0$ and $F_1=1$.  We will not be using recursion in this course, but it is an interesting and useful programming construct.

# Puting it all together
[Go back to the main page](#main)

We've learned two of the major components of programs: **variable** and **functions**. We've also learned two of the major components of program control: **conditionals** (`if` statements) and **itrations** (`for` and `while` loops). We can use thse ideas and tools to write code to prform complex tasks. Let's look at an example, involving all of these ideas put together.

Below we write a function that points out all the prime numbers up to some number `n`. We will use iteration to check if each number is prime. We will use a conditional to print out numbers only if they are prime. We will also break up the task ito small pieces so our code is easy to read and understandable. This means we will use (or call) helper functions inside of our solution.

```python
def is_prime(number):
    if number <= 1:
        return False

    for factor in range(2, number):
        if number % factor == 0:
            return False

    return True

def print_prime(n):
    for number in range(1, n):
        if is_prime(number):
            print(f'{number} is prime')
```

```python
print_prime(10)
```

    2 is prime
    3 is prime
    5 is prime
    7 is prime

The other application of the functions might be to do something many times (not necessarily in an iteration). One specific and natural way to understand this is to have a list elements and apply a function of the `list`. Lets take a list of first 20 numbers and find which one are prime. We will do this and save the result in a `list`. Lists have an `append` method which allows us to add an element to the end of the list.

```python
list_of_numbers = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]
prime_list = []
for number in list_of_numbers:
    if is_prime(number):
        prime_list.append(number)
prime_list
```

    [2, 3, 5, 7, 11, 13, 17, 19]

Python provides a nice construct to apply a function to every element of a list, called a `list comprehension`, here is an example of one:

```python
[number for number in list_of_numbers if is_prime(number)]
```

    [2, 3, 5, 7, 11, 13, 17, 19]

Note that this is a simple bit of code that is very undrstandable. We don't ned to care **how** the `is_prime` computation os occuring, only that its occuring for every element of `list_of_numbers`. This means that we can view our program at a high level without caring about the small details (which hopefully we have already designed well and tested)

# More about Functions
[Go back to the main page](#main)

Notice that the `ex_1` and `ex_2` has no input, but other functions like `test_high_score` had multiple variables as input. Remember that a function argument is just a placeholder for a name and will b bound to hatever is passed into the function. For Example

```python
def print_this(a):
    print(f'inside print_this: {a}')

a = 5
print_this(2)
print(f'a = {a}')
```

    inside print_this: 2
    a = 5

Notice that even though `print_this` was printing the variable `a` inside the function and there was a variable `a` defined outside of the function, the `print` function inside `print_this` still printed what was passed in.  However, I can also

```python
def print_it():
    print(f'inside print_it: {a}')

a = 5
print_it()
print('a = ', a)
```

    inside print_it: 5
    a =  5

Here there is no variable passed into the function so python uses th variable from the outer scope. Be careful with this second paradigm as it can be dangerous. The danger lies in th fact that the output of the function depends upon th overall state of th program (namely th value of `a`) as opposed to `print_this` hich depends only on the input of the function. Functions like `print_this` ar much asier to reason about, test, and use, they should be prferred in many contexts.

That said, there is a very powerful technique called `function closure` which we can make use of this ability. Lets say we want a function which will raise a number to some exponent, but we don't know which exponent ahead of runtime.

```python
def some_exponent(exponent):
    def func(x):
        return x**exponent
    return func
```

```python
some_exponent(2)(2), some_exponent(3)(2)
```

    (4, 8)

Now that we understand how normal arguments work, lets look at a few conveniences Python provides for making functions easier to create. The first is default arguments. Let's suppose we have a function which had a bunch of arguments, but most of them had sane defaults, for example:

```python
def print_todo(watch_tv, read, eat, sleep):
    print('I need to:')
    if watch_tv:
        print('  watch_tv')
    if read:
        print('  read')
    if eat:
        print('  eat')
    if sleep:
        print('  sleep')
print_todo(True, True, True, True)
```

    I need to:
      watch_tv
      read
      eat
      sleep

I know that I almost always need to eat and sleep, so I can use a default argument for these instead.  This means I don't need to define the value of `eat` and `sleep` unless they are different than the default.

```python
def print_todo_default(watch_tv, read, eat=True, sleep=True):
    print('I need to:')
    if watch_tv:
        print('  watch_tv')
    if read:
        print('  read')
    if eat:
        print('  eat')
    if sleep:
        print('  sleep')
print_todo_default(True, True)
```

    I need to:
      watch_tv
      read
      eat
      sleep

These default arguments can allow us to create complex function with many inputs while also maintaining ease of use by setting sane defaults.

Another thing we might want to do is take a variable list of arguments, lets write a similar `todo` function as before, but this time we will allow it to pass in any number of argumnts. Hre we will make use of the `*args` syntax. This `*` tells python to gather th rest of the arguments into the tuple `args`.

```python
def print_todo_args(*args):
    print('I need to:')
    for arg in args:
        print(f'{arg}')

print_todo_args('watch_tv', 'read', 'eat', 'sleep')
```

    I need to:
    watch_tv
    read
    eat
    sleep

This sort of syntax can be very useful in large programs where abstract functions may use all a variety of different functions with different arguments.

# Some topics we haven't discussed, but have used
[Go back to the main page](#main)
- [String formatting](https://docs.python.org/3/reference/lexical_analysis.html#f-strings)
- Exceptions (e.g. `TypeError`)

```python

```


```python
# visualization libraries
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

# plot the visuals in ipython
%matplotlib inline

```

```python
from pypge.benchmarks import explicit
prob = explicit.Lipson_02()

print prob['name'], prob['eqn']
print prob['xpts'].shape

plt.plot(prob['xpts'][0], prob['ypure'], 'r.')
plt.show()
plt.plot(prob['xpts'][0], prob['ypts'], 'b.')
plt.show()

```

```python
from pypge.search import PGE
from pypge import expand
from pypge import fitness_funcs as FF
import sympy

pge = PGE(
    system_type = "explicit",
    search_vars = "y",
    usable_vars = prob['xs_str'],
    usable_funcs = [sympy.exp, sympy.cos, sympy.sin, sympy.Abs],
#     usable_funcs = [sympy.cos, sympy.sin],
    pop_count = 3,
    peek_count = 9,
    peek_npts = 100,
    max_iter = 6,
    print_timing = True,
    log_details = True,
    fitness_func = FF.normalized_size_score
    )

pge.fit(prob['xpts'], prob['ypts'])

```

    train.shape: (1, 500) (500,)
    peekn.shape: (1, 100) (100,)

    Preloop setup
       -1 create first exprs      |   56                  0.07193017 seconds      0.00128447 per item
       -1 first => filtered       |   56 ->   56          0.00706601 seconds      0.00012618 per item
       -1 memoize => algebra      |   56 ->   56          0.42682195 seconds      0.00762182 per item
       -1 algebra => result models|   56 ->   31          0.60996890 seconds      0.01089230 per item
       -1 algebrad => filtered    |   31 ->   31          0.00326610 seconds      0.00010536 per item
       -1 memoized => fitting     |   31 ->   17          0.17024803 seconds      0.00549187 per item

```python
final_paretos = pge.get_final_paretos()
print len(final_paretos)
final_list = [item for sublist in final_paretos for item in sublist]
print len(final_list), "\n\n"
```

    9
    72

```python
pge_szs = [m.size() for m in final_list]
pge_scr = [m.score for m in final_list]
pge_evar = [1.0 - m.evar for m in final_list]

pge_szs_f = [m.size() for m in final_paretos[0]]
pge_scr_f = [m.score for m in final_paretos[0]]
pge_evar_f = [1.0 - m.evar for m in final_paretos[0]]

plt.plot(pge_szs, pge_scr, 'b.', pge_szs_f, pge_scr_f, 'ro')
plt.show()
plt.plot(pge_szs, pge_evar, 'b.', pge_szs_f, pge_evar_f, 'ro')
plt.show()
```

```python
from pypge.evaluate import Eval

for best_m in final_paretos[0]:
    print best_m
    y_pred = Eval(best_m, pge.vars, prob['xpts'])
    plt.plot(prob['xpts'][0], prob['ypts'], 'r.',prob['xpts'][0], y_pred, 'b.')
    plt.show()

```

```python
from sympy import *

import networkx as nx
G = pge.GRAPH
n_nodes = G.number_of_nodes()
n_edges = G.number_of_edges()
print n_nodes, n_edges

print nx.info(G)

print nx.density(G)

bins = nx.degree_histogram(G)

# pos=nx.graphviz_layout(G,prog="twopi",root=pge.root_model)
# nx.draw_networkx(G,pos,with_labels=False,node_size=30)
# nx.draw_circular(G,with_labels=False,node_size=30)

# pos=nx.shell_layout(G,pge.iter_expands)
# nx.draw_networkx(G,pos,with_labels=False,node_size=30)

plt.yscale('log')
plt.bar(range(0,len(bins)),bins)
plt.draw()

```


<p align="center">

</p>

## Data Analytics

### Monte Carlo Simulation for Subsurface Data Analytics in Python

#### Michael Pyrcz, Associate Professor, The University of Texas at Austin

##### [Twitter](https://twitter.com/geostatsguy) | [GitHub](https://github.com/GeostatsGuy) | [Website](http://michaelpyrcz.com) | [GoogleScholar](https://scholar.google.com/citations?user=QVZ20eQAAAAJ&hl=en&oi=ao) | [Book](https://www.amazon.com/Geostatistical-Reservoir-Modeling-Michael-Pyrcz/dp/0199731446) | [YouTube](https://www.youtube.com/channel/UCLqEr-xV-ceHdXXXrTId5ig)  | [LinkedIn](https://www.linkedin.com/in/michael-pyrcz-61a648a1)

#### Monte Carlo Simulation

Definition: random sampling from a distribution

Procedure:

1. Model the representative distribution (CDF)
2. Draw a random value from a uniform [0,1] distribution (p-value)
3. Apply the inverse of the CDF to calculate the associated realization

In practice, Monte Carlo simulation refers to the workflow with multiple realizations drawn to buld an uncertainty model.

\begin{equation}
X^\ell = F_x(p^\ell),  \, \forall \, \ell = 1,\ldots, L
\end{equation}

where $X^\ell$ is the realization of the variable $X$ drawn from its CDF, $F_x$, with cumulative probability, p-value, $p^\ell$.

It would be trivial to apply Monte Carlo simulation to a single variable, after many realizations one would get back the original distribution. The general approach is to:

1. Model all distributions for the input, variables of interest $F_{x_1},\ldots,F_{x_m}$.
2. For each realization draw $p^\ell_{1},\ldots,p^\ell_{m}$, p-values
3. Apply the inverse of each distribution to calculate a realization of each variable, $X^\ell_j = F_{x^\ell_j}^{-1}(p^\ell_j),  \, \forall \, j = 1,\ldots$, $m$ variables.
4. Apply each set of variables for a $\ell$ realization to the transfer function to calculate the ouptput realization, $Y^\ell = F(X_1^\ell,\ldots,X_m^\ell)$.

Monte Carlo Simulation (MCS) is extremely powerful

* Possible to easily simulate uncertainty models for complicated systems
* Simulations are conducted by drawing values at random from specified uncertainty distributions for each variable
* A single realization of each variable, $X_1^\ell, X_2^\ell,\ldots,X_m^\ell$ is applied to the transfer function to calculate the realization of the variable of interest (output, decision criteria):

\begin{equation}
Y^\ell = F(X_1^\ell,\ldots,X_m^\ell), \, \forall \, \ell = 1,\ldots, L
\end{equation}

* The MCS method builds empirical uncertainty models by random sampling

Let’s take a simple example, $OIP$ is oil-in-place calculated as the product of reservoir volume, $V$, average porosity, $\overline{\phi}$, and oil saturaton, $\overline{s_o}$:

\begin{equation}
OIP^\ell = V^\ell \cdot \overline{\phi}^\ell \cdot \overline{s_o}^\ell, \, \forall \, \ell = 1,\ldots, L
\end{equation}

It would be difficult to directly calculate the OIP distribution as a combination of all these different distributions.
* The distributions could all have different forms (parametric or non-parametric)
* We use MCS to empirically work this out by sampling
* Repeat to calculate enough realizations for analysis.

How many realizations, $L$?

The answer is enough! If the MCS computational cost is low then **many** is the right answer. If too few realizations are calculated then the summary statistics and the entire CDF of the output, decision criteria may be incorrect. This is caused by fluctuations due to not enough samples (see the 'Law of Small Numbers').

The MCS method is very powerful. You can simulate output distributions that could not be calculated analytically.

#### Limitations

The MCS method above assumes:
1. **representativity** - the distribution is representative
2. **independence** - the variables are independent of eachother
3. **stationarity** - all realizations for each variable are from the same distribution

#### Objective

I want to provide hands-on experience with building data analytics and machine learning workflows. Python provides an excellent vehicle to accomplish this. Also, I have coded a package called GeostatsPy with GSLIB: Geostatistical Library (Deutsch and Journel, 1998) functionality that provides basic building blocks for building subsurface modeling workflows.

The objective is to remove the hurdles of subsurface modeling workflow construction by providing building blocks and sufficient examples. This is not a coding class per se, but we need the ability to 'script' workflows working with numerical methods.

#### Getting Started

Here's the steps to get setup in Python with the GeostatsPy package:

1. Install Anaconda 3 on your machine (https://www.anaconda.com/download/).
2. From Anaconda Navigator (within Anaconda3 group), go to the environment tab, click on base (root) green arrow and open a terminal.
3. In the terminal type: pip install geostatspy.
4. Open Jupyter and in the top block get started by copy and pasting the code block below from this Jupyter Notebook to start using the geostatspy functionality.

There are examples below with these functions. You can go here to see a list of the available functions, https://git.io/fh4eX, other example workflows and source code.

```python
import geostatspy.GSLIB as GSLIB          # GSLIB utilies, visualization and wrapper
import geostatspy.geostats as geostats    # GSLIB methods convert to Python
```

We will also need some standard packages. These should have been installed with Anaconda 3.

```python
import numpy as np                        # ndarrys for gridded data
import pandas as pd                       # DataFrames for tabular data
import os                                 # set working directory, run executables
import matplotlib.pyplot as plt           # for plotting
from scipy import stats                   # summary statistics
import math                               # trig etc.
import random
```

#### Set the working directory

I always like to do this so I don't lose files and to simplify subsequent read and writes (avoid including the full address each time).

```python
#os.chdir("c:/PGE383")                     # set the working directory
```

#### Simple Monte Carlo Simulation Example

Let's work out the OIP uncertainty example above. Let's work with parametric distributions for average porosity, $apor$, volume, $vol$ and oil saturation, $so$. Here's some parameters for these distributions. Note, these parameters could originate from a bootstrap workflow, analogs, etc.

```python
apor_mean = 0.15; apor_stdev = 0.02       # Gaussian mean and standard deviation porosity (fraction)
vol_mu = 13.0; vol_sigma = 0.5            # LogNormal mu and sigma volume (m^3)
so_min = 0.2; so_max = 0.4                # Uniform minimum and maximum oil saturation (fraction)
L = 1000                                  # Number of MCS realizations
```

Let's set the minimum and maximum values for plotting.

```python
apor_min = 0.1; apor_max = 0.2            # average porosity min and max
vol_min = 0.0; vol_max = 2.0              # vol min and max
```

In the *NumPy* package we have handy methods for Monte Carlo simulation from parametric distributions. We can actually draw all $L$ realizations at once for each variable and store them in ndarrays (each ndarray with realizations $\ell = 1,\ldots,L$).

```python
apor = np.random.normal(apor_mean, apor_stdev, size=L) # average porosity MCS simulation L times and store in array
vol = np.random.lognormal(vol_mu, vol_sigma, size=L)/1.0e6   # volume ...
so = np.random.uniform(so_min, so_max, size=L)         # saturation oil
```

Let's plot the distributions of the realizations of each variable to make sure the match the form of the parametric distributions that we selected.

```python
plt.subplot(131)
GSLIB.hist_st(apor,apor_min,apor_max,log=False,cumul=False,bins=50,weights=None,xlabel="Average Porosity (fraction)",title="Average Porosity Realizations")
plt.ylim(0.0,60)

plt.subplot(132)
GSLIB.hist_st(vol,vol_min,vol_max,log=False,cumul=False,bins=50,weights=None,xlabel="Volume ($10^6$ $m^3$)",title="Average Volume Realizations")
plt.ylim(0.0,120)

plt.subplot(133)
GSLIB.hist_st(so,so_min,so_max,log=False,cumul=False,bins=50,weights=None,xlabel="Saturation Oil (fraction)",title="Average Saturation Oil Realizations")
plt.ylim(0.0,40)

plt.subplots_adjust(left=0.0, bottom=0.0, right=2.0, top=1.2, wspace=0.2, hspace=0.2)
plt.show()
```

This looks good, the shapes are Gaussian, lognormal and uniform and the central tendency and dispersion make sense given the parameters that we selected.

Now we can use broadcast methods to calculate the output realizations of $OIP$, based on this equation.

\begin{equation}
OIP^\ell = V^\ell \cdot \overline{\phi}^\ell \cdot \overline{s_o}^\ell \cdot 6.29 \quad \forall \, \ell = 1,\ldots, L
\end{equation}

where 6.26 $bbls/m^3$.

```python
OIP = apor * vol * so * 6.29

plt.subplot(111)
GSLIB.hist_st(OIP,0.0,0.8,log=False,cumul=False,bins=50,weights=None,xlabel="Reservoir OIP ($10^6$ bbls)",title="Reservoir OIP Realizations")
plt.ylim(0.0,175)
plt.subplots_adjust(left=0.0, bottom=0.0, right=1.0, top=1.2, wspace=0.2, hspace=0.2)
plt.show()
```

It's not a bad idea to check your calculation. Let's compare the first and last realizations of $OIP$ to the results from performing the calculation by hand.

```python
print('Check realization 0, broadcast method = ' + str(round(OIP[0],3)) + ' compare to by-hand ' + str(round(apor[0]*vol[0]*so[0]*6.29,3)))
print('Check realization L-1, broadcast method = ' + str(round(OIP[L-1],3)) + ' compare to by-hand ' + str(round(apor[L-1]*vol[L-1]*so[L-1]*6.29,3)))
```

    Check realization 0, broadcast method = 0.066 compare to by-hand 0.066
    Check realization L-1, broadcast method = 0.053 compare to by-hand 0.053

The results check out.

Now let's look at what happens if too few realizations are used!  Set the $L$ smaller, calculate 3 sets of $L$ realizations.

```python
L = 100                                                # set L small
# First set of realizations
apor = np.random.normal(apor_mean, apor_stdev, size=L) # average porosity MCS simulation L times and store in array
vol = np.random.lognormal(vol_mu, vol_sigma, size=L)/1.0e6 # volume ...
so = np.random.uniform(so_min, so_max, size=L)         # saturation oil
OIP1 = apor * vol * so * 6.29                          # calculate OIP

# Second set of realizations
apor = np.random.normal(apor_mean, apor_stdev, size=L) # average porosity MCS simulation L times and store in array
vol = np.random.lognormal(vol_mu, vol_sigma, size=L)/1.0e6 # volume ...
so = np.random.uniform(so_min, so_max, size=L)         # saturation oil
OIP2 = apor * vol * so * 6.29                          # calculate OIP

# Third set of realizations
apor = np.random.normal(apor_mean, apor_stdev, size=L) # average porosity MCS simulation L times and store in array
vol = np.random.lognormal(vol_mu, vol_sigma, size=L)/1.0e6 # volume ...
so = np.random.uniform(so_min, so_max, size=L)         # saturation oil
OIP3 = apor * vol * so * 6.29                          # calculate OIP
```

Let's look at the distributions, and calculate and compare the average for each of the 3 sets.

* note that I use the subplot method below that allows me to share the y axis of all the plots to improve our ability to compare the distributions.

```python
f, (ax1, ax2, ax3) = plt.subplots(1, 3, sharey=True)
ax1.hist(OIP1,bins=np.linspace(0.0,0.8,20),color="darkorange",edgecolor='black',alpha=0.8); ax1.set_xlabel("Reservoir OIP (bbls)"); ax1.set_ylabel("Frequency"); ax1.set_title("Reservoir OIP Realizations, Sample Set 1")

ax2.hist(OIP2,bins=np.linspace(0.0,0.8,20),color="darkorange",edgecolor='black',alpha=0.8); ax2.set_xlabel("Reservoir OIP (bbls)"); ax2.set_title("Reservoir OIP Realizations, Sample Set 2")

ax3.hist(OIP3,bins=np.linspace(0.0,0.8,20),color="darkorange",edgecolor='black',alpha=0.8); ax3.set_xlabel("Reservoir OIP (bbls)"); ax3.set_title("Reservoir OIP Realizations, Sample Set 3")

plt.subplots_adjust(left=0.0, bottom=0.0, right=2.0, top=1.2, wspace=0.2, hspace=0.2)
plt.show()

print('The average OIP for large number of realizations = ' + str(round(np.average(OIP),3)))
print('The average OIP for small number of realizations = ' + str(round(np.average(OIP1),3)) + ', ' + str(round(np.average(OIP2),3)) + ',' + str(round(np.average(OIP3),3)))
```

Try setting the number of realizations, $L$, larger and you will see that the 3 sets of realizations converge, they become stable. This is why we set $L$ large.

#### Non-Parametric Monte Carlos Simulation

I have demonstrated the case of MCS with parametric distributions. Of course, we may have non-parametric distributions. In this case we are working with a list of values (typically in an 1D ndarray). For this case we simply substitute in the command:

```p
apor = np.random.choice(array_average_porosity,size=L)
```

The result is a ndarray with $L$ Monte Carlo simulations from the array 'array_average_porosity'.  This is simply $L$ random draws with replacement (so the same value may be selected again), from the list of values in the ndarray.

#### Comments

This was a basic demonstration of Monte Carlo simulation for uncertainty analysis. A lot more could be done, for example, more complicated transfer functions and a combination of non-parametric and parametric distributions. Also, one could integrate relationships between the variables (we assumed independent here).

I have other demonstrations on the basics of working with DataFrames, ndarrays, univariate statistics, plotting data, declustering, data transformations, trend modeling, multivariate analysis and many other workflows available at https://github.com/GeostatsGuy/PythonNumericalDemos and https://github.com/GeostatsGuy/GeostatsPy.

I hope this was helpful,

*Michael*

Michael Pyrcz, Ph.D., P.Eng. Associate Professor The Hildebrand Department of Petroleum and Geosystems Engineering, Bureau of Economic Geology, The Jackson School of Geosciences, The University of Texas at Austin

#### More Resources Available at: [Twitter](https://twitter.com/geostatsguy) | [GitHub](https://github.com/GeostatsGuy) | [Website](http://michaelpyrcz.com) | [GoogleScholar](https://scholar.google.com/citations?user=QVZ20eQAAAAJ&hl=en&oi=ao) | [Book](https://www.amazon.com/Geostatistical-Reservoir-Modeling-Michael-Pyrcz/dp/0199731446) | [YouTube](https://www.youtube.com/channel/UCLqEr-xV-ceHdXXXrTId5ig)  | [LinkedIn](https://www.linkedin.com/in/michael-pyrcz-61a648a1)


<form action="https://github.com/prmiles/pymcmcstat_examples">
    <input type="submit" value="Return to Index" style="background-color: green; color: white; width: 150px; height: 35px; float: right"/>
</form>

# Banana Example
Author(s): Paul Miles | Date Created: June 18, 2018

This technical example constructs a non-Gaussian target distribution by twisting two first dimensions of Gaussian
distribution. The Jacobian of the transformation is 1, so it is easy to calculate the right probability regions for the banana and study different adaptive methods.

The basics of the model are as follows.
$$B_f(x_i, a, b) = [ax_1, \frac{x_2}{a}-b(a^2x_1^2 + a^2), x_3, ..., x_{npar}]$$
$$B_f^{-1}(x_i, a, b) = [x_1 /a, ax_2 + ab(x_1^2 + a^2), x_3, ..., x_{npar}]$$

We can construct a sum-of-squares function
$$B_{sos}(x_i, a, b, \mu_i, \lambda) = B_f^{-1}(x_i-\mu_i, a, b) \cdot \lambda \cdot B_f^{-1}(x_i - \mu_i, a, b)$$
where $\mu_i$ are the centers for our target distributions, $\lambda$ is the target precision, and the banana parameters are $a$ and $b$.  For this particular example we consider the problem where we have 2 unknown parameters with center $\mu_i = 0$, and we have a target correlation of $\rho = 0.9$ for the first two dimensions.  We subsequently define the target precision matrix $\lambda$
\begin{equation}
    \lambda = \sigma^{-1} = \begin{bmatrix}
    1 & \rho & 0 & \\
    \rho & 1 & \\
    0 & & 1 \\
    & & & \ddots \\
    & & & & 1
  \end{bmatrix}^{-1}
\end{equation}

Note, for a comparison between using MCMC algorithms in Matlab, Python, and R, see [here](https://prmiles.wordpress.ncsu.edu/files/2018/03/20180329_mcmc_banana_examples.pdf).

```python
# import required packages
import numpy as np
from pymcmcstat.MCMC import MCMC
from pymcmcstat.plotting.utilities import generate_ellipse
import matplotlib.pyplot as plt
```

We create a class to initialize model parameters

```python
class Banana_Parameters:
    def __init__(self, rho=0.9, npar=12, a=1, b=1, mu=None):
        self.rho = rho
        self.a = a
        self.b = b
        self.sig = np.eye(npar)
        self.sig[0,1] = rho
        self.sig[1,0] = rho
        self.lam = np.linalg.inv(self.sig)
        self.npar = npar
        if mu is None:
            self.mu = np.zeros([npar, 1])

npar = 6 # number of model parameters
udobj = Banana_Parameters(npar=npar) # user defined object
```

# Initialize MCMC object
- initialize data structure
- assign model parameters
- define simulation options

```python
# Initialize MCMC object
mcstat = MCMC()
mcstat.data.add_data_set(np.zeros(1), np.zeros(1),
                         user_defined_object=udobj)
# Add model parameters
for ii in range(npar):
    mcstat.parameters.add_model_parameter(
        name=str('$x_{}$'.format(ii + 1)),
        theta0=0.0)
# Define options
mcstat.simulation_options.define_simulation_options(
    nsimu=4.0e3,
    qcov=np.eye(npar)*5,
    method='dram')
```

We define a series of functions to evaluation the sum-of-squares error

```python
# Define model object
def bananafunction(x, a, b):
    response = x
    response[:, 0] = a*x[:, 0]
    response[:, 1] = x[:, 1]*a**(-1) - b*((a*x[:, 0])**(2) + a**2)
    return response

def bananainverse(x, a, b):
    response = x
    response[0] = x[0]*a**(-1)
    response[1] = x[1]*a + a*b*(x[0]**2 + a**2)
    return response

def bananass(theta, data):
    udobj = data.user_defined_object[0]
    lam = udobj.lam
    mu = udobj.mu
    a = udobj.a
    b = udobj.b
    npar = udobj.npar
    x = np.array([theta])
    x = x.reshape(npar, 1)
    baninv = bananainverse(x - mu, a, b)
    stage1 = np.matmul(baninv.transpose(),lam)
    stage2 = np.matmul(stage1, baninv)

    return stage2

mcstat.model_settings.define_model_settings(sos_function=bananass)
```

# Run Simulation

```python
mcstat.run_simulation()
```

    Sampling these parameters:
          name      start [      min,       max] N(       mu,   sigma^2)
         $x_1$:  0.00e+00 [     -inf,       inf] N( 0.00e+00,      inf)
         $x_2$:  0.00e+00 [     -inf,       inf] N( 0.00e+00,      inf)
         $x_3$:  0.00e+00 [     -inf,       inf] N( 0.00e+00,      inf)
         $x_4$:  0.00e+00 [     -inf,       inf] N( 0.00e+00,      inf)
         $x_5$:  0.00e+00 [     -inf,       inf] N( 0.00e+00,      inf)
         $x_6$:  0.00e+00 [     -inf,       inf] N( 0.00e+00,      inf)
     [-----------------100%-----------------] 4000 of 4000 complete in 1.0 sec

Extract results and plot chain panel

```python
# Extract results
results = mcstat.simulation_results.results
chain = results['chain']
s2chain = results['s2chain']
names = results['names'] # parameter names
# plot chain panel
mcstat.mcmcplot.plot_chain_panel(chain, names, figsizeinches=(6,6));
plt.savefig('banana_chain_panel.eps',
            format='eps',
            bbox_inches='tight')
```

# Pairwise Correlation
Here we plot the pairwise correlation as well as 50% and 95% critical regions.  These critical regions have a shape resembling a banana, hence the name of the example.

```python
# plot pairwise correlation
mcstat.mcmcplot.plot_pairwise_correlation_panel(
    chain[:, 0:2], names[0:2], figsizeinches=(5, 5))
# calculate contours for 50% and 95% critical regions
c50 = 1.3863 # critical values from chisq(2) distribution
c95 = 5.9915
xe50, ye50 = generate_ellipse(udobj.mu, c50*udobj.sig[0:2, 0:2])
xe95, ye95 = generate_ellipse(udobj.mu, c95*udobj.sig[0:2, 0:2])
bxy50 = bananafunction(np.array([xe50, ye50]).T, udobj.a, udobj.b)
bxy95 = bananafunction(np.array([xe95, ye95]).T, udobj.a, udobj.b)
# add countours to pairwise plot
plt.plot(bxy50[:,0], bxy50[:,1], 'k-', LineWidth=2)
plt.plot(bxy95[:,0], bxy95[:,1], 'k-', LineWidth=2)
plt.axis('equal')
plt.title('2 first dimensions of the chain with 50% and 95% target contours');
plt.savefig('banana_pairwise.png',
            format='png',
            dpi=500,
            bbox_inches='tight')
```


# API demonstration for paper of v1.0

_the LSST-DESC CLMM team_

Here we demonstrate how to use `clmm` to estimate a WL halo mass from observations of a galaxy cluster when source galaxies follow a given distribution (The LSST DESC Science Requirements Document - arXiv:1809.01669,  implemented in `clmm`). It uses several functionalities of the support `mock_data` module to produce mock datasets.

- Setting things up, with the proper imports.
- Computing the binned reduced tangential shear profile, for the 2 datasets, using logarithmic binning.
- Setting up a model accounting for the redshift distribution.
- Perform a simple fit using `scipy.optimize.curve_fit` included in `clmm` and visualize the results.

## Setup

First, we import some standard packages.

```python
import matplotlib.pyplot as plt
import numpy as np
plt.rcParams['font.family'] = ['gothambook','gotham','gotham-book','serif']
```

```python
from clmm.dataops import _compute_lensing_angles_flatsky as clmm_flat
from clmm.dataops import _compute_lensing_angles_ap as clmm_full
```

```python
ra1, dec1 = 0, 0
t = np.linspace(0, np.pi*2, 100)[:-1]
ra2 = ra1+np.cos(t)
dec2 = dec1+np.sin(t)

flat_vals = clmm_flat(ra1, dec1, ra2, dec2)
full_vals = clmm_full(ra1, dec1, ra2, dec2)
```

```python
f, axes = plt.subplots(2, sharex=True)

axes[0].plot(full_vals[1], flat_vals[0]-full_vals[0])
axes[0].set_ylabel('$\\theta$ [Flat-Curve]')

axes[1].plot(full_vals[1], flat_vals[1]-full_vals[1])
axes[1].set_xlabel('Azimuthal angle')
axes[1].set_ylabel('$\phi$ [Flat-Curve]')

for ax in axes:
    ax.set_xticks(np.arange(-1, 1.01, 0.5)*np.pi)
    ax.xaxis.grid(True, which='major', lw=.5)
    ax.yaxis.grid(True, which='major', lw=.5)
    ax.xaxis.grid(True, which='minor', lw=.1)
    ax.yaxis.grid(True, which='minor', lw=.1)

axes[1].set_xticklabels(['$-\pi$', '$-\\frac{\pi}{2}$', '$0$', '$\\frac{\pi}{2}$', '$\pi$'])
```

## Generating mock data

`clmm` has a support code to generate a mock catalog given a input cosmology and cluster parameters. We will use this to generate a data sample to be used in this example:

```python
from clmm import Cosmology
import clmm.support.mock_data as mock
```

```python
np.random.seed(14) # For reproducibility

# Set cosmology of mock data
cosmo = Cosmology(H0=70.0, Omega_dm0=0.27-0.045, Omega_b0=0.045, Omega_k0=0.0)

# Cluster info
cluster_m = 1.e15 # Cluster mass - ($M200_m$) [Msun]
concentration = 4  # Cluster concentration
cluster_z = 0.3 # Cluster redshift
cluster_ra = 0. # Cluster Ra in deg
cluster_dec = 0. # Cluster Dec in deg

# Catalog info
field_size = 10 # i.e. 10 x 10 Mpc field at the cluster redshift, cluster in the center

# Make mock galaxies
mock_galaxies = mock.generate_galaxy_catalog(
    cluster_m=cluster_m, cluster_z=cluster_z, cluster_c=concentration, # Cluster data
    cosmo=cosmo, # Cosmology object
    zsrc='desc_srd', # Galaxy redshift distribution,
    zsrc_min=0.4, # Minimum redshift of the galaxies
    shapenoise=0.05, # Gaussian shape noise to the galaxy shapes
    photoz_sigma_unscaled=0.05, # Photo-z errors to source redshifts
    field_size=field_size,
    ngal_density=20 # number of gal/arcmin2 for z in [0, infty]
)['ra', 'dec', 'e1', 'e2', 'z', 'ztrue', 'pzbins', 'pzpdf', 'id']
print(f'Catalog table with the columns: {", ".join(mock_galaxies.colnames)}')

ngals_init = len(mock_galaxies)
print(f'Initial number of galaxies: {ngals_init:,}')

# Keeping only galaxies with "measured" redshift greater than cluster redshift
mock_galaxies = mock_galaxies[(mock_galaxies['z']>cluster_z)]
ngals_good = len(mock_galaxies)

if ngals_good < ngals_init:
    print(f'Number of excluded galaxies (with photoz < cluster_z): {ngals_init-ngals_good:,}')
    # reset galaxy id for later use
    mock_galaxies['id'] = np.arange(ngals_good)

# Check final density
from clmm.utils import convert_units
field_size_arcmin = convert_units(field_size, 'Mpc', 'arcmin', redshift=cluster_z, cosmo=cosmo)
print(f'Background galaxy density = {ngals_good/field_size_arcmin**2:.2f} gal/arcmin2\n')
```

## Shear values

```python
import clmm
import copy
```

```python
# Create a GalaxyCluster
cluster_flat = clmm.GalaxyCluster("Name of cluster", cluster_ra, cluster_dec,
                                   cluster_z, mock_galaxies)
cluster = clmm.GalaxyCluster("Name of cluster", cluster_ra, cluster_dec,
                                   cluster_z, copy.deepcopy(mock_galaxies))

# Convert elipticities into shears for the members
cluster_flat.compute_tangential_and_cross_components(geometry="flat")
cluster.compute_tangential_and_cross_components(geometry="curve")
pass
```

```python
def colorp(x, y, c, **kwargs):
    isort = np.argsort(c)
    return plt.scatter(x[isort], y[isort], c=c[isort], **kwargs)
```

```python
sc = colorp(mock_galaxies['ra'], mock_galaxies['dec'],
            c=abs(cluster_flat.galcat['et']-cluster.galcat['et']), s=1)
plt.colorbar(sc, label='$\Delta g_t$')
plt.show()
```

```python
sc = colorp(mock_galaxies['ra'], mock_galaxies['dec'],
            c=abs(cluster_flat.galcat['ex']-cluster.galcat['ex']), s=1)
plt.colorbar(sc, label='$\Delta g_x$')
plt.show()
```

## Profile values

```python
import clmm.dataops as da
# Measure profile and add profile table to the cluster
cluster_flat.make_radial_profile(bins=da.make_bins(0.1, field_size/2., 25, method='evenlog10width'),
                            bin_units="Mpc",
                            cosmo=cosmo,
                            include_empty_bins=False,
                            gal_ids_in_bins=True,
                           )
cluster.make_radial_profile(bins=da.make_bins(0.1, field_size/2., 25, method='evenlog10width'),
                            bin_units="Mpc",
                            cosmo=cosmo,
                            include_empty_bins=False,
                            gal_ids_in_bins=True,
                           )
pass
```

```python
#from paper_formating import prep_plot
#prep_plot(figsize=(9, 9))
f, axes = plt.subplots(2, sharex=True)

errorbar_kwargs = dict(linestyle='', marker='o',
    markersize=1, elinewidth=.5, capthick=.5)

axes[0].errorbar(cluster.profile['radius'], cluster.profile['gt'],
             cluster.profile['gt_err'], c='k', label='curve', **errorbar_kwargs)
axes[0].errorbar(cluster_flat.profile['radius'], cluster_flat.profile['gt'],
             cluster_flat.profile['gt_err'], c='r', label='flat', **errorbar_kwargs)
axes[1].errorbar(cluster.profile['radius'],
                (cluster_flat.profile['gt']-cluster.profile['gt']),
                 cluster.profile['gt_err'], **errorbar_kwargs)
axes[1].set_xlabel('r [Mpc]', fontsize = 10)
axes[0].set_ylabel(r'$g_t$', fontsize = 10)
axes[1].set_ylabel(r'$\Delta g_t$', fontsize = 10)
axes[1].set_xscale('log')
axes[0].set_yscale('log')
axes[0].legend()

for ax in axes:
    ax.xaxis.grid(True, which='major', lw=.5)
    ax.yaxis.grid(True, which='major', lw=.5)
    ax.xaxis.grid(True, which='minor', lw=.1)
    ax.yaxis.grid(True, which='minor', lw=.1)
```

## Theoretical predictions

We consider 3 models:
1. One model where all sources are considered at the same redshift
2. One model using the overall source redshift distribution to predict the reduced tangential shear
3. A more accurate model, relying on the fact that we have access to the individual redshifts of the sources, where the average reduced tangential shear is averaged independently in each bin, accounting for the acutal population of sources in each bin.

All models rely on `clmm.predict_reduced_tangential_shear` to make a prediction that accounts for the redshift distribution of the galaxies in each radial bin:

### Model considering all sources located at the average redshift
\begin{equation}
     g_{t,i}^{\rm{avg(z)}} = g_t(R_i, \langle z \rangle)\;,
 \label{eq:wrong_gt_model}
 \end{equation}

```python
def predict_reduced_tangential_shear_mean_z(profile, logm):
    return clmm.compute_reduced_tangential_shear(
            r_proj=profile['radius'], # Radial component of the profile
            mdelta=10**logm, # Mass of the cluster [M_sun]
            cdelta=4, # Concentration of the cluster
            z_cluster=cluster_z, # Redshift of the cluster
            z_source=np.mean(cluster.galcat['z']), # Mean value of source galaxies redshift
            cosmo=cosmo,
            delta_mdef=200,
            halo_profile_model='nfw'
        )
```

### Model relying on the overall redshift distribution of the sources N(z), not using individual redshift information (eq. (6) from Applegate et al. 2014, MNRAS, 439, 48)
\begin{equation}
     g_{t,i}^{N(z)} = \frac{\langle\beta_s\rangle \gamma_t(R_i, z\rightarrow\infty)}{1-\frac{\langle\beta_s^2\rangle}{\langle\beta_s\rangle}\kappa(R_i, z\rightarrow\infty)}
     \label{eq:approx_model}
 \end{equation}

```python
z_inf = 1000
dl_inf = cosmo.eval_da_z1z2(cluster_z, z_inf)
d_inf = cosmo.eval_da(z_inf)

def betas(z):
    dls = cosmo.eval_da_z1z2(cluster_z, z)
    ds = cosmo.eval_da(z)
    return dls * d_inf / (ds * dl_inf)

def predict_reduced_tangential_shear_approx(profile, logm):

    bs_mean = np.mean(betas(cluster.galcat['z']))
    bs2_mean = np.mean(betas(cluster.galcat['z'])**2)

    gamma_t_inf = clmm.compute_tangential_shear(
            r_proj=profile['radius'], # Radial component of the profile
            mdelta=10**logm, # Mass of the cluster [M_sun]
            cdelta=4, # Concentration of the cluster
            z_cluster=cluster_z, # Redshift of the cluster
            z_source=z_inf, # Redshift value at infinity
            cosmo=cosmo,
            delta_mdef=200,
            halo_profile_model='nfw')
    convergence_inf = clmm.compute_convergence(
            r_proj=profile['radius'], # Radial component of the profile
            mdelta=10**logm, # Mass of the cluster [M_sun]
            cdelta=4, # Concentration of the cluster
            z_cluster=cluster_z, # Redshift of the cluster
            z_source=z_inf, # Redshift value at infinity
            cosmo=cosmo,
            delta_mdef=200,
            halo_profile_model='nfw')

    return bs_mean*gamma_t_inf/(1-(bs2_mean/bs_mean)*convergence_inf)
```

### Model using individual redshift and radial information, to compute the averaged shear in each radial bin, based on the galaxies actually present in that bin.
\begin{equation}
    g_{t,i}^{z, R} = \frac{1}{N_i}\sum_{{\rm gal\,}j\in {\rm bin\,}i} g_t(R_j, z_j)
    \label{eq:exact_model}
 \end{equation}

```python
cluster.galcat['theta_mpc'] = convert_units(cluster.galcat['theta'], 'radians', 'mpc',cluster.z, cosmo)

def predict_reduced_tangential_shear_exact(profile, logm):
    return np.array([np.mean(
        clmm.compute_reduced_tangential_shear(
            # Radial component of each source galaxy inside the radial bin
            r_proj=cluster.galcat[radial_bin['gal_id']]['theta_mpc'],
            mdelta=10**logm, # Mass of the cluster [M_sun]
            cdelta=4, # Concentration of the cluster
            z_cluster=cluster_z, # Redshift of the cluster
            # Redshift value of each source galaxy inside the radial bin
            z_source=cluster.galcat[radial_bin['gal_id']]['z'],
            cosmo=cosmo,
            delta_mdef=200,
            halo_profile_model='nfw'
        )) for radial_bin in profile])
```

## Mass fitting

We estimate the best-fit mass using `scipy.optimize.curve_fit`. The choice of fitting $\log M$ instead of $M$ lowers the range of pre-defined fitting bounds from several order of magnitude for the mass to unity. From the associated error $\sigma_{\log M}$ we calculate the error to mass as $\sigma_M = M_{fit}\ln(10)\sigma_{\log M}$.

#### First, identify bins with sufficient galaxy statistics to be kept for the fit
For small samples, error bars should not be computed using the simple error on the mean approach available so far in CLMM)

```python
data_for_fit = cluster.profile[cluster.profile['n_src'] > 5]
data_for_fit_flat = cluster_flat.profile[cluster_flat.profile['n_src'] > 5]
```

#### Perform the fits

```python
from clmm.support.sampler import fitters
def fit_mass(predict_function, data_for_fit=data_for_fit):
    popt, pcov = fitters['curve_fit'](predict_function,
        data_for_fit,
        data_for_fit['gt'],
        data_for_fit['gt_err'], bounds=[10.,17.])
    logm, logm_err = popt[0], np.sqrt(pcov[0][0])
    return {'logm':logm, 'logm_err':logm_err,
            'm': 10**logm, 'm_err': (10**logm)*logm_err*np.log(10)}
```

```python
fit_mean_z = fit_mass(predict_reduced_tangential_shear_mean_z)
fit_approx = fit_mass(predict_reduced_tangential_shear_approx)
fit_exact = fit_mass(predict_reduced_tangential_shear_exact)
```

```python
fit_mean_z_flat = fit_mass(predict_reduced_tangential_shear_mean_z, data_for_fit_flat)
fit_approx_flat = fit_mass(predict_reduced_tangential_shear_approx, data_for_fit_flat)
fit_exact_flat = fit_mass(predict_reduced_tangential_shear_exact, data_for_fit_flat)
```

```python
print(f'Input mass = {cluster_m:.2e} Msun\n')
print('Curve sky')
print(f'Best fit mass for average redshift               = {fit_mean_z["m"]:.3e} +/- {fit_mean_z["m_err"]:.3e} Msun')
print(f'Best fit mass for N(z) model                     = {fit_approx["m"]:.3e} +/- {fit_approx["m_err"]:.3e} Msun')
print(f'Best fit mass for individual redshift and radius = {fit_exact["m"]:.3e} +/- {fit_exact["m_err"]:.3e} Msun')
print()
print('Flat sky')
print(f'Best fit mass for average redshift               = {fit_mean_z_flat["m"]:.3e} +/- {fit_mean_z_flat["m_err"]:.3e} Msun')
print(f'Best fit mass for N(z) model                     = {fit_approx_flat["m"]:.3e} +/- {fit_approx_flat["m_err"]:.3e} Msun')
print(f'Best fit mass for individual redshift and radius = {fit_exact_flat["m"]:.3e} +/- {fit_exact_flat["m_err"]:.3e} Msun')
print()
print('Difference (Curve-Flat)')
print(f'Best fit mass for average redshift               = {fit_mean_z_flat["m"]-fit_mean_z["m"]:.3e} Msun')
print(f'Best fit mass for N(z) model                     = {fit_approx_flat["m"]-fit_approx["m"]:.3e} Msun')
print(f'Best fit mass for individual redshift and radius = {fit_exact_flat["m"]-fit_exact["m"]:.3e} Msun')
```

## Reduced $\chi$2

```python
def get_predicted_shear(predict_function, fit_values):
    gt_est = predict_function(data_for_fit, fit_values['logm'])
    gt_est_err = [predict_function(data_for_fit, fit_values['logm']+i*fit_values['logm_err'])
                          for i in (-3, 3)]
    return gt_est, gt_est_err
def chi2(predict_function, fit_values):
    gt_mean_z, gt_err_mean_z =  get_predicted_shear(predict_function, fit_values)
    return np.sum((gt_mean_z-data_for_fit['gt'])**2/(data_for_fit['gt_err'])**2)/(len(data_for_fit)-1)

```

```python
print('Curve sky')
print(f'Reduced chi2 (mean z model) = {chi2(predict_reduced_tangential_shear_mean_z, fit_mean_z)}')
print(f'Reduced chi2 (N(z) model) = {chi2(predict_reduced_tangential_shear_approx, fit_approx)}')
print(f'Reduced chi2 (individual (R,z) model) = {chi2(predict_reduced_tangential_shear_exact, fit_exact)}')
print()
print('Flat sky')
print(f'Reduced chi2 (mean z model) = {chi2(predict_reduced_tangential_shear_mean_z, fit_mean_z_flat)}')
print(f'Reduced chi2 (N(z) model) = {chi2(predict_reduced_tangential_shear_approx, fit_approx_flat)}')
print(f'Reduced chi2 (individual (R,z) model) = {chi2(predict_reduced_tangential_shear_exact, fit_exact_flat)}')
```