# Lecture 3: Numerical Integration

```python
# some imports we will make at the start of every notebook
# later notebooks may add to this with specific SciPy modules

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

## Contents

## I         .    Introduction to Numerical Integration
## II        .    Rule 1: Midpoint Rule a.k.a Rectangle Method
## III       .    Good/Bad Numerical Integration Methods
## IV       .    Rule 2: Trapezoid Rule
## V        .    Error Analysis
## VI       .    Rule 3: Simpson's Rule
## VII      .    Rule 4: Composite Simpson's Rule
## VIII     .    Rule 5: Weddle's Rule
## Aside:    (Richardson) extrapolation, Romberg integration and Newton-Cotes formula

 I might end up using interval when I actually intend to say subinterval, sorry ....

## I.   Introduction to Numerical Integration

Numerical Integration is involves finding the integration of a function. While Sympy can be used to do analytical integration, there are actually many functions for which finding an analytical solution to integration is very difficult, and numerical integration is used instead.

To understand how to perform numerical integration, we first need to understand what exactly is the purpose of integration. For a 1D function, integration means finding the area underneath the curve. However, integration can also be extended for a 2D function and even a 3D function, and some of these will be covered in your Mathematics modules.

[*Quadrature*](https://en.wikipedia.org/wiki/Numerical_integration) is the term used for numerical evaluation of a *definite* (i.e. over a range $[a,b]$) integral, or in 1D finding the area under a curve.

*(Wikipedia: https://upload.wikimedia.org/wikipedia/commons/f/f2/Integral_as_region_under_curve.svg)*

Wondered why the symbol of integration looks like that? The symbol of integration actually comes from the cursive of capital letter $S$, meaning summation. But why would it come from the idea of summation? Well, integration is very much related to summation.

We know that for a 1D function, which will be our primary topic of discussion today, if we evaluate an integral, we find the area under the function, as illustrated in the figure above. If your curve was a straight line (remember, straight lines are but a subset of curves which has no curves), then it would be rather straightforward to calculate the area under the curve, since you would either have a rectangle if your line was a horizontal straight line, or a trapezoid if your line was not a horizontal straight line. Yet, what happens if your line got a bunch of bend and curves?

For example, we could have an a very simple equation like

$$f(x) = sin(x) +5$$

You probably don't need matplotlib to tell you that this equation is a sinusoidal function. Well, now your shape becomes complicated, and you cannot evaluate the area as easily as you would have done when the line was straight and the area was a rectangle or trapezoid. Therefore, integration comes in to help you find the area. But how does integration actually magically get you the area underneath the curve?

Welll, what integration essentially does is basically breaking the area into smaller and smaller parts, evaluating the area of each part, and then summing each small part together. The small part of the area can be approximated to be a rectangle, a trapezoid, or some other weird shape if you find it suitable.

A very simple example, using rectangles are shown below.

I make the observation from the gif that as each rectangle slice becomes thinner and thinner slices, the summed area from the rectangles become more and more closely fitting to the area under the curve. It should be understable that if the rectangle slice becomes infinitely thin, then the summed area from the rectangle would become so close to the area underneath the curve that the two would be essentially the same.

It should also be noted that although the example shown uses rectangular slices, it is noted neccesary to use rectangular slices. It is not even neccesasry to use slices that are of the the same width. Even if you used slices with different width, and which are not rectangles, like trapezoids for example, you could still make the slices increasingly thinner so that the summation of the area under the slices becomes closer and closer to the area underneath the curve, until the slices becomes infinitely thin, and the summed area of slices becomes essentially equal to the area under the curve.

For those of you who adore mathematics, you can find the rigorous mathematics for the above searching Riemann integral on wikipedia. Of course, integration has come a long way since the Riemann integral, and other integrals were developed to deal with the deficiencies with the Riemann integral, but well these are not for now, and for now, let's stick with the Riemann Integral and well, deficiencies, what are those?

As with previous lectures, the choice of approximation method, as well as the size of the intervals (basically how thick of thin the slices are, but sounding more professional and cool), will control the error.  Better methods as well as smaller (i.e. more to cover our total interval of interest: $[a,b]$) sub-intervals will lead to lower errors, but will generally cost more to compute.

Here the following quadrature methods will be covered in the context of a simple function:

* Midpoint rule (also known as the Rectangle Method)
* Trapezoid rule
* Simpson's rule
* Composite Simpson's rule
* Weddle's rule.

## Integration example

Let's begin with a simple function to demonstrate some of the most basic methods for performing numerical integration:

$$f\left ( x \right ) := \sin \left ( x \right ),$$

and assume that we want to know what the area under the $\,\sin\,$ function between 0 and $\pi$, i.e. $[a,b]=[0,\pi]$.

The indefinite integral (or anti-derivative) of $\,\sin \left ( x \right )\,$ is of course $\,-\cos \left ( x \right )\,$ (plus a constant of integration, $C$, which we can simply ignore as we saw above as it drops out as soon as we perform a *definite* integral).

Since we know the indefinite integral exactly in this case, we can perform the definite integration (i.e. find the area under the curve) ourselves exactly by hand:

$$I :=  \int_{0}^{\pi} \sin \left ( x \right ) = \left [ -\cos\left ( x \right )+ C \right ]_{0}^{\pi} =-\cos\left ( \pi \right ) - (-\cos\left ( 0 \right )) =-\cos\left ( \pi \right ) + \cos\left ( 0 \right ) = -(-1) + 1 = 2.
$$

[We included the constant $C$ here to just to emphasise again the fact that it's present doesn't matter - we can legitimately just not write it down in this type of expression.]

Let's start by plotting the function between these points.

```python
# set up figure
##First we create a figure
##Then we make our plot a subplot of the figure
##Subplots come in handy when you are making multiple plots on a single figure
fig = plt.figure(figsize=(10, 4))
ax1 = plt.subplot(111)

# Get the value of pi from numpy and generate 100 equally spaced values from 0 to pi.
x = np.linspace(0, np.pi, 100)

# Calculate sin at these points.
y = np.sin(x)

# plot
ax1.plot(x, y, 'b')

# Set x axis limits between 0 and pi.
ax1.set_xlim([0, np.pi])
ax1.set_ylim([0, 1.1])

# Label axis.
ax1.set_xlabel('$x$', fontsize=16)
ax1.set_ylabel('$f(x)=\sin(x)$', fontsize=16)
ax1.set_title('An example function we wish to integrate', fontsize=16)

# Overlay a grid.
ax1.grid(True);
```

Now let's investigate the different quadrature rules, finding the area under the curve and seeing how it differs from the true area which we know is 2.

## II.    Rule 1: Midpoint rule a.k.a Rectangle Method

The *midpoint rule* is perhaps the simplest quadrature rule.

For reasons you will see below it is sometimes also called the *rectangle method*.

Consider one of the subintervals $\,[x_i, x_{i+1}].$

The midpoint rule approximates the integral over this (the $i$-th) subinterval by the area of a rectangle, with a base of length $\,(x_{i+1}-x_i)\,$ and a height given by the value of $\,f(x)\,$ at the midpoint of that interval (i.e. at $\,x=(x_{i+1}+x_i)/2$):

$$ I_M^{(i)} := (x_{i+1}-x_i) \;\; \times \;\; f \left ( \frac {x_{i+1}+x_i} {2} \right ), \;\;\;\;\;\;\text{for}
\;\;\;\;\;\; 0\le i \le n-1.$$

The midpoint estimate of $I$ then simply involves summing up over all the subintervals:

$$I_M := \sum_{i=0}^{n-1} \, f \left ( \frac {x_{i+1}+x_i} {2} \right )\, (x_{i+1}-x_i).$$

Ok, so basically, what the midpoint rule does is rather simple.

1. So you have a curve, and you have an interval of the curve for which you want to calculate the area underneath that interval. You will divide that interval of the curve into smaller pieces, and each small piece will be called a subinterval.

2. We assume that your interval begins at $x_0$ and ends at $x_n$. We can pick a random subinterval $[x_i,x_{i+1}]$ where $0\le i \le n-1$. For example, your 1st interval will be $[x_0,x_1]$ and your $i=0$, and your last interval will be $[x_{n-1},x_n]$ and your $i=n-1$.

3. For every subinterval, we approxiamte the slice to be a rectangle. To find the area of the rectangle we need to find the width and the height. The width of the rectangle is simply the width of the subinterval. The height of the rectangle can be estimated as the value of the function at the midpoint of the subinterval, so $f \left ( \frac {x_{i+1}+x_i} {2} \right )$.  To find the area we simply multiply the width of the rectangle by the height of the rectangle.

$$Width \;\; of \;\; Rectangle = x_{i+1} - x_i$$

$$Height \;\; of \;\; Rectangle = f \left ( \frac {x_{i+1}+x_i} {2} \right ) $$

$$Area \;\; of \;\; Rectangle = (x_{i+1} - x_i) f \left ( \frac {x_{i+1}+x_i} {2} \right )$$

Generalizing the above for all slices, where $I_M^{(i)}$ is simpley the area of the subinterval $[x_i,x_{i+1}]$. $M$ subscript here denotes the use of the Midpoint Method

$$ I_M^{(i)} := (x_{i+1}-x_i) \;\; \times \;\; f \left ( \frac {x_{i+1}+x_i} {2} \right ), \;\;\;\;\;\;\text{for}
\;\;\;\;\;\; 0\le i \le n-1.$$

4. To find the area under the curve, we need to sum up all of the areas from the subinterval, so we are going to use the summation symbol. We knoe that the subinterval index goes from the first subinterval where $i=0$ to the last subinterval where $i=n-1$, thus we arrive at

$$I_M := \sum_{i=0}^{n-1} \, f \left ( \frac {x_{i+1}+x_i} {2} \right )\, (x_{i+1}-x_i).$$

Note that we dropped $i$ prefix from $I$ because now it is the whole area under the curve, not just the area from one subinterval.

Let's write some code to plot the idea as well as compute an estimate of the integral using the midpoint rule.

```python
# this is a matplotlib function that allows us to easily plot rectangles
# which will be useful for visualising what the midpoint rule does
from matplotlib.patches import Rectangle

def f(x):
    """The function we wish to integrate"""
    return np.sin(x)

# Get the value of pi from numpy and generate equally spaced values from 0 to pi.
x = np.linspace(0, np.pi, 100)
y = f(x)

# plot
fig = plt.figure(figsize=(12, 4))
ax1 = plt.subplot(111)
ax1.plot(x, y, 'b', lw=2)

ax1.margins(0.1)

# Label axis.
ax1.set_xlabel('$x$', fontsize=16)
ax1.set_ylabel('$f(x)=\sin(x)$', fontsize=16)
ax1.set_title('Approximating a function with rectangles', fontsize=16)

# Overlay a grid.
ax1.grid(True)

number_intervals = 5
xi = np.linspace(0, np.pi, number_intervals+1)
I_M = 0.0
for i in range(number_intervals):
    ax1.add_patch(Rectangle((xi[i], 0.0), (xi[i+1] - xi[i]),
                            f((xi[i+1]+xi[i])/2), fill=False, ls='--', color='k', lw=2))
    I_M += f((xi[i+1]+xi[i])/2)*(xi[i+1] - xi[i])

# use an explicit show here to force the figure to appear before the following print.
plt.show()
print('The sum of the areas of the rectangles is (I_M): {:.12f}'.format(I_M))
```

A complex example looks like this, where the red line shows the original function we wish to compute the integral of, and the blue rectangles *approximate* the area under that function for a number of sub-intervals:

## Implementing a midpoint rule function

Clearly the sum of the areas of all the rectangles provides an estimate of the true integral. In the case above we observe an error of around 1.5%.

[Note that the SciPy module features many different integration functions, and you can find thorough documentation for these functions (including methods not covered in this course) [here](http://docs.scipy.org/doc/scipy/reference/integrate.html). This library does not contain a function for the midpoint rule, but it is trivial to create our own.]

As we are going to compare different rules below, let's implement a midpoint rule function.

```python
def midpoint_rule(a, b, function, number_intervals=10):
    """ Our implementation of the midpoint quadrature rule.

    a and b are the end points for our interval of interest.

    'function' is the function of x \in [a,b] which we can evaluate as needed.

    number_intervals is the number of subintervals/bins we split [a,b] into.

    Returns the integral of function(x) over [a,b].
    """
    interval_size = (b - a)/number_intervals

    # Some examples of some asserts which might be useful here -
    # you should get into the habit of using these sorts of checks as much as is possible/sensible.
    assert interval_size > 0
    assert type(number_intervals) == int

    # Initialise to zero the variable that will contain the cumulative sum of all the areas
    I_M = 0.0

    # Find the first midpoint -- i.e. the centre point of the base of the first rectangle
    mid = a + (interval_size/2.0)
    # and loop until we get past b, creating and summing the area of each rectangle
    while (mid < b):
        # Find the area of the current rectangle and add it to the running total
        # this involves an evaluation of the function at the subinterval midpoint
        I_M += interval_size * function(mid)
        # Move the midpoint up to the next centre of the interval
        mid += interval_size

    # Return our running total result
    return I_M

# check the function runs and agrees with our first version used to generate the schematic plot of the method above:
print('midpoint_rule(0, np.pi, np.sin, number_intervals=5) = ', midpoint_rule(0, np.pi, np.sin, number_intervals=5))
```

    midpoint_rule(0, np.pi, np.sin, number_intervals=5) =  2.033281476926104

```python
# Now let's test the midpoint function.
print("The exact area found by direct integration = 2")
for i in (1, 2, 10, 100, 1000):
    area = midpoint_rule(0, np.pi, np.sin, i)
    print("Area %d rectangle(s) = %g (error=%g)"%(i, area, abs(area-2)))
```

    The exact area found by direct integration = 2
    Area 1 rectangle(s) = 3.14159 (error=1.14159)
    Area 2 rectangle(s) = 2.22144 (error=0.221441)
    Area 10 rectangle(s) = 2.00825 (error=0.00824841)
    Area 100 rectangle(s) = 2.00008 (error=8.22491e-05)
    Area 1000 rectangle(s) = 2 (error=8.22467e-07)

## Exercise 3.1: Midpoint rule convergence plot

Plot the log-log plot mentioned below.

```python

```

We can observe from the slope of the log-log plot of error against number of subintervals that the error is a quadratic function of the inverse of the number of subintervals (or equivalently is quadratically dependent on the spacing between the points - the interval size).

This demonstrates that (for this particular example at least), the method demonstrates second-order accuracy - if we halve the interval size the error goes down by a factor of 4!

The simplicity of this method is its weakness, as rectangles (i.e. a flat top) are rarely a good approximation for the shape of a smooth function.

```python

```

```python

```

## III. Good/Bad Numerical Integration Methods

We observe that for the rectangle method, if the subintervals are large, then the method becomes inaccurate. We also observe that if the subintervals become smaller and smaller, then the area calculated becomes closer and closer to the exact area under the curve. We also note that to have small intervals, we need to evaluate the function at more and more points. I see that for 5 subintervals, we will need to evaluate 6 points in total.

In numerical integration, given an $\infty$ number of points, and thus an $\infty$ Note:$(\infty - 1 = \infty )$ number of subintervals, any numerical integration method will result in the area summed from the subinterval becoming closer and closer to the area under the curve.

However, it should be noted that computational power is not infinite. While the examples here demonstrated can easily be calculated, real world examples are often very complex, and significant computational power may be required to compute even just a single point. Yet to get an accurate calculation of the area under the curve, we need more and more points, for more and more subintervals. Thus, we reach a conflict. To have a more accurate area under the curve, we need more points, but getting more points requires more computational power, which is often limited.

Thus, a good numerical integration method must be able to be both accurate in finding the area underneath even if only a few points can be calculated.

## IV.     Rule 2: Trapezoid rule

As previously stated, the slices we use do not have to be rectangles, they can also be trapezoids. Rectangle rule is very similar to the trapezoid rule except for one small difference.

For the height of the rectangle, the rectangle rule, the width of the subinterval is multiplied by

$$f(\frac{x_i + x{i+1}}{2})$$

while for the trapezoid rule, the width of the subinterval will instead by multiplied by

$$\frac{f(x_i) + f(x_{i+1})}{2}$$

Oh, and we will also change the subscript. For the midpoint rule, a.k.a the rectangle rule, the subscript was $M$, while for the trapezoid rule, the subscipt will accordingly be $T$. Do write down both methods on pen and paper, side by side, and it should be plainly obvious their similarities and differences.

If we change the shape of the rectangle to a trapezoid (i.e. the top of the shape now being a linear line fit defined by the values of the function at the two end points of the subinterval, rather than the constant value used in the midpoint rule), we arrive at the trapezoid, or trapezoidal, rule.

The trapezoid rule approximates the integral by the area of a trapezoid with base $(x_{i+1}-x_i)$ and the left- and right-hand-sides equal to the values of the function at the two end points.

In this case the area of the shape approximating the integral over one subinterval, is given by:

$$I_T^{(i)} := (x_{i+1}-x_i) \;\; \times \;\;
\left( \frac {f\left ( x_{i+1}\right ) + f \left (x_{i} \right )} {2} \right)
\;\;\;\;\;\;\text{for}
\;\;\;\;\;\; 0\le i \le n-1.$$

The trapezoidal estimate of $I$ then simply involves summing up over all the subintervals:

$$I_T := \sum_{i=0}^{n-1}\,   \left(\frac{f(x_{i+1}) + f(x_{i})}{2}\right )\, (x_{i+1}-x_i). $$

Ok, so basically, what the trapezoid rule does is rather simple.

1. So you have a curve, and you have an interval of the curve for which you want to calculate the area underneath that interval. You will divide that interval of the curve into smaller pieces, and each small piece will be called a subinterval.

2. We assume that your interval begins at $x_0$ and ends at $x_n$. We can pick a random subinterval $[x_i,x_{i+1}]$ where $0\le i \le n-1$. For example, your 1st interval will be $[x_0,x_1]$ and your $i=0$, and your last interval will be $[x_{n-1},x_n]$ and your $i=n-1$.

3. For every subinterval, we approxiamte the slice to be a trapezoid. To find the area of the trapezoid we need to find the width and the average between the two bases. The width of the rectangle is simply the width of the subinterval. The average of the two bases of the trapezoid can be estimated as the average between the value of the function at $(x_i)$ and $(x_{i+1})$,so $ \left ( \frac {f(x_{i+1})+f(x_i)} {2} \right )$.  To find the area we simply multiply the width of the rectangle by the height of the rectangle.

$$Width \;\; of \;\; Rectangle = x_{i+1} - x_i$$

$$Height \;\; of \;\; Rectangle = \left( \frac {f\left ( x_{i+1}\right ) + f \left (x_{i} \right )} {2} \right) $$

$$Area \;\; of \;\; Rectangle = (x_{i+1} - x_i) \left( \frac {f\left ( x_{i+1}\right ) + f \left (x_{i} \right )} {2} \right)$$

Generalizing the above for all slices, where $I_T^{(i)}$ is simpley the area of the subinterval $[x_i,x_{i+1}]$. $T$ subscript here denotes the use of the Trapzoid Method

$$ I_T^{(i)} := (x_{i+1}-x_i) \;\; \times \;\; \left( \frac {f\left ( x_{i+1}\right ) + f \left (x_{i} \right )} {2} \right) , \;\;\;\;\;\;\text{for}
\;\;\;\;\;\; 0\le i \le n-1.$$

4. To find the area under the curve, we need to sum up all of the areas from the subinterval, so we are going to use the summation symbol. We know that the subinterval index goes from the first subinterval where $i=0$ to the last subinterval where $i=n-1$, thus we arrive at

$$I_T := \sum_{i=0}^{n-1}\,   \left(\frac{f(x_{i+1}) + f(x_{i})}{2}\right )\, (x_{i+1}-x_i). $$

Note that we dropped $i$ prefix from $I$ because now it is the whole area under the curve, not just the area from one subinterval.

Let's write some code to plot the idea and compute an estimate of the integral.

```python
# this is a matplotlib function that allows us to plot polygons
from matplotlib.patches import Polygon

def f(x):
    """The function we wish to integrate"""
    return np.sin(x)

# Get the value of pi from numpy and generate equally spaced values from 0 to pi.
x = np.linspace(0, np.pi, 100)
y = f(x)

# plot
fig = plt.figure(figsize=(12, 4))
ax1 = plt.subplot(111)
ax1.plot(x, y, 'b', lw=2)

ax1.margins(0.1)

# Label axis.
ax1.set_xlabel('$x$', fontsize=16)
ax1.set_ylabel('$\sin(x)$', fontsize=16)
ax1.set_title('Approximating function with trapezoids', fontsize=16)

# Overlay a grid.
ax1.grid(True)

number_intervals = 5
xi = np.linspace(0, np.pi, number_intervals+1)
I_T = 0.0
for i in range(number_intervals):
    ax1.add_patch(Polygon(np.array([[xi[i], 0], [xi[i], f(xi[i])], [
                  xi[i+1], f(xi[i+1])], [xi[i+1], 0]]), closed=True, fill=False, ls='--', color='k', lw=2))
    I_T += ((f(xi[i+1]) + f(xi[i]))/2)*(xi[i+1] - xi[i])

plt.show()
print('The sum of the areas of the trapezoids is (I_T): {:.12f}'.format(I_T))
```

For our pictorial example used above the approximation looks like it should be more accurate than the midpoint rule:

the tops of the shapes (now trapezoids) are approximating the variation of the function with a linear function, rather than a flat (constant) function.  This looks like it should give more accurate results, but see below.

Note that numpy has a function for the trapezoid rule, numpy.trapz, but we'll make our own that works in a similar way to our midpoint rule function.

## Exercise 3.2: Complete the implementation of the trapezoid rule below

Test your function in a similar way to above,  compute another log-log plot of the error and compare with the midpoint rule.

```python
def trapezoidal_rule(a, b, function, number_intervals=10):
    """Our implementation of the trapezoidal quadrature rule.

    Note that as discussed in the lecture this version of the implementation
    performs redundant function evaluations - see the composite implementation
    in the homework for a more efficient version.
    """
    interval_size = (b - a)/number_intervals

    assert interval_size > 0
    assert type(number_intervals) == int

    I_T = 0.0

    # Loop to create each trapezoid
    # note this function takes a slightly different approach to Midpoint
    # (a for loop rather than a while loop) to achieve the same thing
    for i in range(number_intervals):

        # add your code here!

    # Return our running total result
        return I_T
```

```python
print("The exact area found by direct integration = 2")
for i in (1, 2, 10, 100, 1000):
    area = trapezoidal_rule(0, np.pi, np.sin, i)
    print("Area %d trapezoid(s) = %g (error=%g)"%(i, area, abs(area-2)))
```

You should have found the following errors:

`The area found by direct integration = 2`

`Area 1 trapezoid(s) = 1.92367e-16 (error=2)`

`Area 2 trapezoid(s) = 1.5708 (error=0.429204)`

`Area 10 trapezoid(s) = 1.98352 (error=0.0164765)`

`Area 100 trapezoid(s) = 1.99984 (error=0.000164496)`

`Area 1000 trapezoid(s) = 2 (error=1.64493e-06)`

## V.   Error Analysis

It is important to understand the errors of the numerical integration method we are using. If there are a limited number of slices, the area covered by the slices from trapezoid rule or the midpoint rule will not be slightly different from the actual area under the curve. Of course, if we increase the number of slices, the error becomes smaller; however, we also increase the computational power required.

A good numerical integration method should be able to have few slices, meaning using little computational power, but still be able to have a small error only. Thus, to know if the numerical integration method we used is a good or a bad method, we need to analyse the errors, or more exactly the change in the errors with numbers of slcies, of our numerical integration method.

Ah, and we could also use the error analysis we do to for the rectangle and the trapezoid rule to arrive at Simpson's, the cooler Simpson's (no, it's not actually called like that) and the Weddle Rules! These are rules coming from the error analysis, whose error quickly decreases as the number of slices increase.

A good method will have a rapid decrease in the error when increasing the number of slices, while a bad method will have a slow decrease in the error when increasing the number of slices.

The accuracy of a quadrature, i.e. the mid point rule, the trapezoidal rule etc. is predicted by examining its behaviour in relationship with polynominals.

We say that the **degree of accuracy** or the **degree of precision** of a quadrature rule is equal to $M$ if it is exact for all polynomials of degree up to and including $M$, but not exact for some polynomial of degree $M+1$.

Take the trapezoid rule for example. If we are using a degree $1$ polynominal, or a straight line, then the trapzoid rule will be able to give exactly the area underneath the straight line.

Again, take the trapezoid rule, but this time we are using a degree $2$ polynominal, or a parabola, or a curve with one single bend, then the trapzeoid rule will fail to give the exact area, as the straight lines of the trapezoid will not be able to account for the curviness of the parabola.

Thus, we say that the **degree of accuracy** or the **degree of precision** of a trapezoid rule, which is a quadrature rule is equal to $1$ if it is exact for all polynomials of degree up to and including $1$, but not exact for some polynomial of degree $1+1$.

For the Trapezoid Rule or the Mid Point Rule, we have the degree of accuracy or precision being $1$ but the method is $2nd$ order accurate, since if we halve the interval size the error goes down by a factor of 4!

Clearly both the midpoint and trapezoid rules will give the exact result for both constant and linear functions,

but they are not exact for quadratics [you could test our codes yourself on the function $x^2$ to demonstrate this].

Therefore, they have a degree of precision of 1.

### Example: Concave Down Sine Wave

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

# this is a matplotlib function that allows us to easily plot rectangles
# which will be useful for visualising what the midpoint rule does
from matplotlib.patches import Rectangle

def f(x):
    """The function we wish to integrate"""
    return np.sin(x)

# Get the value of pi from numpy and generate equally spaced values from 0 to pi.
x = np.linspace(0, np.pi/2, 100)
y = f(x)

# plot
fig = plt.figure(figsize=(12, 4))
ax1 = plt.subplot(111)
ax1.plot(x, y, 'b', lw=2)

ax1.margins(0.1)

# Label axis.
ax1.set_xlabel('$x$', fontsize=16)
ax1.set_ylabel('$f(x)=\sin(x)$', fontsize=16)
ax1.set_title('Approximating a function with rectangles', fontsize=16)

# Overlay a grid.
ax1.grid(True)

number_intervals = 5
xi = np.linspace(0, np.pi/2, number_intervals+1)
I_M = 0.0
for i in range(number_intervals):
    ax1.add_patch(Rectangle((xi[i], 0.0), (xi[i+1] - xi[i]),
                            f((xi[i+1]+xi[i])/2), fill=False, ls='--', color='k', lw=2))
    I_M += f((xi[i+1]+xi[i])/2)*(xi[i+1] - xi[i])

# use an explicit show here to force the figure to appear before the following print.
plt.show()
print('The sum of the areas of the rectangles is (I_M): {:.12f}'.format(I_M))

# this is a matplotlib function that allows us to plot polygons
from matplotlib.patches import Polygon

def f(x):
    """The function we wish to integrate"""
    return np.sin(x)

# Get the value of pi from numpy and generate equally spaced values from 0 to pi.
x = np.linspace(0, np.pi/2, 100)
y = f(x)

# plot
fig = plt.figure(figsize=(12, 4))
ax1 = plt.subplot(111)
ax1.plot(x, y, 'b', lw=2)

ax1.margins(0.1)

# Label axis.
ax1.set_xlabel('$x$', fontsize=16)
ax1.set_ylabel('$\sin(x)$', fontsize=16)
ax1.set_title('Approximating function with trapezoids', fontsize=16)

# Overlay a grid.
ax1.grid(True)

number_intervals = 5
xi = np.linspace(0, np.pi/2, number_intervals+1)
I_T = 0.0
for i in range(number_intervals):
    ax1.add_patch(Polygon(np.array([[xi[i], 0], [xi[i], f(xi[i])], [
                  xi[i+1], f(xi[i+1])], [xi[i+1], 0]]), closed=True, fill=False, ls='--', color='k', lw=2))
    I_T += ((f(xi[i+1]) + f(xi[i]))/2)*(xi[i+1] - xi[i])

plt.show()
print('The sum of the areas of the trapezoids is (I_T): {:.12f}'.format(I_T))

```

For the "concave-down" (i.e. the first half of a sine wave) function we chose above, notice from the plot that the trapezoidal rule will **consistently underestimate** the area under the curve, as the line segments approximating the function are always under the concave function curve.

In contrast, the mid-point rule will have parts of each rectangle above and below the curve, hence to a certain extent the **errors will cancel** each other out.

This is why, *for this particular example*, the errors in the mid-point rule turn out to be approximately half those in the trapezoidal rule.

While this result turns out to be *generally* true for smooth functions, we can always come up with (counter) examples where the trapezoid rule will win (can you think of an example?).

Taylor series analysis can be used to formally construct upper bounds on the quadrature error for both methods.

We know that the error when integrating constant and linear functions is zero for our two rules, so let's first consider an example of integrating a quadratic polynomial.

We know analytically that

$$\int_{0}^{1} x^{2}\,dx = \left.\frac{1}{3}x^3\right|_0^1=\frac {1}{3}.$$

Numerically, the midpoint rule on a single interval gives

\begin{equation}
I_M = 1 \left(\frac {1}{2}\right)^{2} = \frac {1}{4},
\end{equation}

while the trapezoidal rule gives

\begin{equation}
I_T = 1 \frac {0+1^{2}}{2} = \frac {1}{2}.
\end{equation}

The error for $I_M$ is therefore $1/3 - 1/4 = 1/12$, while the error for $I_T$ is $1/3 - 1/2 = -1/6$.

Therefore, the midpoint rule is twice as accurate as the trapezoid rule:

$$|E_M| = \frac{1}{2} |E_T|,$$

where $|E|$ indicates the error (the absolute value of the difference from the exact solution).

This is the case for this simple example, and we can see from the actual error values printed above that it also appears to be approximately true for the sine case (which is not a simple polynomial) as well.

We will use this knowledge to generate new more accurate rules below, but first let's sketch how you can go about a more rigorous analysis/estimation of errors.

```python

```

In short, for our half sinewave, since we know that the area of half a sine wave is 1, the rectangle method overestimates it by about 0.4%, while the trapezoid method underestimates it for 0.9%.

We notice that in this situation, the rectangle method **overestimates** while the trapezoid method **underestimates**.
We also notice that the underestimate of the trapezoid rule is about **twice** the overestimate of the rectangle method.

So, well, could we combine twice the **overestimate** from the rectangle method and once the **underestimate** from the trapezoid method, and then divide everything by **3** since it's 2 parts rectangle and one part trapezoid rule, to obtain something more accurate?

## VI.     Rule 3: Simpson's rule

Knowing the error estimates from the two rules explored so far opens up the potential for us to combine them in an appropriate manner to create a new quadrature rule, generally more accurate than either one separately.

Suppose $I_S$ indicates an unknown, but more accurate, estimate of the integral over an interval.

Then, as seen above, as $I_T$ has an error that is approximately $-2$ times the error in $I_M$, the following relation must hold approximately:

$$I_S - I_T \approx -2 \left ( I_S - I_M\right ).$$

This follows from the fact that $\,I - I_T \approx -2 \left ( I - I_M\right )$, provided that $I_S$ is closer to $I$ than either of the other two estimates.

Replacing this approximately equals sign with actual equality defines $I_S$ for us in terms of things we know.

We can rearrange this to give an expression for $I_S$ that yields a more accurate estimate of the integral than either $I_M$ or $I_T$:

$$I_S := \frac{2}{3}I_M + \frac{1}{3}I_T.$$

We combined twice the **overestimate** from the rectangle method and once the **underestimate** from the trapezoid method, and then divide everything by **3** since it's 2 parts rectangle and one part trapezoid rule, to obtain something more accurate!

What we're doing here is using the fact that we know something about (the *leading order* behaviour of the) two errors, and we can therefore combine them to cancel this error to a certain extent.

This estimate will generally be more accurate than either $M$ or $T$ alone. The error won't actually be zero in general as we're only cancelling out the leading order term in the error, but a consequence is that we will be left with higher-degree terms in the error expansion of the new quadrature rule which should be smaller (at least in the asymptotic limit), and converge faster.

The resulting quadrature method in this case is known as [Simpson's rule](http://en.wikipedia.org/wiki/Simpson%27s_rule):

Let's expand the Simpsons rule by substituting in what we know about the Rectangle Rule and the Trapezoid Rule

\begin{align*}
I_S &:= \frac{2}{3}I_M + \frac{1}{3}I_T \\[5pt]
&= \frac{2}{3}  (b-a)f\left ( \frac{a+b}{2}\right ) + \frac{1}{3}(b-a)\frac{(f(a) + f(b))}{2} \\[5pt]
& = \frac{(b-a)}{6}\left( f \left ( a\right ) + 4f \left ( c\right ) + f\left ( b\right )\right),
\end{align*}

where $a$ and $b$ are the end points of an interval and $c = \left ( a+b\right )/2$ is the midpoint.

Note that an alternate derivation of the same rule involves fitting a *quadratic function* (i.e. $P_2(x)$ rather than the constant and linear approximations already considered) that interpolates the integral at the two end points of the interval, $a$ and $b$, as well as at the midpoint, $c = \left ( a+b\right )/2$, and calculating the integral under that polynomial approximation.

See the homework exercise, and note that we'll come back to this idea a bit later when we introduce the Newton-Cotes family of quadrature rules.

Let's plot what this method is doing and compute the integral for our sine case.

```python
# this is a matplotlib function that allows us to plot polygons
# use this to plot the straight sides, and add an approximate
# quadratic at the top.
from matplotlib.patches import Polygon

def f(x):
    """The function we wish to integrate"""
    return np.sin(x)

# Get the value of pi from numpy and generate equally spaced values from 0 to pi.
x = np.linspace(0, np.pi, 100)
y = f(x)

# plot
fig = plt.figure(figsize=(12, 4))
ax1 = plt.subplot(111)
ax1.plot(x, y, 'b', lw=2)

ax1.margins(0.1)

# Label axis.
ax1.set_xlabel('x', fontsize=16)
ax1.set_ylabel('sin(x)', fontsize=16)
ax1.set_title('Approximating a function with shapes with quadratic tops', fontsize=16)

# Overlay a grid.
ax1.grid(True)

number_intervals = 5
xi = np.linspace(0, np.pi, number_intervals+1)

I_S = 0.0

for i in range(number_intervals):
    # use a non-closed Polygon to visualise the straight sides of each interval
    ax1.add_patch(Polygon(np.array([[xi[i], f(xi[i])], [xi[i], 0], [xi[i+1], 0], [xi[i+1], f(xi[i+1])]]),
                          closed=False, fill=False, ls='--', color='k', lw=2))
    # add the quadratic top - fit a quadratic using numpy
    poly_coeff = np.polyfit((xi[i], (xi[i] + xi[i+1])/2.0, xi[i + 1]),
                            (f(xi[i]), f((xi[i] + xi[i+1])/2.0), f(xi[i+1])), 2)
    # plot the quadratic using 20 plotting points within the interval
    ax1.plot(np.linspace(xi[i], xi[i+1], 20),
             f(np.linspace(xi[i], xi[i+1], 20)), ls='--', color='k', lw=2)
    # add in the area of the interval shape to our running total using Simpson's formula
    I_S += ((xi[i+1] - xi[i])/6.) * (f(xi[i]) + 4 *
                                     f((xi[i] + xi[i+1])/2.0) + f(xi[i+1]))

plt.show()
print("The Simpson's rule approximation (I_S): {:.12f}".format(I_S))
```

It looks like a much closer fit to the function:

Let's make a function to test it out...

## Exercise 3.3: Implementing Simpson's rule

Complete an implementation of Simpson's rule and test it on our sine function.

```python
def simpsons_rule(a, b, function, number_intervals=10):
    """ Function to evaluate Simpson's rule.

    Note that this implementation takes the function as an argument,
    and evaluates this at the midpoint of subintervals in addition to the
    end point. Hence additional information is generated and used through
    additional function evaluations.

    This is different to the function/implementation available with SciPy
    where discrete data only is passed to the function.

    Bear this in mind when comparing results - there will be a factor of two
    in the definition of "n" we need to be careful about!

    Also note that this version of the function performs redundant function
    evaluations - see the **composite** implementation below.
    """

    # Add your code here!

```

```python
print("The area found by direct integration = 2")
for i in (1, 2, 10, 100, 1000):
    area = simpsons_rule(0, np.pi, np.sin, i)
    print("Area %d rectangle(s) = %g (error=%g)"%(i, area, abs(area-2)))
```

You should find:

`The area found by direct integration = 2`

`Area 1 rectangle(s) = 2.0944 (error=0.0943951)`

`Area 2 rectangle(s) = 2.00456 (error=0.00455975)`

`Area 20 rectangle(s) = 2 (error=4.23093e-07)`

`Area 200 rectangle(s) = 2 (error=4.228e-11)`

`Area 2000 rectangle(s) = 2 (error=2.22045e-15)`

For this simple function you should find far smaller errors, and which drop much more rapidly with smaller $h$ (or more sub-intervals).

## Observations

- The errors are lower than for the midpoint and trapezoidal rules, and the method converge more rapidly - i.e. the relative improvement only gets better for more subintervals.

- This expression now integrates up to cubics exactly (by construction), so it is of order 4 (as confirmed by the convergence plot above).

- For the Simpsons Rule, we have the degree of accuracy or precision being $3$ but the method is $4th$ order accurate, since if we halve the interval size the error goes down by a factor of $2^4 =16$!. Simpsons rule integrates to cubics exactly, so since it's integrating exactly, cubics cannot contribute to the error. Only the quartic(4th) contribute to the error, so it's 4th order accurate

- We're getting down to errors close to machine precision now when we use 1000 subintervals. Remember: your average consumer grade hardware can only handle that many decimal points, and you will need some rather expensive hardware to have even higher levels of precisions. Continuing with 1000 subintervals is actually not helpful as the error that you will get stops decreasing as it is so small that your computer stops being able to discriminate it from 0 But remember we may well either have a relatively small number of data points, or want to minimise the number of function evaluations well below this relatively high number. This will mean that for problems with lots of variation, and/or in higher dimensions, that we still work to do in improving our quadrature methods.

- As was the case with our first Trapezoidal implementation, we are performing unnecessary function evaluations here; we can fix this issue through the implementation of a so-called *composite* version of the rule, which still gives the same result as your Simpson's rule, but makes it but easier for the computer. The Composite Simpsons Rule still does many evaluations, but fewer evaluations than your Standard Simpson's Rule.

- You could always try to derive the Composite Simpson's Rule from the Simpsons Rule! I think this was covered in A Level Further Mathematics, at least when I was in high school.

##   VII.     Rule 4: Composite Simpson's Rule

If we assume that our interval $[a,b]$ has been split up into $n$ intervals (or $n+1$ data points) we can save some function evaluations by writing Simpson's Rule in the following form (note here we do not introduce any additional midpoint function evaluations)

\begin{align*}
I_{S}
& = \frac{\Delta x}{3}\left[ f \left ( x_0\right ) + 4f \left ( x_1\right ) + 2f\left ( x_2\right ) + 4f \left ( x_3\right ) + \cdots + 2 f \left ( x_{n-2}\right )  + 4 f \left ( x_{n-1}\right ) +  f \left ( x_{n}\right ) \right]\\[5pt]
& = \frac{\Delta x}{3}\left[ f \left ( x_0\right ) + 2\sum_{i=1}^{n/2 - 1} f\left(x_{2i}\right) + 4\sum_{i=1}^{n/2} f\left(x_{2i-1}\right)  +  f \left ( x_{n}\right ) \right].
\end{align*}

This is known as the [Composite Simpson's rule](http://en.wikipedia.org/wiki/Simpson%27s_rule#Composite_Simpson.27s_rule),
or more precisely the *composite Simpson's 1/3 rule*.

This is the version of Simpson's rule implemented by Scipy [`scipy.interpolate.simps`](http://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.simps.html).

Note that this way of formulating Simpson's rule (where we do not allow additional function evaluations at the midpoints of intervals - we assume we are only in a position to use the given data points) requires that $n$ be even.

This way of writing the composite form in the case of $n=2$ is equivalent to the formula over $[a,b]$ that introduced the additional midpoint location $c$.

```python
def simpsons_composite_rule(a, b, function, number_intervals=10):
    """Function to evaluate the composite Simpson's rule only using
    function evaluations at (number_intervals + 1) points.

    This implementation requires that the number of subintervals (number_intervals) be even
    """
    assert number_intervals % 2 == 0, "number_intervals is not even"

    interval_size = (b - a) / number_intervals
    # start with the two end member values
    I_cS2 = function(a) + function(b)

    # add in those terms with a coefficient of 4
    for i in range(1, number_intervals, 2):
        I_cS2 += 4 * function(a + i * interval_size)

    # and those terms with a coefficient of 2
    for i in range(2, number_intervals-1, 2):
        I_cS2 += 2 * function(a + i * interval_size)

    return I_cS2 * (interval_size / 3.0)
```

```python
print("The area found by direct integration = 2")
for i in (2, 10, 100, 1000):
    area = simpsons_composite_rule(0, np.pi, np.sin, i)
    print("Area %d rectangle(s) = %g (error=%g)"%(i, area, abs(area-2)))
```

This is a slight improvement for a simple function like $\sin$, but will be much more of an improvement for functions which oscillate more, in a relative sense compared to the size of our bins.

## VIII.      Rule 5: Weddle's rule

We noted above that Simpson's rule is fourth-order accurate. For the Simpsons Rule, we have the degree of accuracy or precision being $3$ but the method is $4th$ order accurate, since if we halve the interval size the error goes down by a factor of $2^4 =16$!

Suppose we take an approximation to $I$ using $n$ subintervals with Simpson's rule and call the result $I_S$, and then apply Simpson's rule with double the number of intervals ($2n$) and call the result $I_{S_2}$.

Then we have two estimates for the integral where we expect $I_{S_2}$ to be approximately $2^4=16$ times more accurate than $S$. In particular, we expect the lowest (i.e. the leading) order error term in $I_{S_2}$ to be precisely one sixteenth that of $I_S$.For the Simpsons Rule, we have the degree of accuracy or precision being $3$ but the method is $4th$ order accurate, since if we halve the interval size the error goes down by a factor of $2^4 =16$!

Similar to how we derived Simpson's rule by combining what we knew of the error for the midpoint and trapezoidal rules, with this knowledge we can combine the two estimates from Simpson's rule to derive an even more accurate estimate of $I$:

Basically,

You have your Simpsons rule, where you know it's 4th order accurate. 4th order accurate means that if the subinterval decreases by 2, then the error will also decrease by 16 times. You do the Simpsons Rule as usual, and you get that the area under the curve found through the Simpsons Rule is $I_S$. There is a certain error, let's call it $E_S$ in $I_S$

You have your Simpsons rule, but this time, you make the subintervals $2$ times smaller, thus decreasing the error by $16$ times, and you get the area under the curve $I_{S2}$. There is a certain error, let's call it $E_{S2}$ in $I_{S2}$.

Since for $I_{S2}$, compared to $I_{S}$, you made the intervals $2$ times samller, the error from $I_{S2}$, so $E_{S2}$, will be well 16 times smaller than the error from $I_S$, so $E_S$

We can therefore write the relationship between $E_S$ and $E_{S2}$ as

$$E_S \approx E_{S2} \times 16$$

Now, suppose we name our more accurate integral to be $I_W$. The difference between the more accurate integral and the Simpsons Rule integral $I_S$ is the error of the Simpsons Rule $E_S$, and I can write the relationship as

$$I_W = I_S + E_S$$

The difference between the more accurate integral and the Simpsons Rule integral with 2 times smaller subintervals $I_S2$ is the error of the Simpsons Rule $E_S2$, and I can write the relationship as

$$I_W = I_{S2} + E_{S2}$$

Performing Subsitution using the 3 equations above, I arrive at

$$I_W - I_S = 16 \left ( I_W - I_{S_2} \right ),$$

A bit of manipulation:

\begin{align*}
& \;\;\; I_W - I_S = 16 \left ( I_W - I_{S_2} \right ) \\[5pt]
\implies & \;\;\; I_W - I_S = 16 I_W - 16 I_{S_2} \\[5pt]
\implies & \;\;\; 15 I_W  = 16 I_{S_2} - I_S \\[5pt]
\implies & \;\;\; 15 I_W  = 15 I_{S_2} + (I_{S_2} - I_S) ,
\end{align*}

leads us to the expression

$$ I_W = I_{S_2} + \frac {\left (I_{S_2} - I_S \right )}{15}.$$

This is known as *Weddle's rule*, or the *extrapolated Simpson's rule* because it uses two different values for the interval size and *extrapolates* from these two to obtain an even more accurate result.

Making a function for this rule is easy as we can just call our Simpson's rule functions with two values for the number of intervals.

## Exercise 3.4: Implementing Weddle's rule

Write a function which implements Weddle's rule using appropriate calls to the simpsons and simpsons_composite functions written above.

Ultimately you should be able to generate a comparison image that looks something like this:

## Aside: (Richardson) extrapolation, Romberg integration and Newton-Cotes formula

Note that the above technique of using the same rule, but with different values for the interval size, $h$, to derive a more accurate estimate of the integral is an example of what is more generally called *Richardson extrapolation*.

Performing this approach using the trapezoid rule as the starting point leads to what is termed *Romberg integration*.

Taking the idea behind Simpson's rule which fit a quadratic Lagrange interpolating polynomial to *equally spaced* points in the interval, end extending to any order Lagrange polynomial leads to the [*Newton-Cotes* family of quadrature rules](https://en.wikipedia.org/wiki/Newton%E2%80%93Cotes_formulas).

Note finally, that even wider families exist where the function being integrated is evaluated at non-equally-spaced points.

And of course for practical application these ideas need to be extended to more than one dimension.

```python

```


```python
import matplotlib.pyplot as plt
import pandas as pd
pd.set_option('display.latex.repr', True)
import sympy as sym
sym.init_printing(use_latex=True)
import numpy as np
from IPython.display import Image, Latex
```

# Markdown

## General

Some markdown text.

A list:

- something
- something else

A numbered list

1. something
2. something else

non-ascii characters TODO

This is a long section of text, which we only want in a document (not a presentation)
some text
some more text
some more text
some more text
some more text
some more text
some more text
some more text
some more text

This is an abbreviated section of the document text, which we only want in a presentation

- summary of document text

## References and Citations

References to \cref{fig:example}, \cref{tbl:example}, =@eqn:example_sympy and \cref{code:example_mpl}.

A latex citation.\cite{zelenyak_molecular_2016}

A html citation.(Kirkeminde, 2012)

## Todo notes

\todo[inline]{an inline todo}

Some text.\todo{a todo in the margins}

# Text Output

```python
print("""
This is some printed text,
with a nicely formatted output.
""")
```

    This is some printed text,
    with a nicely formatted output.

# Images and Figures

## Displaying a plot with its code

A matplotlib figure, with the caption set in the markdowncell above the figure.

The plotting code for a matplotlib figure (\cref{fig:example_mpl}).

# Tables (with pandas)

The plotting code for a pandas Dataframe table (\cref{tbl:example}).

```python
np.random.seed(0)
df = pd.DataFrame(np.random.rand(3,4),columns=['a','b','c','d'])
df.a = ['$\delta$','x','y']
df.b = ['l','m','n']
df.set_index(['a','b'])
df.round(3)
```

      a
      b
      c
      d

      0
      $\delta$
      l
      0.603
      0.545

      1
      x
      m
      0.438
      0.892

      2
      y
      n
      0.792
      0.529

# Equations (with ipython or sympy)

```python
Latex('$$ a = b+c $$')
```

$$ a = b+c $$

The plotting code for a sympy equation (=@eqn:example_sympy).

```python
y = sym.Function('y')
n = sym.symbols(r'\alpha')
f = y(n)-2*y(n-1/sym.pi)-5*y(n-2)
sym.rsolve(f,y(n),[1,4])
```

# Interactive outputs

## ipywidgets

```python
import ipywidgets as widgets
widgets.Layout(model_id="1337h4x0R")
```

    Layout()


# __Estructuras de control iterativo__

## __1. Ciclo _for___

Ciclo estático que identifica un _inicio_ y un _final_ antes de ejecutarse.

### 1.1. Ciclo numérico

Presenta la siguiente estructura básica:

```
  for i in range(n):
    (desarrollo)
```

La función `range()` indica la cantidad individual de veces que deseas que se repita el ciclo (para este caso, `n` veces) e `i` es un _identificador_ del ciclo. Por ejemplo:

```python
for j in range(10):
  print(j)
```

También se puede especificar una estructura más compleja:

```
for i in range(ni, nf, c):
  (desarrollo)
```

Dónde: `ni` es el número en donde inicia el ciclo, `nf` en donde termina y `c` indica el valor aditivo del ciclo (por defecto, es $1$).

```python
for i in range(2,10,2):
  print(i)
```

### 1.2. Ciclo _for_ en variables "multi-contenido"

#### 1.2.1 Listas

Se puede emplear una estructura de control condicional para iterar sobre los elementos de una lista de la siguiente forma:

```
  for elemento in lista:
    (desarrollo)
```
Por ejemplo:

```python
lista = ['a', 'b', 'c']
for e in lista:
  print(e)
```

```python
num_sumar = [1,2,3,4]   #Números a sumar
sumatoria = 0
for num in num_sumar:
  sumatoria += num
print("El total de la sumatoria es:", sumatoria)
```

### __Ejercicio:__

Elabora una condicional que calcule el valor de $x!$. Recuerda que:

$$
\begin{equation}
  x! = x (x-1) (x-2) \cdots \left[x-(x-1) \right]
\end{equation}
$$

Dónde $x$ es un número natural.

```python

```

#### 1.2.2 Tuplas

Funciona de la misma manera que las listas.

```python
t = (1,2,3)
suma = 0
for i in t:
  suma += i
print(suma)
```

### 1.2.3 Diccionario
---

Recordemos, primero, la estructura básica de un diccionario:

```
nombre_dic = {id:contenido}
```

Dónde: `nombre_dic` es el nombre de la variable, `id` es el identificador del contenido (en listas y tuplas son números, en los diccionarios pueden ser palabras) y el `contenido` adopta el valor de interés y puede ser de cualquier naturaleza.

---

Existen tres formas de iterar sobre el contenido de un diccionario: sobre los _identificadores_, sobre el _contenido_ o sobre los dos directamente.

Si nos interesan sólo los `id`:

```
for id in dic:
  (desarrollo)
```

```python
dic = {
    'id1':1,
    'id2':2
}
for id in dic:
  print(id)
```

Si nos interesa sólo el `contenido`:

```python
for contenido in dic.values():
  print(contenido)
```

Si nos interesa el `id` y el `contenido`.

```python
for id, contenido in dic.items():
  print(id, contenido)
```

Usualmente, en programación, se desea emplear la toma de decisiones mediante estructuras de control condicional a una serie de datos y no hacia una variable en particular.

## __Ejemplo:__

Elabora un algoritmo que identifique los números mayores a $1$.

```python
lista = [5,4,2,-4,0,20,50]

```

# __2. Ciclo _while___

A diferencia del ciclo for, el ciclo _while_ no presenta un "final" establecido. Puede, incluso, categorizarse como un ciclo indefinido, o "infinito" llegado el caso.

Presenta la siguiente estructura:

```
while condicion:
  (desarrollo)
```

```python
#El presente bloque es un ciclo while infinito. CUIDADO: puede crashear el sistema.
#Para usarlo, borra las comillas.
"""
suma = 0
while True:
  suma += 1
  print('Ciclo número:', suma)
"""
```

```python
suma = 0
while suma < 10:
  suma += 1
  print("El valor de suma es", suma)
```

## __Ejercicio:__

1. Identifica las personas mayores de edad de la siguiente lista.

```python
personas = {
    'Julián Flores': {"Tipo documento": "C.C.", "Número": "1.098.880.330"},
    'Óscar Giraldo': {"Tipo documento": "T.I.", "Número": "098.880.330"},
    'Andrea Juárez': {"Tipo documento": "C.C.", "Número": "1.098.870.330"},
    'Jorge Iván López': {"Tipo documento": "C.C.", "Número": "1.098.860.331"},
    'Javier Flores': {"Tipo documento": "T.I.", "Número": "1.098.880.330"},
    'Sara López': {"Tipo documento": "C.C.", "Número": "1.098.980.330"},
}

```

2. Desarrollaste una plataforma de e-commerce. Deseas que se registre un descuento del $5\%$ a partir de la sexta unidad por la compra de cualquier producto. El descuento aplica sobre todo el carrito. Después de la doceava unidad, aplicas un descuento del $2\%$ adicional __sólo__ a los productos individuales mayores a una unidad. Elabora un algoritmo que calcule el total del carrito de compras.

```python
carrito_compras = {
    "Talco": {"Unitario": 10000, "Cantidad":2},
    "Cepillo dental": {"Unitario": 2000, "Cantidad":6},
    "Ceda dental": {"Unitario":3000, "Cantidad":1},
    "Guantes": {"Unidad":500, "Cantidad":2},
    "Lomito": {"Unitario":20000, "Cantidad":0.2}
}

```


```python
# Header starts here.
from sympy.physics.units import *
from sympy import *

# Rounding:
import decimal
from decimal import Decimal as DX
from copy import deepcopy
def iso_round(obj, pv, rounding=decimal.ROUND_HALF_EVEN):
    import sympy
    """
    Rounding acc. to DIN EN ISO 80000-1:2013-08
    place value = Rundestellenwert
    """
    assert pv in set([
        # place value   #  round to:
        1,              #  1
        0.1,            #  1st digit after decimal
        0.01,           #  2nd
        0.001,          #  3rd
        0.0001,         #  4th
        0.00001,        #  5th
        0.000001,       #  6th
        0.0000001,      #  7th
        0.00000001,     #  8th
        0.000000001,    #  9th
        0.0000000001,   # 10th
        ])
    objc = deepcopy(obj)
    try:
        tmp = DX(str(float(objc)))
        objc = tmp.quantize(DX(str(pv)), rounding=rounding)
    except:
        for i in range(len(objc)):
            tmp = DX(str(float(objc[i])))
            objc[i] = tmp.quantize(DX(str(pv)), rounding=rounding)
    return objc

# LateX:
kwargs = {}
kwargs["mat_str"] = "bmatrix"
kwargs["mat_delim"] = ""

# kwargs["symbol_names"] = {FB: "F^{\mathsf B}", }

# Units:
(k, M, G ) = ( 10**3, 10**6, 10**9 )
(mm, cm) = ( m/1000, m/100 )
Newton = kg*m/s**2
Pa     = Newton/m**2
MPa    = M*Pa
GPa    = G*Pa
kN     = k*Newton
deg    = pi/180

half = S(1)/2

# Header ends here.
#
l, EI, q = var("l, EI, q")
a1, a2 = var("a1, a2")
x = var("x")

# Ansatz (must match geometrical BCs):
xi = x/l

# case = Wellerdick's Solution:
case = 1

if case ==1: # one parameter
    w = a1*(xi-xi*xi)
    # student:
    w = a1*(l*x - x*x)
elif case == 2:
    t1 = a1*(xi - xi*xi)
    t2 = a2*(xi*xi - xi*xi*xi)
    w = t1 + t2
elif case == 3:
    t1 = a1*(xi - xi*xi)
    t2 = a2*(xi*xi*xi - xi*xi*xi*xi)
    w = t1 + t2
elif case == 4: # error is Wellerdick's solution
    t1 = a1*(xi - xi*xi*xi)
    t2 = a2*(xi*xi*xi - xi*xi*xi*xi)
    w = t1 + t2

w1 = diff(w,  x)
w2 = diff(w1, x)

pprint("\nw(x):")
pprint(w)
pprint("\nw'(x):")
pprint(w1)
pprint("\nw''(x):")
pprint(w2)

pprint("\nUi:")
Ui = EI/2 * integrate(w2*w2, (x, 0, l))
pprint(Ui)

pprint("\nUa:")
Ua =  integrate(q*w, (x, 0, l))
pprint(Ua)

U = Ui - Ua

if case != 1:
    dUda1 = diff(U,a1)
    dUda2 = diff(U,a2)
    eq1 = Eq(dUda1,0)
    eq2 = Eq(dUda2,0)
    pprint("\nEq 1:")
    pprint(eq1)
    pprint("\nEq 2:")
    pprint(eq2)
    sol = solve([eq1,eq2], [a1,a2])
    a1s = sol[a1]
    a2s = sol[a2]
    pprint("\na1:")
    pprint(a1s)
    pprint("\na2:")
    pprint(a2s)
    w = w.subs({a1: a1s, a2:a2s})
else: # one parameter
    dUda1 = diff(U,a1)
    eq1 = Eq(dUda1,0)
    pprint("\nEq 1:")
    pprint(eq1)
    sol = solve([eq1], [a1])
    a1s = sol[a1]
    pprint("\na1:")
    pprint(a1s)
    w = w.subs({a1: a1s})

w = w.simplify()
pprint("\nw(x):")
pprint(w)
pprint("\nw(l/2):")
tmp = w.subs(x,l/2)
pprint(tmp)

pprint("\nw(l/2) / w(l/2)_exact:")
exact = 5*q*l**4/(384*EI)
tmp /= exact
tmp = iso_round(tmp,0.01)
pprint(tmp)

# Case 1:
#
# w(x):
#    ⎛     2⎞
#    ⎜x   x ⎟
# a₁⋅⎜─ - ──⎟
#    ⎜l    2⎟
#    ⎝    l ⎠
#
# w'(x):
#    ⎛1   2⋅x⎞
# a₁⋅⎜─ - ───⎟
#    ⎜l     2⎟
#    ⎝     l ⎠
#
# w''(x):
# -2⋅a₁
# ──────
#    2
#   l
#
# Ui:
#        2
# 2⋅EI⋅a₁
# ────────
#     3
#    l
#
# Ua:
# a₁⋅l⋅q
# ──────
#   6
#
# Eq 1:
# 4⋅EI⋅a₁   l⋅q
# ─────── - ─── = 0
#     3      6
#    l
#
# a1:
#   4
#  l ⋅q
# ─────
# 24⋅EI
#
# w(x):
#  2
# l ⋅q⋅x⋅(l - x)
# ──────────────
#     24⋅EI
#
# w(l/2):
#   4
#  l ⋅q
# ─────
# 96⋅EI
#
# w(l/2) / w(l/2)_exact:
# 0.80

```


###### Content under Creative Commons Attribution license CC-BY 4.0, code under MIT license (c) 2019 Daniel Koehn, based on (c)2018 L.A. Barba, G.F. Forsyth [CFD Python](https://github.com/barbagroup/CFDPython#cfd-python), (c)2014 L.A. Barba, I. Hawke, B. Knaepen [Practical Numerical Methods with Python](https://github.com/numerical-mooc/numerical-mooc#practical-numerical-methods-with-python), also under CC-BY.

```python
# Execute this cell to load the notebook's style sheet, then ignore it
from IPython.core.display import HTML
css_file = '../style/custom.css'
HTML(open(css_file, "r").read())
```

<link href="https://fonts.googleapis.com/css?family=Merriweather:300,300i,400,400i,700,700i,900,900i" rel='stylesheet' >
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,300i,400,400i,700,700i" rel='stylesheet' >
<link href='http://fonts.googleapis.com/css?family=Source+Code+Pro:300,400' rel='stylesheet' >
<style>

@font-face {
    font-family: "Computer Modern";
    src: url('http://mirrors.ctan.org/fonts/cm-unicode/fonts/otf/cmunss.otf');
}

#notebook_panel { /* main background */
    background: rgb(245,245,245);
}

div.cell { /* set cell width */
    width: 800px;
}

div #notebook { /* centre the content */
    background: #fff; /* white background for content */
    width: 1000px;
    margin: auto;
    padding-left: 0em;
}

#notebook li { /* More space between bullet points */
margin-top:0.5em;
}

/* draw border around running cells */
div.cell.border-box-sizing.code_cell.running {
    border: 1px solid #111;
}

/* Put a solid color box around each cell and its output, visually linking them*/
div.cell.code_cell {
    background-color: rgb(256,256,256);
    border-radius: 0px;
    padding: 0.5em;
    margin-left:1em;
    margin-top: 1em;
}

div.text_cell_render{
    font-family: 'Source Sans Pro', sans-serif;
    line-height: 140%;
    font-size: 110%;
    width:680px;
    margin-left:auto;
    margin-right:auto;
}

/* Formatting for header cells */
.text_cell_render h1 {
    font-family: 'Merriweather', serif;
    font-style:regular;
    font-weight: bold;
    font-size: 250%;
    line-height: 100%;
    color: #004065;
    margin-bottom: 1em;
    margin-top: 0.5em;
    display: block;
}
.text_cell_render h2 {
    font-family: 'Merriweather', serif;
    font-weight: bold;
    font-size: 180%;
    line-height: 100%;
    color: #0096d6;
    margin-bottom: 0.5em;
    margin-top: 0.5em;
    display: block;
}

.text_cell_render h3 {
    font-family: 'Merriweather', serif;
	font-size: 150%;
    margin-top:12px;
    margin-bottom: 3px;
    font-style: regular;
    color: #008367;
}

.text_cell_render h4 {    /*Use this for captions*/
    font-family: 'Merriweather', serif;
    font-weight: 300;
    font-size: 100%;
    line-height: 120%;
    text-align: left;
    width:500px;
    margin-top: 1em;
    margin-bottom: 2em;
    margin-left: 80pt;
    font-style: regular;
}

.text_cell_render h5 {  /*Use this for small titles*/
    font-family: 'Source Sans Pro', sans-serif;
    font-weight: regular;
    font-size: 130%;
    color: #e31937;
    font-style: italic;
    margin-bottom: .5em;
    margin-top: 1em;
    display: block;
}

.text_cell_render h6 { /*use this for copyright note*/
    font-family: 'Source Code Pro', sans-serif;
    font-weight: 300;
    font-size: 9pt;
    line-height: 100%;
    color: grey;
    margin-bottom: 1px;
    margin-top: 1px;
}

    .CodeMirror{
            font-family: "Source Code Pro";
			font-size: 90%;
    }
/*    .prompt{
        display: None;
    }*/

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

# 2D Heat Equation: Comparison between analytical and numerical solution

In this module, we developed a finite difference modelling code to solve the 2D heat equation and optimized the runtime performance using Just-In-Time (JIT) compilation from the `Numba` package. What is still missing is a comparison between an analytical and the corresponding numerical solution.

## Analytical solution of the 2D Heat Equation

A simple (time-dependent) analytical solution for the 2D heat equation

\begin{equation}
\frac{\partial T}{\partial t} = \alpha \left(\frac{\partial^2 T}{\partial x^2} + \frac{\partial^2 T}{\partial y^2} \right) \tag{1}
\end{equation}

exists for the case that the initial temperature distribution in a full-space model

\begin{equation}
T(x,y,t=0) = T_{max} exp\biggl[\frac{-(x^2+y^2)}{s^2}\biggr]\tag{2}
\end{equation}

where $T_{max}$ is the maximum amplitude of the temperature perturbation at $(x,y)=(0,0)$ and it 's half-width $s$. Then, the analytical solution is

\begin{equation}
T(x,y,t) = \frac{T_{max}}{1+4t\alpha/s^2} exp\biggl[\frac{-(x^2+y^2)}{s^2+4t\alpha}\biggr]\tag{3}
\end{equation}

##### Exercise 1

Solve the above problem using the JIT-optimized FTCS FD-code from the last class and compare the numerical with the analytical solution.

Let's start by setting up our Python compute environment and reuse the performance optimized `ftcs_JIT` code from the last class.

```python
# Import libraries
import numpy
from matplotlib import pyplot
%matplotlib inline

# import JIT from Numba
from numba import jit
```

```python
# Set the font family and size to use for Matplotlib figures.
pyplot.rcParams['font.family'] = 'serif'
pyplot.rcParams['font.size'] = 16
```

```python
# FTCS code to solve the 2D heat equation with JIT optimization
# -------------------------------------------------------------
@jit(nopython=True) # use Just-In-Time (JIT) Compilation for C-performance
def ftcs_JIT(T0, nt, dt, dx, dy, alpha):
    """
    Computes and returns the temperature distribution
    after a given number of time steps.
    Explicit integration using forward differencing
    in time and central differencing in space, with
    Neumann conditions (zero-gradient) on top and right
    boundaries and Dirichlet conditions on bottom and
    left boundaries.

    Parameters
    ----------
    T0 : numpy.ndarray
        The initial temperature distribution as a 2D array of floats.
    nt : integer
        Maximum number of time steps to compute.
    dt : float
        Time-step size.
    dx : float
        Grid spacing in the x direction.
    dy : float
        Grid spacing in the y direction.
    alpha : float
        Thermal diffusivity.

    Returns
    -------
    T : numpy.ndarray
        The temperature distribution as a 2D array of floats.
    """
    # Define some constants.
    sigma_x = alpha * dt / dx**2
    sigma_y = alpha * dt / dy**2

    # Integrate in time.
    T = T0.copy()

    # Estimate number of grid points in x- and y-direction
    ny, nx = T.shape

    # Indices of the model center
    I, J = int(nx / 2), int(ny / 2)

    # Time loop
    for n in range(nt):

        # store old temperature field
        Tn = T.copy()

        # loop over spatial grid
        for i in range(1,nx-1):
            for j in range(1,ny-1):

                T[j, i] = (Tn[j, i] +
                         sigma_x * (Tn[j, i+1] - 2.0 * Tn[j, i] + Tn[j, i-1]) +
                         sigma_y * (Tn[j+1, i] - 2.0 * Tn[j, i] + Tn[j-1, i]))

    return T
```

Define modelling parameters and initial conditions according to eq. (2). This time, I give you the freedom to define your own model parameters

```python
# Definition of modelling parameters
# ----------------------------------
Lx =   # length of the plate in the x direction [m]
Ly =   # height of the plate in the y direction [m]
nx =   # number of points in the x direction
ny =   # number of points in the y direction
dx = Lx / (nx - 1)  # grid spacing in the x direction
dy = Ly / (ny - 1)  # grid spacing in the y direction
alpha =   # thermal diffusivity of the plate [m^2/s]

# Define the locations along a gridline.
x = numpy.linspace(0.0, Lx, num=nx)
y = numpy.linspace(0.0, Ly, num=ny)

# DEFINE THE INITIAL TEMPERATURE DISTRIBUTION EQ.(2) HERE!
X, Y = numpy.meshgrid(x,y) # coordinates X,Y required to define T0

# I recommend moving the maximum of the initial temperature
# distribution to the center of the model
X = X - Lx/2.
Y = Y - Ly/2.

s =         # half-width of the Gaussian function [m]
Tmax =      # maximum temperature Tmax [°C]
T0 =        # Define initial temperature distribution according to eq.(2)
```

We don't want our solution blowing up, so let's find a time step with $\frac{\alpha \Delta t}{\Delta x^2} = \frac{\alpha \Delta t}{\Delta y^2} = \frac{1}{4}$. Also, define the number of time steps `nt` you want to model the heat conduction

```python
# Set the time-step size based on CFL limit.
sigma = 0.25
dt = sigma * min(dx, dy)**2 / alpha  # time-step size
nt =   # number of time steps to compute
```

After setting all modelling parameters, we can run the `ftcs_JIT` modelling code to compute the numerical solution after `nt` time steps

```python
# Compute the temperature distribution after nt timesteps
T = ftcs_JIT(T0, nt, dt, dx, dy, alpha)
```

Compute the temperature field of the analytical solution

```python
# DEFINE ANALYTICAL SOLUTION EQ. (3) HERE!
t = nt * dt  # maximum modelling time of the FD code
T_analytical =
```

In order to compare the different solutions, we first plot the analytical temperature distribution. Depending on how you defined the problem, you might have to adjust the temperature range in the `levels` array

```python
# Plot the filled contour of the analytical temperature distribution
pyplot.figure(figsize=(7.0, 6.0))
pyplot.xlabel('x [m]')
pyplot.ylabel('y [m]')
levels = numpy.linspace(0.0, 80.0, num=101)
contf = pyplot.contourf(x, y, T_analytical, levels=levels)
cbar = pyplot.colorbar(contf)
cbar.set_label('Temperature [°C]')
pyplot.axis('scaled', adjustable='box');
```

Plot the numerical temperature distribution from the `ftcs_JIT` code. Be sure, that the `levels` in this plot are identical to the one in the analytical solution plot above to allow a fair comparison.

```python
# Plot the filled contour of the temperature distribution from the ftcs_JIT code
pyplot.figure(figsize=(7.0, 6.0))
pyplot.xlabel('x [m]')
pyplot.ylabel('y [m]')
levels = numpy.linspace(0.0, 80.0, num=101)
contf = pyplot.contourf(x, y, T, levels=levels)
cbar = pyplot.colorbar(contf)
cbar.set_label('Temperature [°C]')
pyplot.axis('scaled', adjustable='box');
```

Plot the difference between numerical and analytical solution. You have to adapt the `levels` to the temperature difference between numerical and analytical solution.

```python
# Plot the filled contour of the temperature distribution from the ftcs_JIT code
pyplot.figure(figsize=(7.0, 6.0))
pyplot.xlabel('x [m]')
pyplot.ylabel('y [m]')
levels = numpy.linspace(-1e-1, 1e-1, num=101)
contf = pyplot.contourf(x, y, T-T_analytical, levels=levels)
cbar = pyplot.colorbar(contf)
cbar.set_label(r'$T_{FD} - T_{analytical}$ [°C]')
pyplot.axis('scaled', adjustable='box');
```

Discuss the error distribution. Where are large and small errors? Explain why.

## What we learned:

* How to estimate the accuracy of the FTCS code to solve the 2D heat equation by validating the numerical against an analytical solution


```python
%load_ext autoreload
%autoreload 2

import utils
from sympy import sqrt
import sympy as sy
```

# Exercise 3 Problem 2

```python
dh_params = [
    [0, 0, 0, "theta_1"],
    [90, "l_1", 0, "theta_2"],
    [0, "l_2", 0, "theta_3"],
    [0, "l_3", 0, 0]
]

joint_points = [
    sy.Matrix([0, 0, 0]),
    sy.Matrix([sy.Symbol("l_1"), 0, 0]),
    sy.Matrix([sy.Symbol("l_2"), 0, 0]),
    sy.Matrix([sy.Symbol("l_3"), 0, 0]),
]
```

```python
J = utils.comp_jacobian(dh_params, joint_points)
```

$\displaystyle {}^1v_1 = \left[\begin{matrix}0\\0\\0\end{matrix}\right]$

$\displaystyle {}^1\omega_1 = \left[\begin{matrix}0\\0\\\dot\theta_{1}\end{matrix}\right]$

$\displaystyle {}^2v_2 = \left[\begin{matrix}0\\0\\- \dot\theta_{1} l_{1}\end{matrix}\right]$

$\displaystyle {}^2\omega_2 = \left[\begin{matrix}\dot\theta_{1} \sin{\left(\theta_{2} \right)}\\\dot\theta_{1} \cos{\left(\theta_{2} \right)}\\\dot\theta_{2}\end{matrix}\right]$

$\displaystyle {}^3v_3 = \left[\begin{matrix}\dot\theta_{2} l_{2} \sin{\left(\theta_{3} \right)}\\\dot\theta_{2} l_{2} \cos{\left(\theta_{3} \right)}\\- \dot\theta_{1} \left(l_{1} + l_{2} \cos{\left(\theta_{2} \right)}\right)\end{matrix}\right]$

$\displaystyle {}^3\omega_3 = \left[\begin{matrix}\dot\theta_{1} \sin{\left(\theta_{2} + \theta_{3} \right)}\\\dot\theta_{1} \cos{\left(\theta_{2} + \theta_{3} \right)}\\\dot\theta_{2} + \dot\theta_{3}\end{matrix}\right]$

$\displaystyle {}^4v_4 = \left[\begin{matrix}\dot\theta_{2} l_{2} \sin{\left(\theta_{3} \right)}\\\dot\theta_{2} l_{2} \cos{\left(\theta_{3} \right)} + l_{3} \left(\dot\theta_{2} + \dot\theta_{3}\right)\\- \dot\theta_{1} \left(l_{1} + l_{2} \cos{\left(\theta_{2} \right)} + l_{3} \cos{\left(\theta_{2} + \theta_{3} \right)}\right)\end{matrix}\right]$

$\displaystyle {}^4\omega_4 = \left[\begin{matrix}\dot\theta_{1} \sin{\left(\theta_{2} + \theta_{3} \right)}\\\dot\theta_{1} \cos{\left(\theta_{2} + \theta_{3} \right)}\\\dot\theta_{2} + \dot\theta_{3}\end{matrix}\right]$

$\displaystyle {}^4J = \left[\begin{matrix}0 & l_{2} \sin{\left(\theta_{3} \right)} & 0\\0 & l_{2} \cos{\left(\theta_{3} \right)} + l_{3} & l_{3}\\- l_{1} - l_{2} \cos{\left(\theta_{2} \right)} - l_{3} \cos{\left(\theta_{2} + \theta_{3} \right)} & 0 & 0\\\sin{\left(\theta_{2} + \theta_{3} \right)} & 0 & 0\\\cos{\left(\theta_{2} + \theta_{3} \right)} & 0 & 0\\0 & 1 & 1\end{matrix}\right]$

```python
transf = utils.full_homo_transf(utils.build_transf(dh_params, verbose=False))
```

$\displaystyle {}^0_2T = \left[\begin{matrix}\cos{\left(\theta_{1} \right)} \cos{\left(\theta_{2} \right)} & - \sin{\left(\theta_{2} \right)} \cos{\left(\theta_{1} \right)} & \sin{\left(\theta_{1} \right)} & l_{1} \cos{\left(\theta_{1} \right)}\\\sin{\left(\theta_{1} \right)} \cos{\left(\theta_{2} \right)} & - \sin{\left(\theta_{1} \right)} \sin{\left(\theta_{2} \right)} & - \cos{\left(\theta_{1} \right)} & l_{1} \sin{\left(\theta_{1} \right)}\\\sin{\left(\theta_{2} \right)} & \cos{\left(\theta_{2} \right)} & 0 & 0\\0 & 0 & 0 & 1\end{matrix}\right]$

$\displaystyle {}^0_3T = \left[\begin{matrix}\cos{\left(\theta_{1} \right)} \cos{\left(\theta_{2} + \theta_{3} \right)} & - \sin{\left(\theta_{2} + \theta_{3} \right)} \cos{\left(\theta_{1} \right)} & \sin{\left(\theta_{1} \right)} & \left(l_{1} + l_{2} \cos{\left(\theta_{2} \right)}\right) \cos{\left(\theta_{1} \right)}\\\sin{\left(\theta_{1} \right)} \cos{\left(\theta_{2} + \theta_{3} \right)} & - \sin{\left(\theta_{1} \right)} \sin{\left(\theta_{2} + \theta_{3} \right)} & - \cos{\left(\theta_{1} \right)} & \left(l_{1} + l_{2} \cos{\left(\theta_{2} \right)}\right) \sin{\left(\theta_{1} \right)}\\\sin{\left(\theta_{2} + \theta_{3} \right)} & \cos{\left(\theta_{2} + \theta_{3} \right)} & 0 & l_{2} \sin{\left(\theta_{2} \right)}\\0 & 0 & 0 & 1\end{matrix}\right]$

$\displaystyle {}^0_4T = \left[\begin{matrix}\cos{\left(\theta_{1} \right)} \cos{\left(\theta_{2} + \theta_{3} \right)} & - \sin{\left(\theta_{2} + \theta_{3} \right)} \cos{\left(\theta_{1} \right)} & \sin{\left(\theta_{1} \right)} & \left(l_{1} + l_{2} \cos{\left(\theta_{2} \right)} + l_{3} \cos{\left(\theta_{2} + \theta_{3} \right)}\right) \cos{\left(\theta_{1} \right)}\\\sin{\left(\theta_{1} \right)} \cos{\left(\theta_{2} + \theta_{3} \right)} & - \sin{\left(\theta_{1} \right)} \sin{\left(\theta_{2} + \theta_{3} \right)} & - \cos{\left(\theta_{1} \right)} & \left(l_{1} + l_{2} \cos{\left(\theta_{2} \right)} + l_{3} \cos{\left(\theta_{2} + \theta_{3} \right)}\right) \sin{\left(\theta_{1} \right)}\\\sin{\left(\theta_{2} + \theta_{3} \right)} & \cos{\left(\theta_{2} + \theta_{3} \right)} & 0 & l_{2} \sin{\left(\theta_{2} \right)} + l_{3} \sin{\left(\theta_{2} + \theta_{3} \right)}\\0 & 0 & 0 & 1\end{matrix}\right]$

```python

```


```python
from trikde.pdfs import DensitySamples, IndepdendentLikelihoods, MultivariateNormalPriorHyperCube, CustomPriorHyperCube
import os
from lenslikelihood.measurements import flux_measurements, flux_measurement_uncertainties, all_lens_names, all_param_ranges
import pickle

import numpy as np
from scipy.integrate import quad
import matplotlib.pyplot as plt

```

## Folding new priors into the inference

This notebook demonstrates how to fold in new priors on various hyper-parameters. We'll consider two different priors:

1) a prior on the normalization and slope of the subhalo mass function inferred from stellar streams

2) a prior on the concentration-mass relation based on observations of dwarf galaxies

First, we will load the pre-computed lensing-only likelihoods. These can be computed and saved for later use with the notebook "inference_5D_from_scatch".

```python
nbins = 20
likelihoods, likelihoods_coupled = [], []
param_names = ['LOS_normalization', 'beta', 'c0', 'delta_power_law_index', 'sigma_sub']
param_ranges = [all_param_ranges[name] for name in param_names]
load_from_pickle = True
save_to_pickle = False
filename_extension = '_joint'
filename_extension_coupled = '_joint_coupled'
base_path = './../lenslikelihood/precomputed_likelihoods/'

for lens in all_lens_names:

    fname = base_path + lens + filename_extension
    print('loading joint likelihoods for lens '+lens+' ...')
    f = open(fname, 'rb')
    single_lens_likelihood = pickle.load(f)
    f.close()
    likelihoods.append(single_lens_likelihood)

    fname = base_path + lens + filename_extension_coupled
    f = open(fname, 'rb')
    single_lens_likelihood = pickle.load(f)
    f.close()
    likelihoods_coupled.append(single_lens_likelihood)

likelihood = IndepdendentLikelihoods(likelihoods)
likelihood_coupled = IndepdendentLikelihoods(likelihoods_coupled)
```

    loading joint likelihoods for lens B1422 ...
    loading joint likelihoods for lens HE0435 ...
    loading joint likelihoods for lens WGD2038 ...
    loading joint likelihoods for lens WGDJ0405 ...
    loading joint likelihoods for lens WFI2033 ...
    loading joint likelihoods for lens PSJ1606 ...
    loading joint likelihoods for lens WFI2026 ...
    loading joint likelihoods for lens RXJ0911 ...
    loading joint likelihoods for lens RXJ1131 ...
    loading joint likelihoods for lens MG0414 ...
    loading joint likelihoods for lens PG1115 ...

### Prior on the amplitude and slope of the subhalo mass function inferred from stellar streams https://arxiv.org/pdf/1911.02662.pdf

Banik et al. (2021) used a paramterization for the subhalo mass function

\begin{equation}
\frac{dn}{dm} = a \left(\frac{m}{m_0}\right)^{\alpha} \exp{ \Big\{ -\frac{2}{\alpha_r}\left[\left(\frac{r}{r_{-2}}\right)^{\alpha_r} - 1\right] \Big\} }
\end{equation}

with $m_0 = 2.52 \times 10^7 M_{\odot}$, $\alpha_r = 0.672$, $r_{-2} = 162.4 \rm{kpc}$, and normalization $a = 2.02\times 10^{-13} M_{\odot}^{-1} \rm{kpc^{-3}}$. To map into lensing units $\Sigma_{\rm{sub}}$, we have to project this into two dimensions.

```python
def projected_mass_function(r2d, a=2.02e-13, alpha=-1.9, m0=2.52e+7, r_minus2=162.4,
                            alpha_r=0.672, mlow=10**6, mhigh=10**10):

    def _spatial_integrand(z):
        r = np.sqrt(r2d**2 + z**2)
        x = r/r_minus2
        return np.exp(-(2/alpha_r) * ((x)**alpha_r - 1))

    mass_function_1st_moment = a/(2 + alpha)/m0**alpha * ( mhigh**(2+alpha) - mlow ** (2+alpha))
    spatial_integral = 2 * quad(_spatial_integrand, 0, 100 * r_minus2)[0]

    # returns the projected mass in substructure [M_sun / kpc^2] using the parameterization in Banik et al. 2021
    return spatial_integral * mass_function_1st_moment

def projected_mass_lensing(sigma_sub, m_host, z, mlow=10**6, mhigh=10**10, alpha=-1.9, m0=10**8):

    host_redshift_scaling = 0.88 * np.log10(m_host/10**13) + 1.7 * np.log10(1+z)

    # returns the projected mass in substructure as parameterized in the lensing analysis
    return sigma_sub/(2 + alpha)/m0**(1+alpha) * ( mhigh**(2+alpha) - mlow ** (2+alpha))

r_minus2 = 162.4
r = np.logspace(-1.5, 1, 100) * r_minus2
projected_mass_density = [projected_mass_function(ri) for ri in r]
plt.loglog(r, projected_mass_density)

# since the projected density is almost constant in lenses around the Einstein radius, we will compare to the projected
# mass at r = 0.
print('projected mass at r=0 [M/kpc^2]: ', projected_mass_function(0.))
print('projected mass using lensing parameterization with sigma_sub = 0.0244 [M/kpc^2]: ',
      projected_mass_lensing(0.0244, 1.4 * 10**12, 0.))
```

Using the functions in the cell above, we can propagate the value of sigma_sub inferred from lensing to Milky Way like halos and vice versa. As shown in the cell above, the stellar streams inference on the subhalo mass function cast in terms of the lensing parameterization corresponds to $\Sigma_{\rm{sub}} = 0.0244$, assuming that tidal stripping is equally efficient in the Milky Way compared to massive ellipticls.

If disruption is equally efficient in the Milky Way as in lens halos, then the matching value of sigma_sub would be 0.0244. If twice as many subhalos are disrupted in the MW relative to lens halos, then the matching value would be 0.0488. To see how this information affects the lensing results, we can add a prior on the subhalo mass function inferred from the lenses.

We will also add a prior on the line of sight halo mass function of $\delta_{\rm{LOS}} = 1 \pm 0.2$, corresponding to the Sheth-Tormen halo mass function prediction with an uncertainty of $20\%$.

```python
# Recall the parameter ordering:
# param_names = ['sigma_sub', 'LOS_normalization', 'beta', 'c0', 'delta_power_law_index']

# assumes subhalos are disrupted at the same rate in the Milky Way as in ellipticals
means = [None, None, None, None, 0.025]
sigmas = [None, None, None, None, means[-1] * 0.2]

prior_on_sigma_sub = MultivariateNormalPriorHyperCube(means, sigmas, param_names, param_ranges, nbins)
likelihoods_coupled_with_prior = likelihoods_coupled + [prior_on_sigma_sub]
likelihood_coupled_with_prior = IndepdendentLikelihoods(likelihoods_coupled_with_prior)

# assumes subhalos are disrupted twice as much in the MW as in ellipticals
means = [None, None, None, None, 0.05]
sigmas = [None, None, None, None, means[-1] * 0.2]
prior_on_sigma_sub_2 = MultivariateNormalPriorHyperCube(means, sigmas, param_names, param_ranges, nbins)
likelihoods_coupled_with_prior_2 = likelihoods_coupled + [prior_on_sigma_sub_2]
likelihood_coupled_with_prior_2 = IndepdendentLikelihoods(likelihoods_coupled_with_prior_2)
```

```python
from trikde.triangleplot import TrianglePlot

triangle_plot = TrianglePlot([likelihood, likelihood_coupled_with_prior, likelihood_coupled_with_prior_2])
axes = triangle_plot.make_triplot(filled_contours=True, show_intervals=True, show_contours=True)
```

### Custom priors

To add more complicated priors, we can use a CustomPriorHyperCube object. This class takes as input a function that computes a chi2 for each sample, given the full array of samples in the likelihood. As an example, we'll use this to enforce a prior on the normalization and slope of the concentration-mass relation such that a 10^10 solar mass halo as a concentration of 12.5 plus/minus 4 at z=0.

Note: this cell requires the software package colossus http://www.benediktdiemer.com/code/colossus/

```python
from colossus.halo.concentration import peaks
from colossus.cosmology import cosmology
from trikde.pdfs import CustomPriorHyperCube

kwargs_cosmo = {'H0': 69.7, 'Om0': 0.0464 + 0.235, 'Ob0': 0.0464, 'ns': 0.9608, 'sigma8': 0.82}
cosmology.setCosmology('custom', kwargs_cosmo)
# from pyHalo.Halos.lens_cosmo import LensCosmo
# lc = LensCosmo()
# c = [lc.NFW_concentration(10**10, 0., scatter=True) for i in range(0, 1000)]
# print(np.mean(c), np.std(c))
def anchor_mc_relation_function(samples, log10_m_anchor=10, c_at_anchor=12.5, sigma=4, little_h=0.697):

    c8_samples = samples[:, 2]
    beta_samples = samples[:, 1]
    m_ref = 10 ** 8 * little_h
    m = 10 ** log10_m_anchor * little_h
    nu = peaks.peakHeight(m, z=0)
    nu_ref = peaks.peakHeight(m_ref, z=0)
    nu_ratio = nu/nu_ref
    c_model = c8_samples * nu_ratio ** -beta_samples
    chi2 = (c_at_anchor - c_model)**2/sigma**2
    return chi2

prior_on_mc_relation = CustomPriorHyperCube(anchor_mc_relation_function, param_names, param_ranges, nbins)
likelihoods_coupled_with_prior_on_MC = likelihoods_coupled + [prior_on_mc_relation] + [prior_on_sigma_sub_2]
likelihood_coupled_with_prior_on_MC = IndepdendentLikelihoods(likelihoods_coupled_with_prior_on_MC)
```

```python
from trikde.triangleplot import TrianglePlot

triangle_plot = TrianglePlot([likelihood, likelihood_coupled_with_prior_2, likelihood_coupled_with_prior_on_MC])
axes = triangle_plot.make_triplot(filled_contours=True, show_intervals=True, show_contours=True)
```

Finally, we can experiment with priors on $\beta$, $\Sigma_{\rm{sub}}$, and $\delta_{\rm{LOS}}$.

```python
means = [1., 0.83, 18, None, 2 * 0.0244]
sigmas = [0.2, 0.2, 5., None, 0.005]
prior_on_beta_sigmasub_deltalos = MultivariateNormalPriorHyperCube(means, sigmas, param_names, param_ranges, nbins)
likelihoods_coupled_with_prior_on_beta_and_sigma_sub = likelihoods_coupled + [prior_on_beta_sigmasub_deltalos]
likelihood_coupled_with_prior_on_beta_and_sigma_sub = IndepdendentLikelihoods(likelihoods_coupled_with_prior_on_beta_and_sigma_sub)
```

```python
triangle_plot = TrianglePlot([likelihood, likelihood_coupled_with_prior_2, likelihood_coupled_with_prior_on_MC,
                             likelihood_coupled_with_prior_on_beta_and_sigma_sub])
axes = triangle_plot.make_triplot(filled_contours=True, show_intervals=True, show_contours=True)
```

```python
help(triangle_plot.get_parameter_confidence_interval)
print('bin_width: ', (0.9 + 0.6)/20)
triangle_plot.get_parameter_confidence_interval('delta_power_law_index', 0., chain_num=None, thresh=0.68)
```

    Help on method get_parameter_confidence_interval in module trikde.triangleplot:

    get_parameter_confidence_interval(parameter, clevel, chain_num=None, show_percentage=False, return_intervals=False, print_intervals=True, thresh=None) method of trikde.triangleplot.TrianglePlot instance

    bin_width:  0.075
    parameter name:  delta_power_law_index
    68.0% confidence intervals:
    format: median (lower, upper)

    SAMPLES 1:
    0.024081322738921607 (-0.3153911556133603, 0.3704173319784855)
    width:  0.6858084875918458
    SAMPLES 2:
    0.020086587180486504 (-0.29267100325453127, 0.37902585312776693)
    width:  0.6716968563822983
    SAMPLES 3:
    0.0831535008927971 (-0.26645877355975695, 0.4622652109226838)
    width:  0.7287239844824407
    SAMPLES 4:
    -0.007989553054820464 (-0.3106306511346696, 0.3298346784260081)
    width:  0.6404653295606777

```python
help(triangle_plot.get_parameter_confidence_interval)
print('bin_width: ', (0.9 + 0.6)/20)
triangle_plot.get_parameter_confidence_interval('delta_power_law_index', 0., chain_num=None, thresh=0.95)
```

    Help on method get_parameter_confidence_interval in module trikde.triangleplot:

    get_parameter_confidence_interval(parameter, clevel, chain_num=None, show_percentage=False, return_intervals=False, print_intervals=True, thresh=None) method of trikde.triangleplot.TrianglePlot instance

    bin_width:  0.075
    parameter name:  delta_power_law_index
    95.0% confidence intervals:
    format: median (lower, upper)

    SAMPLES 1:
    0.02549325563736532 (-0.5122086786164712, 0.7060795822472343)
    width:  1.2182882608637056
    SAMPLES 2:
    0.019424909778863575 (-0.5041815911485215, 0.7109876184006901)
    width:  1.2151692095492117
    SAMPLES 3:
    0.08030620479481598 (-0.49443846901385635, 0.7539852296761952)
    width:  1.2484236986900514
    SAMPLES 4:
    -0.008932006243528023 (-0.4948711951909694, 0.6556371529744713)
    width:  1.1505083481654408

```python
triangle_plot = TrianglePlot([likelihood_coupled_with_prior, likelihood_coupled_with_prior_2])
axes = triangle_plot.make_triplot(filled_contours=True, show_intervals=True, show_contours=True,
                                  display_params=['LOS_normalization', 'beta', 'c0', 'delta_power_law_index'])

beta = r'$\beta$'
beta_ticks = [0., 1., 2., 3., 4.]
c0 = r'$c_8$'
c0_ticks = [1, 50, 100, 150, 200]
delta_power_law_index = r'$\Delta \alpha$'
dpli_ticks = [-0.6, -0.3, 0., 0.3, 0.6, 0.9]
sigma_sub = r'$\Sigma_{\rm{sub}}$'
sigma_sub_ticks = [0., 0.025, 0.05, 0.075, 0.1]
delta_LOS = r'$\delta_{\rm{LOS}}$'
dlos_ticks = [0., 0.5, 1., 1.5, 2.]
axes[4].set_ylabel(beta)
axes[4].set_yticks(beta_ticks)
axes[4].set_yticklabels(beta_ticks)

axes[8].set_ylabel(c0)
axes[8].set_yticks(c0_ticks)
axes[8].set_yticklabels(c0_ticks)

axes[12].set_ylabel(delta_power_law_index)
axes[12].set_yticks(dpli_ticks)
axes[12].set_yticklabels(dpli_ticks)

axes[12].set_xlabel(delta_LOS)
axes[12].set_xticks(dlos_ticks)
axes[12].set_xticklabels(dlos_ticks)

axes[13].set_xlabel(beta)
axes[13].set_xticks(beta_ticks)
axes[13].set_xticklabels(beta_ticks)

axes[14].set_xlabel(c0)
axes[14].set_xticks(c0_ticks)
axes[14].set_xticklabels(c0_ticks)

axes[15].set_xlabel(delta_power_law_index)
axes[15].set_xticks(dpli_ticks)
axes[15].set_xticklabels(dpli_ticks)

plt.savefig('pk_inference.pdf')
```

## Coupled subhalo and field halo mass functions

A reasonable assumption to impose on the inference is that the number of subhalos varies proportionally with the number of field halos, since subhalos are accreted from the field. We can enforce this by choosing an expected amplitude for the subhalo mass function in $\Lambda$CDM, and then coupling variations to $\Sigma_{\rm{sub}}$ around this value to $\delta_{\rm{LOS}}$.

```python
def couple_mass_functions(samples, sigma_sub_theory=0.05, coupling_strength=0.2):

    delta_los_samples = samples[:, 0]
    sigma_sub_samples = samples[:, -1]
    delta_sigma_sub = sigma_sub_samples/sigma_sub_theory
    chi2 = (delta_sigma_sub - delta_los_samples)**2/coupling_strength**2
    return chi2

kwargs_1 = {'sigma_sub_theory': 0.05}
kwargs_2 = {'sigma_sub_theory': 0.025}
prior_on_mass_functions_1 = CustomPriorHyperCube(couple_mass_functions, param_names, param_ranges, nbins, kwargs_1)
prior_on_mass_functions_2 = CustomPriorHyperCube(couple_mass_functions, param_names, param_ranges, nbins, kwargs_2)

likelihoods_coupled_with_prior_mass_functions = likelihoods_coupled + [prior_on_mass_functions_1]
likelihood_coupled_with_prior_mass_functions_1 = IndepdendentLikelihoods(likelihoods_coupled_with_prior_mass_functions)
likelihoods_coupled_with_prior_mass_functions = likelihoods_coupled + [prior_on_mass_functions_2]
likelihood_coupled_with_prior_mass_functions_2 = IndepdendentLikelihoods(likelihoods_coupled_with_prior_mass_functions)
```

```python
triangle_plot = TrianglePlot([likelihood_coupled_with_prior_mass_functions_1, likelihood_coupled_with_prior_mass_functions_2])
axes = triangle_plot.make_triplot(filled_contours=True, show_intervals=True)
```

```python

```


# Sparse sampling

$$
\newcommand{\wmax}{{\omega_\mathrm{max}}}
\newcommand{\dd}{{\mathrm{d}}}
\newcommand{\ii}{{\mathrm{i}}}
\newcommand{\iv}{{\mathrm{i}\nu}}
\newcommand{\tauk}{{\bar{\tau}_k}}
\newcommand{\wk}{{\bar{\omega}^\alpha_k}}
\newcommand{\vk}{{\bar{\nu}_k}}
\newcommand{\hatFmat}{\hat{\mathbf{F}}}
\newcommand{\Fmat}{{\mathbf{F}}}
$$

We consider how to compute the $L$ expansion coefficients of a Green's function in IR from its numerical values known on a finite set of imaginary times or imaginary frequencies.
The idea of the sparse sampling is simple.
If we want to determine $L$ coefficients $G_l$,
we need to know $G(\tau)$ on only up to $L$ sampling points.
Let $\bar{\tau}_1 < \cdots < \bar{\tau}_{N_\mathrm{smp}}$ ($N_\mathrm{smpl}\ge L$) be such sampling points,
one can evaluate the expansion coefficients as

$$
\begin{align}
    g_l &= \underset{g_l}{\mathrm{argmin}}
        \sum_k \bigg| G(\tauk) - \sum_{l=0}^{N_\mathrm{smp}-1} U_l(\tauk)G_l \bigg|^2\nonumber \\
    &= (\Fmat^+ \boldsymbol{g})_l,
\end{align}
$$

where we define $(\Fmat)_{kl} = U_l(\tauk)$ and $\Fmat^+$ is its pseudo inverse.
The numerical stability of this ``fitting'' scheme is determined
by the condition number of the coefficient matrix $\Fmat$.
An empiritically good choice is to use the middle points of the extrema of
$U'_{L-1}(\tau)$.
From experience, the fitting can be done in the most stable way using SVD of the fitting matrix (already implemented in *sparse-ir*).

The following figure shows the sampling points in the imaginary-time domain generated for $\beta=10$ and $\wmax=10$ as well as the basis function $U_{L-1}(\tau)$ with $L=30$.

```
import sparse_ir
import numpy as np
%matplotlib inline
import matplotlib.pyplot as plt
plt.rcParams['font.size'] = 15

beta = 10
wmax = 10
basis = sparse_ir.FiniteTempBasis("F", beta, wmax, eps=1e-10)

smpl = sparse_ir.TauSampling(basis)

taus = np.linspace(0, beta, 1000)
plt.plot(taus, basis.u[-1](taus))
plt.plot(taus, np.zeros_like(taus), ls='-', color='k', marker='', lw=0.5)
for x in smpl.sampling_points:
    plt.axvline(x=x, ls='--', lw=0.5, color='k')
plt.xlabel(r'$\tau$')
plt.ylabel(r'$U_l(\tau)$')
plt.xlim([0, beta])
plt.tight_layout()
```

One can transform numerical data in the Matsubara-frequency domain using sparse-sampling techniques {cite}`Li:2020eu`.
The IR basis functions $\hat U_l(\iv)$ are purely imaginary/real for even/odd $l$.
Although the basis functions $U_l(\iv)$ are defined only on discrete points,
the respective parts have sign structures similarly to those of $U_l(\tau)$.
The figure shown below shows the basis functions for the largest $l$
as well as the corresponding sampling frequencies.
A sign change generally takes place between two adjacent Matsubara frequencies.
A segment between two adjacent "roots" contains at least one sampling points.
In the upper panel, you can see that the last segment at high frequencies contains two sampling points.
This improves the condition number slighly.

Let $\bar{\nu}_1 < \cdots < \bar{\nu}_{N_\mathrm{smp}}$ ($N_\mathrm{smpl}\ge L$)
be such sampling frequencies,
one can evaluate the expansion coefficients as

$$
\begin{align}
    g_l &= \underset{g_l}{\mathrm{argmin}}
        \sum_k \bigg| \hat G(\ii\vk) - \sum_{l=0}^{N_\mathrm{smp}-1} \hat{U}_l(\ii\vk)G_l \bigg|^2\nonumber \\
    &= (\hat{\Fmat}^+ \boldsymbol{g})_l.
\end{align}
$$

The following figure shows the sampling points in the imaginary-frequency domain generated for $\beta=10$ and $\wmax=10$.

```
smpl_matsu = sparse_ir.MatsubaraSampling(basis)

get_nonzero_part = lambda l, data: data.imag if l%2==0 else data.real

fig, ax = plt.subplots(figsize=(10,5))

nmax = int(2*(smpl_matsu.sampling_points.max()//2))
nvec = 2*np.arange(-nmax,nmax)+1
vvec = nvec * np.pi/beta
markers = ["o", "s"]
for idx_l, l in enumerate([9, basis.size-1]):
    ax.plot(vvec, get_nonzero_part(l, basis.uhat[l](nvec)),
        label=f"$l={l}$", ls="-", marker=markers[idx_l], ms=6, lw=0.5)

for n in smpl_matsu.sampling_points:
    ax.axvline(n*np.pi/beta, ls="--", lw=0.5, color="k")
    ax.axhline(0, ls="-", lw=0.25, color="k")

ax.legend(frameon=False)
ax.set_xlabel(r"$\nu$")
ax.set_ylabel(r"Re/Im $G(\mathrm{i}\nu)$")

ax.set_xlim([0.5*np.pi/beta, None])
ax.set_ylim([-10, 10])
ax.set_xscale("log")
ax.set_yscale("symlog", linthresh=1e-6)

plt.tight_layout()
```

## Condition number
The condition number of the fitting scales roughly as $\sqrt{\Lambda}$.

```
cond_tau = []
cond_matsu = []
lambdas = [1e+1, 1e+2, 1e+3, 1e+4, 1e+5]
for lambda_ in lambdas:
    basis = sparse_ir.FiniteTempBasis("F", beta, lambda_/beta, eps=1e-15)
    cond_tau.append(sparse_ir.TauSampling(basis).cond)
    cond_matsu.append(sparse_ir.MatsubaraSampling(basis).cond)
```

```
plt.loglog(lambdas, cond_tau, marker="o", label="time")
plt.loglog(lambdas, cond_matsu, marker="x", label="frequency")
plt.loglog(lambdas, np.sqrt(lambdas), marker="", ls="--", label=r"$\sqrt{\Lambda}$")
plt.xlabel(r"$\Lambda$")
plt.ylabel("Condition number")
plt.legend(frameon=False)
plt.show()
```