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

from sympy import *
from mpmath import degrees, radians

init_printing()
```

This derivation is based on the USGS Professional Paper 1395:
[Map Projections — A Working Manual](http://pubs.usgs.gov/pp/1395/report.pdf), ch 14, pp 98 – 103.

```
radius = Symbol('R')
semimajor, ellipticity = symbols('a e', positive=True)
standard_parallel_1, standard_parallel_2 = symbols('phi_1 phi_2')
central_lat, central_lon = symbols('phi_0 lambda_0')
point_lat, point_lon = symbols('phi lambda')

radius, semimajor, ellipticity, \
standard_parallel_1, standard_parallel_2, \
central_lat, central_lon, \
point_lat, point_lon
```

```
def subdict(eq, vals):
    for k, v in vals.items():
        eq = eq.subs(k, v)
    return eq
```

## Formulas for the Sphere

NOTE: Formulas are defined in reverse (mostly) from the paper, so that
they can build on each other.

```
# Eq. 14-6
ns = (sin(standard_parallel_1) + sin(standard_parallel_2)) / 2
ns
```

```
# Eq. 14-5
Cs = cos(standard_parallel_1)**2 + 2 * ns * sin(standard_parallel_1)
Cs
```

```
# Eq. 14-3a
rho0s = radius * (Cs - 2 * ns * sin(central_lat))**0.5 / ns
rho0s
```

```
# Eq. 14-4
thetas = ns * (point_lon - central_lon)
thetas
```

```
# Eq. 14-3
rhos = radius * (Cs - 2 * ns * sin(point_lat))**0.5 / ns
rhos
```

```
# Eq. 14-1
xs = rhos * sin(thetas)
# Eq. 14-2
ys = rho0s - rhos * cos(thetas)
```

```
xs
```

```
ys
```

## Formulas for the Ellipsoid

```
# Eq. 14-15
m = cos(point_lat) / (1 - ellipticity**2 * sin(point_lat)**2)**0.5
m1 = cos(standard_parallel_1) / (1 - ellipticity**2 * sin(standard_parallel_1)**2)**0.5
m2 = cos(standard_parallel_2) / (1 - ellipticity**2 * sin(standard_parallel_2)**2)**0.5
m, m1, m2
```

```
# Eq 3-12
q = (1 - ellipticity**2) * (
    sin(point_lat) / (1 - ellipticity**2 * sin(point_lat)**2) -
    1 / (2 * ellipticity) * log(
        (1 - ellipticity * sin(point_lat)) /
        (1 + ellipticity * sin(point_lat))
        )
    )
q0 = (1 - ellipticity**2) * (
    sin(central_lat) / (1 - ellipticity**2 * sin(central_lat)**2) -
    1 / (2 * ellipticity) * log(
        (1 - ellipticity * sin(central_lat)) /
        (1 + ellipticity * sin(central_lat))
        )
    )
q1 = (1 - ellipticity**2) * (
    sin(standard_parallel_1) / (1 - ellipticity**2 * sin(standard_parallel_1)**2) -
    1 / (2 * ellipticity) * log(
        (1 - ellipticity * sin(standard_parallel_1)) /
        (1 + ellipticity * sin(standard_parallel_1))
        )
    )
q2 = (1 - ellipticity**2) * (
    sin(standard_parallel_2) / (1 - ellipticity**2 * sin(standard_parallel_2)**2) -
    1 / (2 * ellipticity) * log(
        (1 - ellipticity * sin(standard_parallel_2)) /
        (1 + ellipticity * sin(standard_parallel_2))
        )
    )

q, q0, q1, q2
```

```
# Eq. 14-14
ne = (m1**2 - m2**2) / (q2 - q1)
ne
```

```
# Eq. 14-13
Ce = m1**2 + ne * q1
Ce
```

```
# Eq. 14-12a
rho0e = semimajor * (Ce - ne * q0)**0.5 / ne
rho0e
```

```
# Eq. 14-4
thetae = ne * (point_lon - central_lon)
thetae
```

```
# Eq. 14-12
rhoe = semimajor * (Ce - ne * q)**0.5 / ne
rhoe
```

```
xe = rhoe * sin(thetae)
ye = rho0e - rhoe * cos(thetae)
```

I'm not even going to bother printing these two. They're long and it's not important.

# Cartopy Boundaries

```
# Default Globe in Cartopy
WGS84_semimajor = Rational(6378137)
WGS84_flattening = Rational(1, 298.257223563)
# Plotting fails if I leave this bit as exact, thinking there are complex values,
# even though the input to sqrt is positive, and it's supposed to return the positive root.
WGS84_ellipticity = sqrt(2 * WGS84_flattening - WGS84_flattening**2).evalf()

# Default parameters in Cartopy
lat0_default = radians(0)
lon0_default = radians(0)
stp1_default = radians(20)
stp2_default = radians(50)
```

## Ellipsoid

### Default - WGS84

```
ellipsoid_vars = {
    semimajor: WGS84_semimajor,
    ellipticity: WGS84_ellipticity,
    standard_parallel_1: stp1_default,
    standard_parallel_2: stp2_default,
    central_lat: lat0_default,
    central_lon: lon0_default
}
```

I know the outer boundary should be something like the following based on existing plots.

```
lat_min_ellipsoid = radians(lat0_default - 90)
lat_max_ellipsoid = radians(lat0_default + 90)
lon_min_ellipsoid = radians(lon0_default - 180)
lon_max_ellipsoid = radians(lon0_default + 180)

default_xe = subdict(xe, ellipsoid_vars)
eq1x = default_xe.subs(point_lat, lat_max_ellipsoid)
eq2x = default_xe.subs(point_lon, lon_max_ellipsoid)
eq3x = default_xe.subs(point_lat, lat_min_ellipsoid)
eq4x = default_xe.subs(point_lon, lon_min_ellipsoid)

default_ye = subdict(ye, ellipsoid_vars)
eq1y = default_ye.subs(point_lat, lat_max_ellipsoid)
eq2y = default_ye.subs(point_lon, lon_max_ellipsoid)
eq3y = default_ye.subs(point_lat, lat_min_ellipsoid)
eq4y = default_ye.subs(point_lon, lon_min_ellipsoid)

plotting.plot_parametric((eq1x, eq1y, (point_lon, lon_min_ellipsoid, lon_max_ellipsoid)),
                         (eq2x, eq2y, (point_lat, lat_max_ellipsoid, lat_min_ellipsoid)),
                         (eq3x, eq3y, (point_lon, lon_max_ellipsoid, lon_min_ellipsoid)),
                         (eq4x, eq4y, (point_lat, lat_min_ellipsoid, lat_max_ellipsoid)),
                         title='Ellipsoid - WGS84 - Guessed Boundaries')
```

```
lat_magnitude_ellipsoid = subdict((point_lat - central_lat) / radians(90), ellipsoid_vars)
lon_magnitude_ellipsoid = subdict((point_lon - central_lon) / radians(180), ellipsoid_vars)

plotting.plot3d_parametric_surface(default_xe, default_ye, lat_magnitude_ellipsoid,
                                   (point_lat, lat_min_ellipsoid, lat_max_ellipsoid),
                                   (point_lon, lon_min_ellipsoid, lon_max_ellipsoid),
                                   xlabel='X', ylabel='Y',
                                   title='Ellipsoid - WGS84 - Latitude Dependence')
plotting.plot3d_parametric_surface(default_xe, default_ye, lon_magnitude_ellipsoid,
                                   (point_lat, lat_min_ellipsoid, lat_max_ellipsoid),
                                   (point_lon, lon_min_ellipsoid, lon_max_ellipsoid),
                                   xlabel='X', ylabel='Y',
                                   title='Ellipsoid - WGS84 - Longitude Dependence')
```

From the above plot, it seems pretty clear that the boundaries match what I guessed above.

However, since the extrema in the $x$ direction is _not_ at the longitudinal limit,
we need to take a derivative and find the zeros.

```
xe_deriv = diff(xe, point_lon)
xe_deriv
```

```
xe_ds = subdict(xe_deriv, ellipsoid_vars)
# Known latitude for outer ring
xe_ds = xe_ds.subs(point_lat, radians(-90))
```

```
extrema_ellipsoid = solve(xe_ds, point_lon)
extrema_ellipsoid
```

```
extrema_ellipsoid = [degrees(_) for _ in extrema_ellipsoid]
extrema_ellipsoid
```

```
xmin = subdict(xe, ellipsoid_vars)
# Calculated minimum location
xmin = xmin.subs(point_lat, radians(-90)).subs(point_lon, radians(-extrema_ellipsoid[0]))
xmin.evalf()
```

```
xmax = subdict(xe, ellipsoid_vars)
# Calculated maximum location
xmax = xmax.subs(point_lat, radians(-90)).subs(point_lon, radians(extrema_ellipsoid[0]))
xmax.evalf()
```

```
ymin = subdict(ye, ellipsoid_vars)
# Known minimum location from plot
ymin = ymin.subs(point_lat, radians(-90)).subs(point_lon, radians(0))
ymin.evalf()
```

```
ymax = subdict(ye, ellipsoid_vars)
# Known maximum location from plot
ymax = ymax.subs(point_lat, radians(-90)).subs(point_lon, radians(180))
ymax.evalf()
```

I want to find how many points to split the longitude range into so that
it comes really close to this extrema.

```
f = plt.figure(figsize=(16, 16))
for n in range(1, 181, 2):
    lons = np.linspace(-180, 180, n)
    plt.plot(lons, n * np.ones(n), c='b')
    plt.scatter(lons, n * np.ones(n), c='b')
    index = np.argmin(abs(lons - extrema_ellipsoid[0]))
    plt.scatter(lons[index], n, c='r')
plt.axvline(extrema_ellipsoid[0], c='g', ls='--')
plt.xlim(150, 180)
plt.xlabel('Longitude')
plt.ylim(0, 180)
plt.ylabel('Number of Points')
plt.title('Longitude Sampling for a Stipulated Number of Points')
plt.show()
```

It seems like some good choices are 83, 103, 123, or 165.

```
lons = np.linspace(-180, 180, 103)
index = np.argmin(abs(lons - extrema_ellipsoid[0]))
lons[index-1:index+2], abs(lons[index-1:index+2] - extrema_ellipsoid[0])
```

    (array([ 158.82352941,  162.35294118,  165.88235294]),
     array([3.55657028545752, 0.0271585207516694, 3.50225324395424], dtype=object))

### Eccentric Globe

```
a = Rational(1000)
b = Rational(500)
f = (a - b) / a
eccentric_globe_vars = {
    semimajor: a,
    ellipticity: sqrt(2 * f - f**2).evalf(),
    standard_parallel_1: stp1_default,
    standard_parallel_2: stp2_default,
    central_lat: lat0_default,
    central_lon: lon0_default
}
```

```
lat_min_eccentric_globe = radians(lat0_default - 90)
lat_max_eccentric_globe = radians(lat0_default + 90)
lon_min_eccentric_globe = radians(lon0_default - 180)
lon_max_eccentric_globe = radians(lon0_default + 180)

eccentric_xe = subdict(xe, eccentric_globe_vars)
eq1x = eccentric_xe.subs(point_lat, lat_max_eccentric_globe)
eq2x = eccentric_xe.subs(point_lon, lon_max_eccentric_globe)
eq3x = eccentric_xe.subs(point_lat, lat_min_eccentric_globe)
eq4x = eccentric_xe.subs(point_lon, lon_min_eccentric_globe)

eccentric_ye = subdict(ye, eccentric_globe_vars)
eq1y = eccentric_ye.subs(point_lat, lat_max_eccentric_globe)
eq2y = eccentric_ye.subs(point_lon, lon_max_eccentric_globe)
eq3y = eccentric_ye.subs(point_lat, lat_min_eccentric_globe)
eq4y = eccentric_ye.subs(point_lon, lon_min_eccentric_globe)

plotting.plot_parametric((eq1x, eq1y, (point_lon, lon_min_eccentric_globe, lon_max_eccentric_globe)),
                         (eq2x, eq2y, (point_lat, lat_max_eccentric_globe, lat_min_eccentric_globe)),
                         (eq3x, eq3y, (point_lon, lon_max_eccentric_globe, lon_min_eccentric_globe)),
                         (eq4x, eq4y, (point_lat, lat_min_eccentric_globe, lat_max_eccentric_globe)),
                         title='Ellipsoid - Eccentric Globe - Guessed Boundaries')
```

```
lat_magnitude_eccentric_globe = subdict((point_lat - central_lat) / radians(90), eccentric_globe_vars)
lon_magnitude_eccentric_globe = subdict((point_lon - central_lon) / radians(180), eccentric_globe_vars)

plotting.plot3d_parametric_surface(eccentric_xe, eccentric_ye, lat_magnitude_eccentric_globe,
                                   (point_lat, lat_min_eccentric_globe, lat_max_eccentric_globe),
                                   (point_lon, lon_min_eccentric_globe, lon_max_eccentric_globe),
                                   xlabel='X', ylabel='Y',
                                   title='Ellipsoid - Eccentric Globe - Latitude Dependence')
plotting.plot3d_parametric_surface(eccentric_xe, eccentric_ye, lon_magnitude_ellipsoid,
                                   (point_lat, lat_min_eccentric_globe, lat_max_eccentric_globe),
                                   (point_lon, lon_min_eccentric_globe, lon_max_eccentric_globe),
                                   xlabel='X', ylabel='Y',
                                   title='Ellipsoid - Eccentric Globe - Longitude Dependence')
```

It appears for the eccentric globe, a very similar boundary will occur.

```
xe_deg = subdict(xe_deriv, ellipsoid_vars)
# Known latitude for outer ring
xe_deg = xe_deg.subs(point_lat, radians(-90))
```

```
extrema_eccentric_globe = solve(xe_deg, point_lon)
extrema_eccentric_globe
```

```
extrema_eccentric_globe = [degrees(_) for _ in extrema_eccentric_globe]
extrema_eccentric_globe
```

These are (unsurprisingly) at the same place!

```
xmin = subdict(xe, eccentric_globe_vars)
# Calculated minimum location
xmin = xmin.subs(point_lat, radians(-90)).subs(point_lon, radians(-extrema_eccentric_globe[0]))
xmin.evalf()
```

```
xmax = subdict(xe, eccentric_globe_vars)
# Calculated maximum location
xmax = xmax.subs(point_lat, radians(-90)).subs(point_lon, radians(extrema_eccentric_globe[0]))
xmax.evalf()
```

```
ymin = subdict(ye, eccentric_globe_vars)
# Known minimum location from plot
ymin = ymin.subs(point_lat, radians(-90)).subs(point_lon, radians(0))
ymin.evalf()
```

```
ymax = subdict(ye, eccentric_globe_vars)
# Known maximum location from plot
ymax = ymax.subs(point_lat, radians(-90)).subs(point_lon, radians(180))
ymax.evalf()
```

```
ymax = subdict(ye, eccentric_globe_vars)
# Known maximum location from plot
ymax = ymax.subs(point_lat, radians(-90)).subs(point_lon, radians(180))
ymax.evalf()
```

### Clarke 1866

```
# See example on pg 292
clarke1866_lat0 = 23
clarke1866_lon0 = -96
clarke1866_vars = {
    semimajor: 6378206.4,
    ellipticity: 0.0822719,
    standard_parallel_1: radians(29 + 30 / 60),
    standard_parallel_2: radians(45 + 30 / 60),
    central_lat: radians(clarke1866_lat0),
    central_lon: radians(clarke1866_lon0)
}
```

```
lat_min_clarke1866 = radians(-90)
lat_max_clarke1866 = radians(+90)
lon_min_clarke1866 = radians(clarke1866_lon0 - 180)
lon_max_clarke1866 = radians(clarke1866_lon0 + 180)

clarke1866_xe = subdict(xe, clarke1866_vars)
eq1x = clarke1866_xe.subs(point_lat, lat_max_clarke1866)
eq2x = clarke1866_xe.subs(point_lon, lon_max_clarke1866)
eq3x = clarke1866_xe.subs(point_lat, lat_min_clarke1866)
eq4x = clarke1866_xe.subs(point_lon, lon_min_clarke1866)

clarke1866_ye = subdict(ye, clarke1866_vars)
eq1y = clarke1866_ye.subs(point_lat, lat_max_clarke1866)
eq2y = clarke1866_ye.subs(point_lon, lon_max_clarke1866)
eq3y = clarke1866_ye.subs(point_lat, lat_min_clarke1866)
eq4y = clarke1866_ye.subs(point_lon, lon_min_clarke1866)

plotting.plot_parametric((eq1x, eq1y, (point_lon, lon_min_clarke1866, lon_max_clarke1866)),
                         (eq2x, eq2y, (point_lat, lat_max_clarke1866, lat_min_clarke1866)),
                         (eq3x, eq3y, (point_lon, lon_max_clarke1866, lon_min_clarke1866)),
                         (eq4x, eq4y, (point_lat, lat_min_clarke1866, lat_max_clarke1866)),
                         title='Ellipsoid - Clarke 1866 - Guessed Boundaries')
```

```
lat_magnitude_clarke1866 = subdict((point_lat - central_lat) / radians(90), clarke1866_vars)
lon_magnitude_clarke1866 = subdict((point_lon - central_lon) / radians(180), clarke1866_vars)

plotting.plot3d_parametric_surface(clarke1866_xe, clarke1866_ye, lat_magnitude_clarke1866,
                                   (point_lat, lat_min_clarke1866, lat_max_clarke1866),
                                   (point_lon, lon_min_clarke1866, lon_max_clarke1866),
                                   xlabel='X', ylabel='Y',
                                   title='Ellipsoid - Clarke 1866 - Latitude Dependence')
plotting.plot3d_parametric_surface(clarke1866_xe, clarke1866_ye, lon_magnitude_clarke1866,
                                   (point_lat, lat_min_clarke1866, lat_max_clarke1866),
                                   (point_lon, lon_min_clarke1866, lon_max_clarke1866),
                                   xlabel='X', ylabel='Y',
                                   title='Ellipsoid - Clarke 1866 - Longitude Dependence')
```

It appears for Clarke 1866, a very similar boundary will occur once again.

```
xe_dc = subdict(xe_deriv, clarke1866_vars)
# Known latitude for outer ring
xe_dc = xe_dc.subs(point_lat, radians(-90))
```

```
extrema_clarke1866 = solve(xe_dc, point_lon)
extrema_clarke1866
```

```
extrema_clarke1866 = [degrees(_) for _ in extrema_clarke1866]
extrema_clarke1866
```

```
xmin = subdict(xe, clarke1866_vars)
# Calculated minimum location
xmin = xmin.subs(point_lat, radians(-90)).subs(point_lon, radians(extrema_clarke1866[1]))
xmin.evalf()
```

```
xmax = subdict(xe, clarke1866_vars)
# Calculated maximum location
xmax = xmax.subs(point_lat, radians(-90)).subs(point_lon, radians(extrema_clarke1866[0]))
xmax.evalf()
```

```
ymin = subdict(ye, clarke1866_vars)
# Known minimum location from plot
ymin = ymin.subs(point_lat, radians(-90)).subs(point_lon, radians(clarke1866_lon0))
ymin.evalf()
```

```
ymax = subdict(ye, clarke1866_vars)
# Known maximum location from plot
ymax = ymax.subs(point_lat, radians(-90)).subs(point_lon, lon_max_clarke1866)
ymax.evalf()
```

## Sphere

```
# From numerical examples, pg 291
radius_sphere = 1.0
stp1_sphere = 29 + 30 / 60
stp2_sphere = 45 + 30 / 60
lat0_sphere = 23
lon0_sphere = -96

sphere_vars = {radius: radius_sphere,
               standard_parallel_1: radians(stp1_sphere),
               standard_parallel_2: radians(stp2_sphere),
               central_lat: radians(lat0_sphere),
               central_lon: radians(lon0_sphere)}
```

```
lon_min_sphere = radians(lon0_sphere - 180)
lon_max_sphere = radians(lon0_sphere + 180)
# Interestingly, the boundary does not appear to depend on central latitude!
lat_min_sphere = radians(-90)
lat_max_sphere = radians(+90)

sphere_xs = subdict(xs, sphere_vars)
eq1x = sphere_xs.subs(point_lat, lat_max_sphere)
eq2x = sphere_xs.subs(point_lon, lon_max_sphere)
eq3x = sphere_xs.subs(point_lat, lat_min_sphere)
eq4x = sphere_xs.subs(point_lon, lon_min_sphere)

sphere_ys = subdict(ys, sphere_vars)
eq1y = sphere_ys.subs(point_lat, lat_max_sphere)
eq2y = sphere_ys.subs(point_lon, lon_max_sphere)
eq3y = sphere_ys.subs(point_lat, lat_min_sphere)
eq4y = sphere_ys.subs(point_lon, lon_min_sphere)

plotting.plot_parametric((eq1x, eq1y, (point_lon, lon_min_sphere, lon_max_sphere)),
                         (eq2x, eq2y, (point_lat, lat_max_sphere, lat_min_sphere)),
                         (eq3x, eq3y, (point_lon, lon_max_sphere, lon_min_sphere)),
                         (eq4x, eq4y, (point_lat, lat_min_sphere, lat_max_sphere)),
                         title='Sphere - Guessed Boundaries')
```

Evidently, the boundary does _not_ depend on central latitude.
However, central longitude does seem to make a difference.

```
lat_magnitude = subdict((point_lat - central_lat) / radians(90), sphere_vars)
lon_magnitude = subdict((point_lon - central_lon) / radians(180), sphere_vars)

plotting.plot3d_parametric_surface(sphere_xs, sphere_ys, lat_magnitude,
                                   (point_lat, lat_min_sphere, lat_max_sphere),
                                   (point_lon, lon_min_sphere, lon_max_sphere),
                                   xlabel='X', ylabel='Y',
                                   title='Sphere - Latitude Dependence')

plotting.plot3d_parametric_surface(sphere_xs, sphere_ys, lon_magnitude,
                                   (point_lat, lat_min_sphere, lat_max_sphere),
                                   (point_lon, lon_min_sphere, lon_max_sphere),
                                   xlabel='X', ylabel='Y',
                                   title='Sphere - Longitude Dependence')
```

The first plot above points to the location of the minimum and maximum latitudes.
The second plot is for the longitudes.
Clearly the outer boundary occurs at the minimum of the latitude ($-90^\circ$!), and all along the longitudes.

Since the extrema in the $x$ direction is _not_ at the longitudinal limit,
we need to take a derivative and find the zeros.

```
xs_deriv = diff(xs, point_lon)
xs_deriv
```

```
xs_ds = subdict(xs_deriv, sphere_vars)
# Known latitude for outer ring
xs_ds = xs_ds.subs(point_lat, lat_min_sphere)
xs_ds
```

```
extrema_sphere = solve(xs_ds, point_lon)
extrema_sphere
```

```
extrema_sphere = [degrees(_) for _ in extrema_sphere]
extrema_sphere
```

```
xmin = subdict(xs, sphere_vars)
# Calculated minimum location
xmin = xmin.subs(point_lat, lat_min_sphere).subs(point_lon, radians(-extrema_sphere[0]))
xmin.evalf()
```

```
xmax = subdict(xs, sphere_vars)
# Calculated maximum location
xmax = xmax.subs(point_lat, lat_min_sphere).subs(point_lon, radians(extrema_sphere[0]))
xmax.evalf()
```

Obviously, something has gone wrong here. Maybe by trying the other extrema,
the result will make some sense.

```
xmin = subdict(xs, sphere_vars)
# Calculated minimum location
xmin = xmin.subs(point_lat, lat_min_sphere).subs(point_lon, radians(-extrema_sphere[1]))
xmin.evalf()
```

```
xmax = subdict(xs, sphere_vars)
# Calculated maximum location
xmax = xmax.subs(point_lat, lat_min_sphere).subs(point_lon, radians(extrema_sphere[1]))
xmax.evalf()
```

Unlike the ellipsoid cases above, it appears both roots were needed to see the
full limits. This difference is likely due to the central longitude not being
zero, and therefore the roots are not symmetric.

```
ymin = subdict(ys, sphere_vars)
# Known minimum location from plot
ymin = ymin.subs(point_lat, lat_min_sphere).subs(point_lon, radians(lon0_sphere))
ymin.evalf()
```

```
ymax = subdict(ys, sphere_vars)
# Known maximum location from plot
ymax = ymax.subs(point_lat, lat_min_sphere).subs(point_lon, lon_max_sphere)
ymax.evalf()
```


###### Content under Creative Commons Attribution license CC-BY 4.0, code under MIT license (c)2014 L.A. Barba, C.D. Cooper, G.F. Forsyth.

# Spreading out

Welcome to the fifth, and last, notebook of Module 4 "_Spreading out: diffusion problems,"_ of our fabulous course **"Practical Numerical Methods with Python."**

In this course module, we have learned about explicit and implicit methods for parabolic equations in 1 and 2 dimensions. So far, all schemes have been first-order in time and second-order in space. _Can we do any better?_ We certainly can: this notebook presents the Crank-Nicolson scheme, which is a second-order method in both time and space! We will continue to use the heat equation to guide the discussion, as we've done throughout this module.

## Crank-Nicolson scheme

The [Crank Nicolson scheme](http://en.wikipedia.org/wiki/Crank–Nicolson_method) is a popular second-order, implicit method used with parabolic PDEs in particular. It was developed by John Crank and [Phyllis Nicolson](http://en.wikipedia.org/wiki/Phyllis_Nicolson). The main idea is to take the average between the solutions at $t^n$ and $t^{n+1}$ in the evaluation of the spatial derivative. Why bother doing that? Because the time derivative will then be discretized with a centered scheme, giving second-order accuracy!

Remember the 1D heat equation from the [first notebook](https://github.com/numerical-mooc/numerical-mooc/blob/master/lessons/04_spreadout/01_Heat_Equation_1D_Explicit.ipynb)? Just to refresh your memory, here it is:

\begin{equation}
\frac{\partial T}{\partial t} = \alpha \frac{\partial^2T}{\partial x^2}.
\end{equation}

In this case, the Crank-Nicolson scheme leads to the following discretized equation:

\begin{eqnarray}
\frac{T^{n+1}_i - T^n_i}{\Delta t} = & \nonumber \\
\alpha \cdot \frac{1}{2} &\left(
\frac{T^{n+1}_{i+1} - 2T^{n+1}_i + T^{n+1}_{i-1}}{\Delta x^2}
+ \frac{T^n_{i+1}-2T^n_i + T^n_{i-1}}{\Delta x^2}
\right)
\end{eqnarray}

Notice how the both time indices $n$ and $n+1$ appear on the right-hand side. You know we'll have to rearrange this equation, right? Now look at the stencil and notice that we are using more information than before in the update.

#### Figure 2. Stencil of the Crank-Nicolson scheme.

Rearraning terms so that everything that we don't know is on the left side and what we do know on the right side, we get

\begin{eqnarray}
-T^{n+1}_{i-1} + 2\left(\frac{\Delta x^2}{\alpha\Delta t}+1\right)T^{n+1}_i - T^{n+1}_{i+1} = & \nonumber\\
T^{n}_{i-1} + & 2\left(\frac{\Delta x^2}{\alpha\Delta t}-1\right)T^{n}_i + T^{n}_{i+1}
\end{eqnarray}

Again, we are left with a linear system of equations. Check out the left side of that equation: it looks a lot like the matrix from [notebook 2](https://github.com/numerical-mooc/numerical-mooc/blob/master/lessons/04_spreadout/02_Heat_Equation_1D_Implicit.ipynb), doesn't it? Apart from the slight modification in the $T_i^{n+1}$ term, the left side of the equation is pretty much the same. What about the right-hand side? Sure, it looks quite different, but that is not a problem, we know all those terms!

Things don't change much for boundary conditions, either. We've seen all the cases already. Say $T_0^{n+1}$ is a Dirichlet boundary. Then the equation for $i=1$ becomes

\begin{eqnarray}
 2\left(\frac{\Delta x^2}{\alpha\Delta t}+1\right)T^{n+1}_1 - T^{n+1}_{2} = & \nonumber\\
 T^{n}_{0} + & 2\left(\frac{\Delta x^2}{\alpha\Delta t}-1\right)T^{n}_1 + T^{n}_{2} + T^{n+1}_{0}
\end{eqnarray}

And if we have a Neumann boundary $\left(\left.\frac{\partial T}{\partial x}\right|_{x=L} = q\right)$ at $T_{n_x-1}^{n+1}$? We know this stuff, right? For $i=n_x-2$ we get

\begin{eqnarray}
-T^{n+1}_{n_x-3} + \left(2\frac{\Delta x^2}{\alpha\Delta t}+1\right)T^{n+1}_{n_x-2} = & \nonumber\\
T^{n}_{n_x-3} + & 2\left(\frac{\Delta x^2}{\alpha\Delta t}-1\right)T^{n}_{n_x-2} + T^{n}_{n_x-1} + q\Delta x
\end{eqnarray}

The code will look a lot like the implicit method from the [second notebook](https://github.com/numerical-mooc/numerical-mooc/blob/master/lessons/04_spreadout/02_Heat_Equation_1D_Implicit.ipynb). Only some terms of the matrix and right-hand-side vector will be different, which changes some of our custom functions.

### The linear system

Just like in [notebook 2](https://github.com/numerical-mooc/numerical-mooc/blob/master/lessons/04_spreadout/02_Heat_Equation_1D_Implicit.ipynb), we need to solve a linear system on every time step of the form:

$$[A][T^{n+1}_\text{int}] = [b]+[b]_{b.c.}$$

The coefficient matrix is very similar to the previous case, but the right-hand side changes a lot:

\begin{align}\left[ \begin{array}{cccccc}
 2\left(\frac{1}{\sigma}+1\right) & -1 & 0 & \cdots & & 0 \\
 -1 & 2\left(\frac{1}{\sigma}+1\right) & -1 & 0 & \cdots & 0 \\
 0 & & \ddots& & & \vdots \\
 \vdots & & & & 2\left(\frac{1}{\sigma}+1\right)& \\
 0 & \cdots & & & -1 & \left(2\frac{1}{\sigma}+1\right) \end{array} \right]
 \cdot
 \left[ \begin{array}{c}
 T_1^{n+1} \\ T_2^{n+1} \\ \vdots \\ \\ T_{N-2}^{n+1} \end{array} \right]
 =
 \left[ \begin{array}{c}
 T_0^n + 2\left(\frac{1}{\sigma}-1\right)T_1^n + T_2^n \\  T_1^n + 2\left(\frac{1}{\sigma}-1\right)T_2^n + T_3^n \\ \vdots \\ \\  T_{n_x-3}^n + 2\left(\frac{1}{\sigma}-1\right)T_{n_x-2}^n + T_{n_x-1}^n \end{array} \right]
 +
 \begin{bmatrix}
  T_0^{n+1}\\
  0\\\\
  \vdots\\\\
  0\\
  q\Delta x
 \end{bmatrix}
 \end{align}

Let's write a function that will create the coefficient matrix and right-hand-side vectors for the heat conduction problem from [notebook 2](https://github.com/numerical-mooc/numerical-mooc/blob/master/lessons/04_spreadout/02_Heat_Equation_1D_Implicit.ipynb): with Dirichlet boundary at $x=0$ and zero-flux boundary $(q=0)$ at $x=L$.

```
import numpy
from scipy.linalg import solve
```

```
def generateMatrix(N, sigma):
    """ Computes the matrix for the diffusion equation with Crank-Nicolson
        Dirichlet condition at i=0, Neumann at i=-1

    Parameters:
    ----------
    N: int
        Number of discretization points
    sigma: float
        alpha*dt/dx^2

    Returns:
    -------
    A: 2D numpy array of float
        Matrix for diffusion equation
    """

    # Setup the diagonal
    d = 2*numpy.diag(numpy.ones(N-2)*(1+1./sigma))

    # Consider Neumann BC
    d[-1,-1] = 1+2./sigma

    # Setup upper diagonal
    ud = numpy.diag(numpy.ones(N-3)*-1, 1)

    # Setup lower diagonal
    ld = numpy.diag(numpy.ones(N-3)*-1, -1)

    A = d + ud + ld

    return A
```

```
def generateRHS(T, sigma):
    """ Computes right-hand side of linear system for diffusion equation
        with backward Euler

    Parameters:
    ----------
    T: array of float
        Temperature at current time step
    sigma: float
        alpha*dt/dx^2

    Returns:
    -------
    b: array of float
        Right-hand side of diffusion equation with backward Euler
    """

    b = T[1:-1]*2*(1./sigma-1) + T[:-2] + T[2:]
    # Consider Dirichlet BC
    b[0] += T[0]

    return b
```

We will solve the linear system at every time step. Let's define a function to step in time:

```
def CrankNicolson(T, A, nt, sigma):
    """ Advances diffusion equation in time with Crank-Nicolson

    Parameters:
    ----------
    T: array of float
        initial temperature profile
    A: 2D array of float
        Matrix with discretized diffusion equation
    nt: int
        number of time steps
    sigma: float
        alpha*td/dx^2

    Returns:
    -------
    T: array of floats
        temperature profile after nt time steps
    """

    for t in range(nt):
        Tn = T.copy()
        b = generateRHS(Tn, sigma)
        # Use numpy.linalg.solve
        T_interior = solve(A,b)
        T[1:-1] = T_interior
        # Enforce Neumann BC (Dirichlet is enforced automatically)
        T[-1] = T[-2]

    return T
```

And we are good to go! First, let's setup our initial conditions, and the matrix

```
L = 1.
nx = 21.
alpha = 1.22e-3

dx = L/(nx-1)

Ti = numpy.zeros(nx)
Ti[0] = 100

sigma = 0.5
dt = sigma * dx*dx/alpha
nt = 10

A = generateMatrix(nx, sigma)
```

Check the matrix...

```
print A
```

    [[ 6. -1.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.
       0.]
     [-1.  6. -1.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.
       0.]
     [ 0. -1.  6. -1.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.
       0.]
     [ 0.  0. -1.  6. -1.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.
       0.]
     [ 0.  0.  0. -1.  6. -1.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.
       0.]
     [ 0.  0.  0.  0. -1.  6. -1.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.
       0.]
     [ 0.  0.  0.  0.  0. -1.  6. -1.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.
       0.]
     [ 0.  0.  0.  0.  0.  0. -1.  6. -1.  0.  0.  0.  0.  0.  0.  0.  0.  0.
       0.]
     [ 0.  0.  0.  0.  0.  0.  0. -1.  6. -1.  0.  0.  0.  0.  0.  0.  0.  0.
       0.]
     [ 0.  0.  0.  0.  0.  0.  0.  0. -1.  6. -1.  0.  0.  0.  0.  0.  0.  0.
       0.]
     [ 0.  0.  0.  0.  0.  0.  0.  0.  0. -1.  6. -1.  0.  0.  0.  0.  0.  0.
       0.]
     [ 0.  0.  0.  0.  0.  0.  0.  0.  0.  0. -1.  6. -1.  0.  0.  0.  0.  0.
       0.]
     [ 0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0. -1.  6. -1.  0.  0.  0.  0.
       0.]
     [ 0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0. -1.  6. -1.  0.  0.  0.
       0.]
     [ 0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0. -1.  6. -1.  0.  0.
       0.]
     [ 0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0. -1.  6. -1.  0.
       0.]
     [ 0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0. -1.  6. -1.
       0.]
     [ 0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0. -1.  6.
      -1.]
     [ 0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0.  0. -1.
       5.]]

Looks okay! Now, step in time

```
T = Ti.copy()
T = CrankNicolson(T, A, nt, sigma)
```

And plot,

```
from matplotlib import pyplot
%matplotlib inline
from matplotlib import rcParams
rcParams['font.family'] = 'serif'
rcParams['font.size'] = 16

x = numpy.linspace(0,L,nx)

pyplot.plot(x, T, color='#003366', ls='-', lw=3);
```

Works nicely. But wait! This method has elements of explicit and implicit discretizations. Is it *conditionally stable* like forward Euler, or *unconditionally stable* like backward Euler? Try out different values of `sigma`. You'll see Crank-Nicolson is an *unconditionally stable scheme* for the diffusion equation!

## Accuracy & convergence

Using some techniques you might have learned in your PDE class, such as separation of variables, you can get a closed expression for the rod problem. It looks like this:

\begin{eqnarray}
T(x,t) = & \nonumber \\
100 - \sum_{n=1}^{\infty} & \frac{400}{(2n-1)\pi}\sin\left(\frac{(2n-1)\pi}{2L}x\right) \exp\left[-\alpha\left(\frac{(2n-1)\pi}{2L}\right)^2t\right]
\end{eqnarray}

Unfortunately, the analytical solution is a bit messy, but at least it gives a good approximation if we evaluate it for large $n$. Let's define a function that will calculate this for us:

```
from math import pi
def T_analytical(x, t, n_max, alpha, L):
    """Computes the exact solution for 1D diffusion with T=100 at x=0 and dT/dx=0 at x=L

    Paramters:
    ---------
    x    : array of float
        Spatial position
    t    : float
        Evaluation time
    n_max: int
        Number of terms to evaluate expression
    alpha: float
        diffusion coefficient
    L    : float
        Size of rod

    Returns:
    -------
    T    : array of float
        Temperature at each location x
    """
    T = 100
    for n in range(1,n_max+1):
        k = (2*n-1)*pi/(2*L)

        summation = 400/((2*n-1)*pi) * numpy.sin(k*x) * numpy.exp(-alpha*k*k*t)
        T -= summation

    return T
```

And let's see how that expression looks for the time where we left the numerical solution

```
T_exact = T_analytical(x, dt*nt, 100, alpha, L)
pyplot.plot(x, T_exact, color='#003366', ls='-', lw=3);
```

```
T1 = T_analytical(x, .2, 100, alpha, L)
T2 = T_analytical(x, .2, 200, alpha, L)
numpy.sqrt(numpy.sum((T1-T2)**2)/numpy.sum(T2**2))
```

    6.9279171182600926e-13

That looks like it should. We'll now use this result to study the convergence of the Crank-Nicolson scheme.

### Time convergence

We said this method was second-order accurate in time, remember? That's in theory, but we should test that the numerical solution indeed behaves like the theory says.

Leaving $\Delta x$ constant, we'll run the code for different values of $\Delta t$ and compare the result at the same physical time, say $t=n_t\cdot\Delta t=10$, with the analytical expression above.

The initial condition of the rod problem has a very sharp gradient: it suddendly jumps from $0{\rm C}$ to $100{\rm C}$ at the boundary. To resolve that gradient to the point that it doesn't affect time convergence, we would need a very fine mesh, and computations would be very slow. To avoid this issue, we will start from $t=1$ rather than starting from $t=0$.

First, let's define a function that will compute the $L_2$-norm of the error:

```
def L2_error(T, T_exact):
    """Computes L2 norm of error

    Parameters:
    ----------
    T      : array of float
        array with numerical solution
    T_exact: array of float
        array with exact solution
    Returns:
    -------
    e: L2 norm of error
    """
    e = numpy.sqrt(numpy.sum((T-T_exact)**2)/numpy.sum(T_exact)**2)
    return e
```

For fun, let's compare the Crank-Nicolson schem with the implicit (a.k.a., backward) Euler scheme. We'll borrow some functions from [notebook 2](http://nbviewer.ipython.org/github/numerical-mooc/numerical-mooc/blob/master/lessons/04_spreadout/02_Heat_Equation_1D_Implicit.ipynb) to do this.

```
def generateMatrix_btcs(N, sigma):
    """ Computes the matrix for the diffusion equation with backward Euler
        Dirichlet condition at i=0, Neumann at i=-1

    Parameters:
    ----------
    T: array of float
        Temperature at current time step
    sigma: float
        alpha*dt/dx^2

    Returns:
    -------
    A: 2D numpy array of float
        Matrix for diffusion equation
    """

    # Setup the diagonal
    d = numpy.diag(numpy.ones(N-2)*(2+1./sigma))

    # Consider Neumann BC
    d[-1,-1] = 1+1./sigma

    # Setup upper diagonal
    ud = numpy.diag(numpy.ones(N-3)*-1, 1)

    # Setup lower diagonal
    ld = numpy.diag(numpy.ones(N-3)*-1, -1)

    A = d + ud + ld

    return A
```

```
def generateRHS_btcs(T, sigma):
    """ Computes right-hand side of linear system for diffusion equation
        with backward Euler

    Parameters:
    ----------
    T: array of float
        Temperature at current time step
    sigma: float
        alpha*dt/dx^2

    Returns:
    -------
    b: array of float
        Right-hand side of diffusion equation with backward Euler
    """
    b = numpy.zeros_like(T)

    b = T[1:-1]*1./sigma
    # Consider Dirichlet BC
    b[0] += T[0]

    return b
```

```
def implicit_btcs(T, A, nt, sigma):
    """ Advances diffusion equation in time with implicit central scheme

    Parameters:
    ----------
    T: array of float
        initial temperature profile
    A: 2D array of float
        Matrix with discretized diffusion equation
    nt: int
        number of time steps
    sigma: float
        alpha*td/dx^2

    Returns:
    -------
    T: array of floats
        temperature profile after nt time steps
    """

    for t in range(nt):
        Tn = T.copy()
        b = generateRHS_btcs(Tn, sigma)
        # Use numpy.linalg.solve
        T_interior = solve(A,b)
        T[1:-1] = T_interior
        # Enforce Neumann BC (Dirichlet is enforced automatically)
        T[-1] = T[-2]

    return T
```

Now, let's do the runs!

```
nx = 1001
dx = L/(nx-1)

dt_values = numpy.asarray([1.0, 0.5, 0.25, 0.125])
error = numpy.zeros(len(dt_values))
error_ftcs = numpy.zeros(len(dt_values))

t_final = 10.0
t_initial = 1.0

x = numpy.linspace(0,L,nx)

Ti =  T_analytical(x, t_initial, 100, alpha, L)
T_exact = T_analytical(x, t_final, 100, alpha, L)

for i,dt in enumerate(dt_values):
    sigma = alpha*dt/(dx*dx)

    nt = int((t_final-t_initial)/dt)

    A = generateMatrix(nx, sigma)

    A_btcs = generateMatrix_btcs(nx, sigma)

    T = Ti.copy()
    T = CrankNicolson(T, A, nt, sigma)

    error[i] = L2_error(T,T_exact)

    T = Ti.copy()
    T = implicit_btcs(T, A_btcs, nt, sigma)

    error_ftcs[i] = L2_error(T,T_exact)

```

And plot,

```
pyplot.figure(figsize=(6,6))
pyplot.grid(True)
pyplot.xlabel(r'$\Delta t$', fontsize=18)
pyplot.ylabel(r'$L_2$-norm of the error', fontsize=18)
pyplot.xlim(1e-2,1)
pyplot.ylim(1e-4,1)
pyplot.axis('equal')
pyplot.loglog(dt_values, error, color='k', ls='--', lw=2, marker='o')
pyplot.loglog(dt_values, error_ftcs, color='k', ls='--', lw=2, marker='s');
```

```
error
```

    array([  3.81125927e-05,   9.41813943e-06,   2.25089054e-06,
             4.63970974e-07])

See how the error drops four times when the time step is halved? This method is second order in time!

Clearly, Crank-Nicolson (circles) converges faster than backward Euler (squares)! Not only that, but also the error curve is shifted down: Crank-Nicolson is more accurate.

If you look closely, you'll realize that the error in Crank-Nicolson decays about twice as fast than backward Euler: it's a second versus first order method!

### Spatial convergence

To study spatial convergence, we will run the code for meshes with 21, 41, 81 and 161 points, and compare them at the same non-dimensional time, say $t=20$.

Let's start by defining a function that will do everything for us

```
nx_values = numpy.asarray([11, 21, 41, 81, 161])

dt = 0.1
error = numpy.zeros(len(nx_values))

t_final = 20.0

x = numpy.linspace(0,L,nx)

for i,nx in enumerate(nx_values):

    dx = L/(nx-1)
    x = numpy.linspace(0,L,nx)

    sigma = alpha*dt/(dx*dx)

    nt = int(t_final/dt)

    A = generateMatrix(nx, sigma)

    T = numpy.zeros(nx)
    T[0] = 100

    T = CrankNicolson(T, A, nt, sigma)

    T_exact = T_analytical(x, t_final, 100, alpha, L)

    error[i] = L2_error(T,T_exact)
```

And plot!

```
pyplot.figure(figsize=(6,6))
pyplot.grid(True)
pyplot.xlabel(r'$n_x$', fontsize=18)
pyplot.ylabel(r'$L_2$-norm of the error', fontsize=18)
pyplot.xlim(1e-4,1)
pyplot.ylim(1e-4,1)
pyplot.axis('equal')
pyplot.loglog(nx_values, error, color='k', ls='--', lw=2, marker='o');
```

That looks good! See how for each quadrant we go right, the error drops two quadrants going down (and even a bit better!).

##### Dig deeper

Let's re-do the spatial convergence, but comparing at a much later time, say $t=1000$.

```
nx_values = numpy.asarray([11, 21, 41, 81, 161])

dt = 0.1
error = numpy.zeros(len(nx_values))

t_final = 1000.0

x = numpy.linspace(0,L,nx)

for i,nx in enumerate(nx_values):

    dx = L/(nx-1)
    x = numpy.linspace(0,L,nx)

    sigma = alpha*dt/(dx*dx)

    nt = int(t_final/dt)

    A = generateMatrix(nx, sigma)

    T = numpy.zeros(nx)
    T[0] = 100

    T = CrankNicolson(T, A, nt, sigma)

    T_exact = T_analytical(x, t_final, 100, alpha, L)

    error[i] = L2_error(T,T_exact)

pyplot.figure(figsize=(6,6))
pyplot.grid(True)
pyplot.xlabel(r'$n_x$', fontsize=18)
pyplot.ylabel(r'$L_2$-norm of the error', fontsize=18)
#pyplot.xlim(1e-4,1)
#pyplot.ylim(1e-4,1)
pyplot.axis('equal')
pyplot.loglog(nx_values, error, color='k', ls='--', lw=2, marker='o');
```

```
error
```

    array([  3.59569224e-03,   1.34923182e-03,   4.90905474e-04,
             1.76021088e-04,   6.26681531e-05])

Wait, convergence is not that great now! It's not as good as second order, but not as bad as first order. *What is going on?*

Remember our implementation of the boundary conditions? We used

\begin{equation}\frac{T^{n}_{N-1} - T^{n}_{N-2}}{\Delta x} = q.\end{equation}

Well, that is a **first-order** approximation!

But, why doesn't this affect our solution at an earlier time? Initially, temperature on the right side of the rod is zero and the gradient is very small in that region; at that point in time, errors there were negligible. Once temperature starts picking up, we start having problems.

**Boundary conditions can affect the convergence and accuracy of your solution!**

---
###### The cell below loads the style of the notebook

```
from IPython.core.display import HTML
css_file = '../../styles/numericalmoocstyle.css'
HTML(open(css_file, "r").read())
```

<link href='http://fonts.googleapis.com/css?family=Alegreya+Sans:100,300,400,500,700,800,900,100italic,300italic,400italic,500italic,700italic,800italic,900italic' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Arvo:400,700,400italic' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=PT+Mono' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Shadows+Into+Light' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Nixie+One' rel='stylesheet' type='text/css'>
<style>

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

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

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

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.8em;
}

/* 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: 'Alegreya Sans' sans-serif;
    line-height: 140%;
    font-size: 125%;
    font-weight: 400;
    width:600px;
    margin-left:auto;
    margin-right:auto;
}

/* Formatting for header cells */
.text_cell_render h1 {
    font-family: 'Nixie One', serif;
    font-style:regular;
    font-weight: 400;
    font-size: 45pt;
    line-height: 100%;
    color: rgb(0,51,102);
    margin-bottom: 0.5em;
    margin-top: 0.5em;
    display: block;
}
.text_cell_render h2 {
    font-family: 'Nixie One', serif;
    font-weight: 400;
    font-size: 30pt;
    line-height: 100%;
    color: rgb(0,51,102);
    margin-bottom: 0.1em;
    margin-top: 0.3em;
    display: block;
}

.text_cell_render h3 {
    font-family: 'Nixie One', serif;
    margin-top:16px;
	font-size: 22pt;
    font-weight: 600;
    margin-bottom: 3px;
    font-style: regular;
    color: rgb(102,102,0);
}

.text_cell_render h4 {    /*Use this for captions*/
    font-family: 'Nixie One', serif;
    font-size: 14pt;
    text-align: center;
    margin-top: 0em;
    margin-bottom: 2em;
    font-style: regular;
}

.text_cell_render h5 {  /*Use this for small titles*/
    font-family: 'Nixie One', sans-serif;
    font-weight: 400;
    font-size: 16pt;
    color: rgb(163,0,0);
    font-style: italic;
    margin-bottom: .1em;
    margin-top: 0.8em;
    display: block;
}

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

.CodeMirror{
        font-family: "PT Mono";
        font-size: 90%;
}

</style>


# Gaussian Models

Gaussian models that are typically used to described extra-axonal diffusion. Briefly, we have:
- Ball model: Isotropic Gaussian diffusion with diffusion coefficients $\lambda_{iso}$.
- Zeppelin Model: Axially symmetric Gaussian tensor, oriented along orientation $\mu$ with parallel and perpendicular diffusivity $\lambda_\parallel$ and $\lambda_\perp$.
- Restricted zeppeling model: Axially symmetric Gaussian tensor with time-dependent perpendicular diffusion to account for diffusion restricted in the extra-axonal space between axons. Instead of $\lambda_\perp$, this model has $\lambda_{inf}$ and characteristic coefficient $A$.

## Ball: G1
the ``Ball'' model *(Behrens et al. 2013)* models the totallity of all extra-axonal diffusion as a Tensor with isotropic diffusivity $\lambda_{\textrm{iso}}$ as

\begin{equation}
E_{\textrm{iso}}(b,\lambda_{\textrm{iso}})=\exp(-b\lambda_{\textrm{iso}}).
\end{equation}

In current models, the Ball is usually only used to describe the CSF and/or grey matter compartment of the tissue, where the isotropic diffusion assumption is reasonably valid*(Alexander et al. 2010, Jeurissen et al. 2014, Tariq et al. 2016)*.

```python
from dmipy.signal_models import gaussian_models
from dmipy.core.acquisition_scheme import acquisition_scheme_from_bvalues
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline

samples = 100
bvalues = np.linspace(0, 1e9, samples)
bvectors = np.tile([0, 0, 1], (samples, 1))
delta = 1e-2
Delta = 3e-2
scheme = acquisition_scheme_from_bvalues(bvalues, bvectors, delta, Delta)

ball = gaussian_models.G1Ball()

for lambda_iso in [1e-9, 2e-9, 3e-9]:
    plt.plot(bvalues, ball(scheme, lambda_iso=lambda_iso),
             label='lambda_iso=' + str(lambda_iso * 1e9) + 'e-9 m^2/s')
plt.legend()
plt.xlabel('b-value [s/m^2]', fontsize=13)
plt.ylabel('Signal Attenuation', fontsize=13)
plt.title('Signal Attenuation Ball', fontsize=15);
```

The higher the diffusivity the faster the signal attenuates.

## Zeppelin: G2
Hindered extra-axonal diffusion, i.e. diffusion of particles in-between axons, is often modeled as an anisotropic, axially symmetric Gaussian, also known as a ``Zeppelin'' *(Panagiotaki et al. 2012)*.
Using DTI notation, a Zeppelin with $\lambda_\parallel=\lambda_1$, $\lambda_\perp=\lambda_2=\lambda_3$ and $\lambda_\parallel>\lambda_\perp$ is given as

\begin{equation}
 E_h(b,\textbf{n},\lambda_\parallel,\lambda_\perp)=\exp(-b\textbf{n}^T(\textbf{R}\textbf{D}^h_{\textrm{diag}}\textbf{R}^T)\textbf{n})\quad\textrm{with}\quad
 \textbf{D}^h_{\textrm{diag}}=
\begin{pmatrix}
  \lambda_\parallel & 0 & 0  \\
  0 & \lambda_\perp & 0  \\
  0  & 0  & \lambda_\perp
 \end{pmatrix}.
\end{equation}

```python
# We set the parallel diffusivity larger than the perpendicular diffusivity
zeppelin = gaussian_models.G2Zeppelin(lambda_par=1.7e-9, lambda_perp=0.8e-9)
E_zeppelin_par = zeppelin(scheme, mu=[0, 0])
E_zeppelin_perp = zeppelin(scheme, mu=[np.pi / 2, 0.])

plt.plot(bvalues, E_zeppelin_par, label='E_parallel')
plt.plot(bvalues, E_zeppelin_perp, label='E_perpendicular')
plt.legend()
plt.xlabel('b-value [s/m^2]', fontsize=13)
plt.ylabel('Signal Attenuation', fontsize=13)
plt.title('Signal Attenuation Zeppelin', fontsize=15);
```

## Temporal Zeppelin: G3
In DTI, the signal attenuation decays like a Gaussian over q-value and like an expontial over diffusion time $\tau$.
However, recent works argue that hindered diffusion is actually slower-than-exponential over $\tau$ due to how the external axon boundaries still restrict diffusing particles*(Novikov et al. 2014)*.
To account for this, *(Burcaw et al. 2015)* proposed a modification to the Zeppelin as

\begin{equation}
\textbf{D}_{\textrm{diag}}^{\textrm{r}}=
\begin{pmatrix}
  \lambda_\parallel & 0 & 0  \\
  0 & \lambda_\perp^{\textrm{r}} & 0  \\
  0  & 0  & \lambda_\perp^{\textrm{r}}
 \end{pmatrix}\quad\textrm{with}\quad\lambda_\perp^{\textrm{r}}=D_{\infty}+\frac{A\ln(\Delta/\delta)+3/2}{\Delta-\delta/3}
\end{equation}
where perpendicular diffusivity $\lambda_\perp^{\textrm{r}}$ is now time-dependent with $D_{\infty}$ the bulk diffusion constant and $A$ is a characteristic coefficient for extra-axonal hindrance.
Notice that when $A=0$ then G3 simplifies to G2.

```python
# we just sample the (restricted) perpendicular direction now
restricted_zeppelin = gaussian_models.G3TemporalZeppelin(
    mu=[np.pi / 2, 0.], lambda_par=1.7e-9, lambda_inf = 1e-9)

for A in [0, 2e-12, 4e-12]:
    plt.plot(bvalues, restricted_zeppelin(scheme, A=A), label='A={} $\mu m^2$'.format(A*1e12))
plt.legend()
plt.xlabel('b-value [s/m^2]', fontsize=13)
plt.ylabel('Signal Attenuation', fontsize=13)
plt.title('Signal Attenuation Restricted Zeppelin', fontsize=15);
```

Notice that for larger characteristic coefficients the signal attenuates faster.

## References
- Behrens, Timothy EJ, et al. "Characterization and propagation of uncertainty in diffusion‐weighted MR imaging." Magnetic resonance in medicine 50.5 (2003): 1077-1088.
- Alexander, Daniel C., et al. "Orientationally invariant indices of axon diameter and density from diffusion MRI." Neuroimage 52.4 (2010): 1374-1389.
- Jeurissen, Ben, et al. "Multi-tissue constrained spherical deconvolution for improved analysis of multi-shell diffusion MRI data." NeuroImage 103 (2014): 411-426.
- Tariq, Maira, et al. "Bingham–noddi: Mapping anisotropic orientation dispersion of neurites using diffusion mri." NeuroImage 133 (2016): 207-223.
- Panagiotaki, Eleftheria, et al. "Compartment models of the diffusion MR signal in brain white matter: a taxonomy and comparison." Neuroimage 59.3 (2012): 2241-2254.
- Novikov, Dmitry S., et al. "Revealing mesoscopic structural universality with diffusion." Proceedings of the National Academy of Sciences 111.14 (2014): 5088-5093.
- Burcaw, Lauren M., Els Fieremans, and Dmitry S. Novikov. "Mesoscopic structure of neuronal tracts from time-dependent diffusion." NeuroImage 114 (2015): 18-37.


# Integração numérica

Também conhecido na literatura como quadratura.

Como calcular

$$
\int_a^b f(\xi)\:d\xi
$$

para uma função qualquer?

 * Na mão: difícil mesmo nos casos mais simples
 * No computador com cálculo simbólico: útil algumas vezes mas pouco eficiente
 * Métodos numéricos - na prática o que é usado.

<!-- TEASER_END -->

# Usar a aproximação!

É bom lembrar que chegamos em várias maneiras para aproximar uma função:

$$
y = f(x) \approx y^\delta = \sum_{i=1}^n a_i\phi_i(x)
$$

Uma primeira possibilidade é integrar termo a termo:

$$
\int_a^bf(\xi)\:d\xi \approx \sum_{i=1}^n a_i\int_a^b\phi_i(\xi)\:d\xi
$$

Como $\phi_i(x)$ foram escolhidos levando em conta simplicidade, esta abordagem pode ser utilizada.

# Regra do ponto médio:

Dados os pontos $x_1, x_2, \ldots, x_n$, usando interpoladores constantes

```julia
using PyPlot
using CurveFit
using BenchmarkTools
```

```julia
x = 0:0.1:1
xx = 0:0.002:1
f(x) = sin(π*x) + 0.5 + cos(x)
y = f.(x);
yy = f.(xx);
```

```julia
plot(xx, yy, "b-")
plot(x, y, "rs")

nx = length(x)

lty = "k-"

for i in 1:nx-1
    xm = 0.5 * (x[i] + x[i+1])
    ym = f(xm)
    plot([x[i], x[i], x[i+1], x[i+1]], [0, ym, ym, 0], lty )
    plot([xm, xm], [0, ym], "k:")

end

```

Analisando a figura acima, pode-se facilmante ver que a integral vale (para pontos igualmente espaçados):

$$
\int_a^b f(\xi)\:d\xi \approx \frac{b-a}{n-1} \sum_{i=1}^{n-1}\left[ f\left(x_{i+\frac{1}{2}}\right) \right]
$$

```julia
function midrule(a, b, f, n)
    dx = (b-a) / n
    x = range(a, b, length=n+1)
    s = 0.0
    for i in 1:n
        s += f(x[i]+dx/2)
    end
    return dx * s
end
```

```julia
## Análise de convergência:
nn = 2:1000; a = 0.0; b = 1.0;
Ie = 2/π + 0.5 + sin(1); Inn = midrule.(a, b, f, nn); err = abs.(Inn .- Ie);
loglog(nn, err, "bo"); xlabel("n"); ylabel("ε")
nn1 = 10:300; ee1 = 5 .* nn1.^(-2)
plot(nn1, ee1, "r--"); text(50, 0.005, L"$\mathcal{O}(n^{-2})$")
```

Assim, a estimativa do erro vale:

$$
\varepsilon = \mathcal{O}\left(n^{-2}\right)
$$

# Regra do trapézio

Agora, ao invés de usar interpolação consante entre os pontos, pode-se ligar os pontos por retas.

```julia
function trapezoidal(a, b, f, n)
    x = range(a, b, length=n+1)
    s = f(x[1])/2 + f(x[end])/2
    for i in 2:n
        s += f(x[i])
    end
    return (b-a) * s/n
end
```

```julia
nn2 = nn1
Inn2 = trapezoidal.(a, b, f, nn2);
err2 = abs.(Inn2 .- Ie);
```

```julia
loglog(nn2, err2, "bo"); xlabel("n"); ylabel("ε")
mm = 10:300; ee1 = 5 .* mm.^(-2)
plot(mm, ee1, "r--")
text(50, 0.005, L"$\mathcal{O}(n^{-2})$")
```

### Estimativa de erro da regra do trapézio:

O erro ao integrar uma seção usando a regra do trapézio vale:

$$
\epsilon_i = R_i(h) - \int_{i-1}^i f(x)\:dx = f''(\xi) \frac{h^3}{12}
$$

Erro total:

$$
\epsilon = \sum_{i=1}^n \epsilon_i \approx f''(\xi)\frac{h^2(b-a)}{12} \sim h^2
$$

# Regra de Simpson

Usa três pontos para aproximar uma parábola:

$$
\int_{i-1}^{i+1} f(x)\:dx = S_i(h) = \frac{h}{3}\left[f(x_{i-1}) + 4f(x_i) + f(x_{i+1})\right]
$$

Assim, no domínio inteiro,

$$
\int_a^b f(x)\:dx \approx \frac{h}{3}\left[f(x_1) + 4f(x_2) + 2f(x_3) + 4f(x_4) + \ldots + 2f(x_{n-2}) + 4f(x_{n-1}) + f(x_n)\right]
$$

### Estimativa do erro:

$$
\int_{i-1}^{i+1} f(x)\:dx = \frac{h}{3}\left[f(x_{i-1}) + 4f(x_i) + f(x_{i+1})\right] - \frac{h^5}{90}f^{(4)}(\xi)
$$

$$
\epsilon_i = \mathcal{O}(h^5) \quad\Longrightarrow\quad \epsilon = \mathcal{O}(h^4)
$$

```julia
# Detalhe n tem que ser divisível por 2.
function simpsonsrule(a, b, f, n)
    x = range(a, b, length=n+1)
    s = f(x[1]) + f(x[end])
    w, w2 = 4,2
    for i = 2:n
        s += w*f(x[i])
        w, w2 = w2, w
    end
    return s * (b-a)/(3n)
end
```

```julia
nn3 = 2:2:1000
Inn3 = simpsonsrule.(a, b, f, nn3);
err3 = abs.(Inn3 .- Ie);
```

```julia
loglog(nn3, err3, "bo")
grid(ls=":")
xlabel("n")
ylabel("ε")
nn1 = 10:300
ee1 = 5 .* nn1.^(-4)
plot(nn1, ee1, "r--")
text(50, 0.000002, L"$\mathcal{O}(n^{-4})$")
```

# Generalização do processo

Usando as idéias acima com interpoladores polinomiais de ordem crescente pode-se obter regras de ordem superior para a integração.

A idéia é usar interpoladores de Lagrange para calcular as integrais.Com isso se chega às formulas de Newton-Cotes (<https://pt.wikipedia.org/wiki/F%C3%B3rmulas_de_Newton-Cotes>)

**Cuidado com isso!!!** Lembre-se das oscilações dos polinômios de Lagrange.

# Integração de Romberg

Um processo parecido com a extrapolação de Richardson (veremos isso mais tarde). Suponhamos que queremos estimar

$$
\int_a^b f(\xi) \: d\xi
$$

Uma primeira aproximação é usar a regra do trapézio nas extremidades. O intervalo pode ser dividido por dois sequencialmente, obtendo-se:

\begin{align}
R_{1,1} &= \frac{h_1}{2}\left[f(a) + f(b)\right] \\
R_{2,1} &= \frac{h_2}{2}\left[f(a) + f(b) + 2f(a+h_2)\right]  = \frac{1}{2}\left[ R_{1,1} + h_1 f(a+h_2)\right]\\
\vdots & \\
R_{k,1} &= \frac{1}{2}\left\{ R_{k-1,1} + h_{k-1}\sum_{i=1}^{2^k-2}f\left[a + \left(2i-1\right)h_k\right]\right\}
\end{align}

onde

$$h_k = \frac{b-a}{2^{k-1}}$$

O erro da integral vale (difícil de demonstrar mas possível):

$$
\int_a^b f(x)\:dx - R_{k,1} = \sum_{i=1}^\infty K_i h_k^{2i} = K_1 h_k^2 + \sum_{i=2}^\infty K_i h_k^{2i}
$$

por outro lado, lembrando que

$$\epsilon_{k+1} = \frac{\epsilon_{k}}{4}$$

chega-se a:

$$
\int_a^b f(x)\:dx - R_{k+1,1} = \sum_{i=1}^\infty K_i h_{k+1}^{2i} = \sum_{i=1}^\infty \frac{K_i h_{k}^{2i}}{2^{2i}} =  \frac{K_1 h_k^2}{4} + \sum_{i=2}^\infty \frac{K_i h_k^{2i}}{4^i}
$$

substraindo a equação anterior de 4 vezes a equação acima, chega-se à seguinte relação:

$$
R_{k,2} = R_{k,1} + \frac{R_{k,1} - R_{k-1,1}}{3}
$$

Repetindo este processo:

$$
R_{k,j} = R_{k,j-1} + \frac{R_{k,j-1} - R_{k-1,j-1}}{4^{j-1}-1}
$$

```julia
function romberg1(a, b, f, n)
    R = zeros(n, n)
    h = Float64(b-a)
    R[1,1] = h/2 * (f(a) + f(b))

    for i in 2:n
        s = 0.0
        for k in 1:(2^(i-2))
            s += f(a + (k-0.5)*h)
        end
        R[i,1] = 0.5 * (R[i-1,1] + h * s)
        for k in 2:i
            R[i,k] = R[i,k-1] + (R[i,k-1] - R[i-1,k-1]) / (4^(k-1) - 1)
        end
        h = h/2
    end

    return R
end

```

```julia
romberg1(0, π, sin, 6)
```

```julia
function romberg(a, b, f, n)
    R1 = zeros(n)
    R2 = zeros(n)
    h = Float64(b-a)
    R1[1] = h/2 * (f(a) + f(b))
    for i in 2:n
        s = 0.0
        for k in 1:(2^(i-2))
            s += f(a + (k-0.5)*h)
        end
        R2[1] = 0.5 * (R1[1] + h * s)
        for k in 2:i
            R2[k] = R2[k-1] + (R2[k-1] - R1[k-1]) / (4^(k-1) - 1)
        end
        for k in 1:i
            R1[k] = R2[k]
        end
        h = h/2
    end

    return R2[n]

end

```

```julia
function countromberg(n)
    #R1 = zeros(n)
    #R2 = zeros(n)
    #h = Float64(b-a)
    #R1[1] = h/2 * (f(a) - f(b))
    countfun = 2
    countfloat = 2
    for i in 2:n
        for k in 1:(2^(i-2))
            countfun += 1
            countfloat += 4
        end
        for k in 2:i
            countfloat += 5
        end
        countfloat += 1
    end

    return countfun, countfloat

end

```

```julia
nnx = 1:20
nny = countromberg.(nnx)
nfun = [x[1] for x in nny]
nfloat = [x[2] for x in nny];

```

```julia
semilogy(nnx, nfun)
grid()
xlabel("Número de iterações")
ylabel("Número de chamadas de função")
```

```julia
nn4 = nnx
Inn4 = romberg.(a, b, f, nn4);
err4 = abs.(Inn4 .- Ie);
```

```julia
rom = loglog(nfun, err4, "bo", label="Romberg")
sim = plot(nn3, err3, "r-", label="Regra de Simpson")
mid = plot(nn, err, "g-", label="Regra do ponto médio")
grid()
xlabel("Número de Chamadas de função")
ylabel("ε")
legend()
```

```julia
## Benchmark para comparar tempo de execução:
I1a = midrule(a, b, f, 400)
I2a = simpsonsrule(a, b, f, 22)
I3a = romberg(a, b, f, 4)

abs(I1a - Ie), abs(I2a - Ie), abs(I3a - Ie)
```

```julia
@benchmark midrule(a, b, f, 400)
```

```julia
@benchmark simpsonsrule(a, b, f, 22)
```

```julia
@benchmark romberg(a, b, f, 4)
```

```julia
I1b = midrule(a, b, f, 800000)
I2b = simpsonsrule(a, b, f, 1000)
I3b = romberg(a, b, f, 6)

abs(I1b - Ie), abs(I2b - Ie), abs(I3b - Ie)
```

```julia
@benchmark midrule(a, b, f, 800000)
```

```julia
@benchmark simpsonsrule(a, b, f, 1000)
```

```julia
@benchmark romberg(a, b, f, 6)
```

# Problemas

## Problema 1

Faça uma função para calcular a integral usando a regra do ponto médio com pontos cujo espaçamento não é igual

```julia

```

## Problema 2
Faça uma função para calcular a integral usando a regra do trapézio com pontos cujo espaçamento não é igual

```julia

```

## Problema 3

Calcule usando os diversos métodos apresentados a seguinte integral:

$$
\int_{-1}^1 \left(1- | x |\right) \: dx
$$

```julia

```

## Problema 4

A regra do ponto médio apresenta o mesmo erro assintótico que a regra do trapézio. Vamos explorar um pouco isso:

 1. Você consegue criar um método com erro $\mathcal{O}(h)$?
 2. Se você criou o método acima do jeito mais simples você consegue criar um outro método bem semelhante com o mesmo tipo de erro.
 3. Qual a relação entre estes dois métodos e a regra ponto médio

```julia

```


```python
# Mtarix Reductions
# elementary_row_op (기본행 연산)
# elementary_col_op
import sympy as sm
T = sm.Matrix([[1,2,3],[4,5,6],[7,8,9]])
T
```

$\displaystyle \left[\begin{matrix}1 & 2 & 3\\4 & 5 & 6\\7 & 8 & 9\end{matrix}\right]$

```python
T1 = T.elementary_row_op(op="n->n+km",row=1,row2=0,k=-4)
T1
```

$\displaystyle \left[\begin{matrix}1 & 2 & 3\\0 & -3 & -6\\7 & 8 & 9\end{matrix}\right]$

```python
T2 = T1.elementary_row_op(op="n->n+km",row=2,row2=0,k=-7)
T2
```

$\displaystyle \left[\begin{matrix}1 & 2 & 3\\0 & -3 & -6\\0 & -6 & -12\end{matrix}\right]$

```python
T3 = T2.elementary_row_op(op='n->n+km',row=2,row2=1,k=-2)
T3
```

$\displaystyle \left[\begin{matrix}1 & 2 & 3\\0 & -3 & -6\\0 & 0 & 0\end{matrix}\right]$

```python
T.echelon_form()
```

$\displaystyle \left[\begin{matrix}1 & 2 & 3\\0 & -3 & -6\\0 & 0 & 0\end{matrix}\right]$

```python
T4 = T3.elementary_row_op(op='n->kn',row=1,k=-1/3)
```

```python
T4.elementary_row_op(op='n->n+km',row=0,row2=1,k=-2)
```

$\displaystyle \left[\begin{matrix}1 & 0 & -1.0\\0 & 1.0 & 2.0\\0 & 0 & 0\end{matrix}\right]$

```python
T.rref()
```

    (Matrix([
     [1, 0, -1],
     [0, 1,  2],
     [0, 0,  0]]),
     (0, 1))

```python

```


# Nonlinear Equations

We want to find a root of the nonlinear function $f$ using different methods.

1. Bisection method
2. Newton method
3. Chord method
4. Secant method
5. Fixed point iterations

```python
%matplotlib inline
from numpy import *
from matplotlib.pyplot import *
import sympy as sym

```

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

f_sym = t/8. * (63.*t**4 - 70.*t**2. +15.) # Legendre polynomial of order 5

f_prime_sym = sym.diff(f_sym,t)

f = sym.lambdify(t, f_sym, 'numpy')
f_prime = sym.lambdify(t,f_prime_sym, 'numpy')

phi = lambda x : 63./70.*x**3 + 15./(70.*x)
#phi = lambda x : 70.0/15.0*x**3 - 63.0/15.0*x**5
#phi = lambda x : sqrt((63.*x**4 + 15.0)/70.)

# Let's plot
n = 1025

x = linspace(-1,1,n)
c = zeros_like(x)

_ = plot(x,f(x))
_ = plot(x,c)
_ = grid()

```

```python
# Initial data for the variuos algorithms

# interval in which we seek the solution
a = 0.7
b = 1.

# initial points
x0 = (a+b)/2.0
x00 = b

```

```python
# stopping criteria
eps = 1e-10
n_max = 1000
```

## Bisection method

$$
x^k = \frac{a^k+b^k}{2}
$$
```
                   if (f(a_k) * f(x_k)) < 0:
                      b_k1 = x_k
                      a_k1 = a_k
                   else:
                      a_k1 = x_k
                      b_k1 = b_k
```

```python
def bisect(f,a,b,eps,n_max):
    assert f(a)*f(b) < 0
    a_new = a
    b_new = b
    x = mean([a,b])
    #This is made in order to have err > eps on first iteration
    err = eps + 1.
    #But why do we want it on our errors array??
    errors = [err]
    it = 0
    while (err > eps and it < n_max):
        if ( f(a_new) * f(x) < 0 ):
            # root in (a_new,x)
            b_new = x
        else:
            # root in (x,b_new)
            a_new = x

        x_new = mean([a_new,b_new])

        #err = 0.5 *(b_new -a_new)
        err = abs(f(x_new))
        #err = abs(x-x_new)

        errors.append(err)
        x = x_new
        it += 1

    semilogy(errors)
    print(it)
    print(x)
    print(err)
    return errors

errors_bisect = bisect(f,a,b,eps,n_max)

```

```python
#TODO?
# is the number of iterations coherent with the theoretical estimation?
```

In order to find out other methods for solving non-linear equations, let's compute the Taylor's series of $f(x^k)$ up to the first order

$$
f(x^k) \simeq f(x^k) + (x-x^k)f^{\prime}(x^k)
$$
which suggests the following iterative scheme
$$
x^{k+1} = x^k - \frac{f(x^k)}{f^{\prime}(x^k)}
$$

The following methods are obtained applying the above scheme where

$$
f^{\prime}(x^k) \approx q^k
$$

## Newton's method
$$
q^k = f^{\prime}(x^k)
$$

$$
x^{k+1} = x^k - \frac{f(x^k)}{q^k}
$$

```python
def newton(f,f_prime,x0,eps,n_max):
    assert f(x0) != 0
    err = eps +1.
    errors = [err]
    it = 0
    x_new = x0
    while err > eps and it < n_max:
        x_new = x_new - f(x_new)/f_prime(x_new)
        err = abs(f(x_new))
        errors.append(err)
        it += 1

    semilogy(errors)
    print(it)
    print(x_new)
    print(err)
    return errors

%time errors_newton = newton(f,f_prime,1.0,eps,n_max)
```

## Chord method

$$
q^k \equiv q = \frac{f(b)-f(a)}{b-a}
$$

$$
x^{k+1} = x^k - \frac{f(x^k)}{q}
$$

```python
def chord(f,a,b,x0,eps,n_max):
    assert f(x0) != 0
    err = eps+1.
    errors = [err]
    it = 0
    x = x0
    q = (f(b) - f(a))/(b-a)
    while (err > eps and it < n_max):
        x = x - f(x)/q
        err = abs(f(x))
        errors.append(err)
        it += 1
    semilogy(errors)
    print(it)
    print(err)
    print(x)
    return errors

errors_chord = chord (f,a,b,x0,eps,n_max)
```

## Secant method

$$
q^k = \frac{f(x^k)-f(x^{k-1})}{x^k - x^{k-1}}
$$

$$
x^{k+1} = x^k - \frac{f(x^k)}{q^k}
$$

Note that this algorithm requirs **two** initial points

```python
def secant(f,x0,x00,eps,n_max):
    assert (f(x0) != 0 and f(x00) != 0)
    err = eps+1.
    errors = [err]
    it = 0
    x_p = x00
    x_n = x0
    x = 0
    while (err > eps and it < n_max):
        q = (f(x_n) - f(x_p))/(x_n - x_p)
        x = x_n - f(x_n)/q
        x_p = x_n
        x_n = x
        err = abs(f(x))
        errors.append(err)
        it += 1
    semilogy(errors)
    print(it)
    print(err)
    print(x)
    return errors

errors_secant = secant(f,x0,x00,eps,n_max)
```

## Fixed point iterations

$$
f(x)=0 \to x-\phi(x)=0
$$

$$
x^{k+1} = \phi(x^k)
$$

```python
def fixed_point(phi,x0,eps,n_max):
    err = eps + 1.
    errors = [err]
    it = 0
    x = x0
    while (it < n_max and err > eps):
        x = phi(x)
        err = abs(x - phi(x))
        errors.append(err)
        it += 1
    semilogy(errors)
    return errors

errors_fixed = fixed_point(phi,0.3,eps,n_max)

```

## Comparison

```python
# plot the error convergence for the methods
loglog(errors_bisect, label='bisect')
loglog(errors_chord, label='chord')
loglog(errors_secant, label='secant')
loglog(errors_newton, label ='newton')
#loglog(errors_fixed, label ='fixed')
_ = legend()
```

```python
# Let's compare the scipy implmentation of Newton's method with our..
```

```python
import scipy.optimize as opt
%time opt.newton(f, 1.0, f_prime, tol = eps)
```

```python

```


```
%matplotlib inline
from matplotlib.pyplot import plot, figure, gca, legend
```

```
from control import tf, rlocus, ss, ss2tf, step_response
from numpy import linspace, matrix, roots
from sympy import init_printing, Matrix, var, eye
init_printing()
```

# Comparación de RL contra Retroalimentación de Estado

## Root Locus

Tenemos el siguiente sistema retroalimentado:

$$
G(s) = \frac{4}{s(s+2)}
$$

```
G = tf([0, 0, 1],[1, 2, 0])
```

```
figure(figsize=(10, 10))
r, k = rlocus(G, linspace(0, 100, 1000))
ax = gca()
ax.set_xlim(-2.5, 0.5)
ax.set_ylim(-3.0, 3.0);
```

Tenemos como parametros deseados $\zeta = 0.5$ y $\omega_n = 4$, para los cuales habiamos considerado anteriormente el siguiente compensador:

$$
G_C(s) = \frac{s + 2.9}{s + 5.4}
$$

```
Gc = tf([0, 1, 2.9], [0, 1, 5.4])
K = tf([0, 0, 4], [0, 0, 1])
```

```
figure(figsize=(10, 10))
r, k = rlocus(Gc*K*G, linspace(0, 100, 1000))
ax = gca()
ax.set_xlim(-6.5, 0.5)
ax.set_ylim(-4.0, 4.0);
```

Y graficando ambos juntos:

```
figure(figsize=(10, 10))
r, k = rlocus(G, linspace(0, 1000, 10000))
r, k = rlocus(Gc*K*G, linspace(0, 1000, 10000))
ax = gca()
ax.set_xlim(-6.5, 0.5)
ax.set_ylim(-4.0, 4.0);
```

```
print G*Gc*K
```

          4 s + 11.6
    ----------------------
    s^3 + 7.4 s^2 + 10.8 s

Y si quieremos obtener la respuesta ante una señal tipo escalon tendremos:

```
Gf = G.feedback(tf([1],[1]))
Gtf = (G*Gc*K).feedback(tf([1],[1]))
```

```
tf, yf = step_response(Gf, T=linspace(0, 10, 1000))
tt, yt = step_response(Gtf, T=linspace(0, 10, 1000))
```

```
figure(figsize=(10, 10))
p1, = plot(tf, yf)
p3, = plot(tt, yt)
legend([p1, p3], [r"$G_F$", "$k G_c G_F$"]);
```

## Representación en Espacio de Estados

Ahora, para analizar el mismo sistema con su representación de estado, primero lo tenemos que llevar a esta representación:

Primero notamos que el grado de numerador es $m = 0$ y que el grado del denominador es $n = 2$, por lo que, de acuerdo a nuestra forma normal para las Ecuaciones Diferenciales Ordinarias, tenemos que los coeficientes de la EDO son:

$$
\frac{y(s)}{u(s)} =
\frac{b_0 s^m + b_1 s^{m-1} + ... + b_{m-1} s + b_m}{s^n + a_1 s^{n-1} + ... + a_{n-1} + a_n}
$$

Si $G(s) = \frac{4}{s^2 + 2s}$, entonces:

$$
b_0 = 4
$$

$$
a_1 = 2 , \quad a_2 = 0
$$

Por lo que tenemos que nuestra representación de estado será la siguiente:

$$
\frac{d}{dt}
\begin{pmatrix}
    x_1 \\
    x_2
\end{pmatrix} =
\begin{pmatrix}
    0 & 1 \\
    0 & -2
\end{pmatrix}
\begin{pmatrix}
    x_1 \\
    x_2
\end{pmatrix} +
\begin{pmatrix}
    0 \\
    1
\end{pmatrix} u
$$

$$
y =
\begin{pmatrix}
    4 & 0
\end{pmatrix}
\begin{pmatrix}
    x_1 \\
    x_2
\end{pmatrix}
$$

```
var("s")
```

```
A = Matrix([[0, 1], [0, -2]])
b = Matrix([[0],[1]])
c = Matrix([[4], [0]])
d = 0
```

Podemos obtener su función de transferencia con la siguiente formula:

$$
F(s) = c^T (sI - A)^{-1} b
$$

```
G = c.T*(s*eye(2) - A).inv()*b
```

```
G[0]
```

Y obtenemos su polinomio caracteristico con:

$$
P(s) = |s I - A|
$$

y sus polos con los eigenvalores de $A$.

```
Pc = (s*eye(2) - A).det()
Pc
```

```
A.eigenvals()
```

Los ceros del sistema se pueden obtener con el determinante de la matriz sistema.

$$
\Sigma (s) =
\begin{pmatrix}
    sI - A & b \\
    -c^T & d
\end{pmatrix}
$$

```
S =((s*eye(2) - A).row_join(b)).col_join((-c.T).row_join(Matrix([d])))
S
```

```
S.det()
```

### Retroalimentación de Estado

Para obtener el comportamiento deseado (coeficiente de amortiguamiento $\zeta = 0.5$ y frecuencia natural de $\omega_n = 4$), tenemos la siguiente función de transferencia.

$$G_C = \frac{1}{s^2 + 2 \zeta \omega_n s + \omega_n^2}$$

por lo que podemos calcular la función de transferencia deseada facilmente:

```
zeta = 0.5
omega = 4
1/(s**2 + 2*zeta*omega*s + omega**2)
```

Por lo que sabemos, que los coeficientes deseados para $a_1$ y $a_2$ son $4$ y $16$. Pero recordemos que en el sistema tenemos:

$$a_1 = 2 \quad y \quad a_2 = 0$$

por lo que nuestro vector de retroalimentación $f$ será:

$$
f =
\begin{pmatrix}
    0 - 16 \\
    2 - 4
\end{pmatrix} =
\begin{pmatrix}
    -16 \\
    -2
\end{pmatrix}
$$

```
f = Matrix([[-16], [-2]])
f
```

Y ahora tan solo tenemos que aplicar lo siguiente:

$$
A_f = A + b f^T
$$

```
Af = A + b*f.T
Af
```

Y de nuevo podemos obtener sus polos con los eigenvalores de $A_f$:

```
Af.eigenvals()
```

Su función de transferencia:

```
Gc = c.T*(s*eye(2) - Af).inv()*b
Gc
```

Tenemos que calcular tambien $c_f^T = c^T + d f^T$ y $b$ y $d$, permanecen igual.

```
cf = c + d*f
cf
```

Y ahora integramos la matriz sistema y podemos obtener sus ceros:

```
Sf =((s*eye(2) - Af).row_join(b)).col_join((-cf.T).row_join(Matrix([d])))
Sf
```

```
Sf.det()
```

Si queremos gráficar el comportamiento de este sistema con retroalimentación de estado con el escalon unitario, tenemos que declarar el sistema de espacio de estados:

```
# Volvemos a definir las matrices, pero como arreglos de numpy
# debido a que el modulo de control no sabe que hacer con las
# variables simbolicas
A = matrix([[0, 1], [0, -2]])
b = matrix([[0], [1]])
c = matrix([[4], [0]])
d = 0
f = matrix([[-16], [-2]])
Af = A + b*f.T
cf = c + d*f
# Se agrega una ganancia de 4 a la entrada del sistema, debido
# a que crece la norma del denominador, y por la tanto se atenua
# la salida del sistema.
SR = ss(Af, 4*b, cf.T, d)
```

```
tr, yr = step_response(SR, T=linspace(0, 10, 1000))
```

```
figure(figsize=(15, 8))
p1, = plot(tf, yf)
p2, = plot(tr, yr)
p3, = plot(tt, yt)
legend([p1, p2, p3], [r"$G_F$", r"$SS$", "$k G_c G_F$"]);
```

### Observador de estado

No siempre es posible tener acceso al estado del sistema, por lo que es importante saber como estimar el valor del estado. Esto se hace por medio de un observador.

Un observador de estado, basicamente es una copia de la planta del sistema, la cual alimentamos con la salida del sistema original $y(s)$ y nos devuelve el estado del sistema $x(s)$.

```

```


(sec:DFT)=
# Diskretna Fourierova transformacija

Predhodno smo spoznali Fourierovo transformacijo za vzorčene podatke (glejte: {ref}`sec:enakomerno_casovno_vzorcenje`), kjer je vzorčenje s korakom $\Delta t\,n$ in $n\in\mathbb{Z}$ gre od $-\infty$ do $+\infty$. Kadar imamo končno mnogo vzorčenih podatkov ($N$) in gre $n=[0,1,\dots,N-1]$, uporabimo diskretno Fourierovo transformacijo (DFT).

:::{note}

**Diskretna Fourierova transformacija**:

$$
X_k = \sum_{n=0}^{N-1} x_n\,e^{-\mathrm{i}\,2\pi\,k\,n/N},
$$

kjer velja $x_n = x(n\,\Delta t)$ in $X_k=X(k/(N\,\Delta t))$. Ker je DFT periodična z $1/\Delta t$ ($X_k=X_{k+N}$), je treba izračunati samo $N$ členov, torej $k=[0,1,\dots\,N-1]$.

:::

Da velja $X_k=X_{k+N}$ dokažemo z:

$$
e^{-\mathrm{i}\,2\pi\,k\,n/N}=e^{-\mathrm{i}\,2\pi\,n\,(k+N)/N}=e^{-\mathrm{i}\,2\pi\,n\,k/N}\,\underbrace{e^{-\mathrm{i}\,2\pi\,n\,N/N}}_{=\cos(-2\pi\,n)=1}
$$

Inverzno DFT izpeljemo tako, da zgornjo enačbo pomnožimo z $e^{\mathrm{i}\,2\pi\,k\,r/N}$ in nato seštejemo po $k$:

$$
\sum_{k=0}^{N-1}X_k\,e^{\mathrm{i}\,2\pi\,k\,r/N} = \sum_{k=0}^{N-1}\sum_{n=0}^{N-1} x_n\,e^{-\mathrm{i}\,2\pi\,k\,n/N}\,e^{\mathrm{i}\,2\pi\,k\,r/N},
$$

Kjer na desni strani zamenjamo vrstni red vsote in ugotovimo:

$$
\sum_{k=0}^{N-1} e^{-\mathrm{i}\,2\pi\,k\,(n-r)/N}=
\begin{cases}
N; \quad n=r\\
0; \quad\textrm{sicer.}
\end{cases}
$$

Sledi:

$$
\sum_{k=0}^{N-1}X_k\,e^{\mathrm{i}\,2\pi\,k\,r/N} = x_r\,N,
$$

:::{note}

**Inverzna diskretna Fourierova transformacija**:

$$
x_n = \frac{1}{N} \sum_{k=0}^{N-1} X_k\,e^{\mathrm{i}\,2\pi\,k\,n/N}.
$$
:::

Ker velja:

$$
e^{\mathrm{i}\,2\pi\,k\,n/N}=e^{\mathrm{i}\,2\pi\,k\,(n+N)/N}=e^{\mathrm{i}\,2\pi\,k\,n/N}\,\underbrace{e^{\mathrm{i}\,2\pi\,k\,N/N}}_{=\cos(2\pi\,k)=1}.
$$

(DFT_periodicno)=

:::{note}

Ugotovimo, da je $x_n$ periodična vrsta $x_n=x_{n+N}$. Res smo začeli z vzorčenimi podatki v času $x_n$, ki niso periodični; rezultat z inverzno DFT pa je periodičen.

:::

## Hitra Fourierova transformacija

Hitra Fourierova transformacija (ang. *Fast Fourier Transform* - FFT) je ime numeričnega algoritma za diskretno Fourierovo transformacijo. DFT ima numerični obseg proporcionalen z $N^2$, kar zapišemo kot: ($\mathcal{O}(N^2)$). Kot avtorja hitre Fourierove transformacije štejeta James Cooley in John Tukey, ki sta algoritem objavila v letu 1965 (glejte: [vir](https://www.ams.org/journals/mcom/1965-19-090/S0025-5718-1965-0178586-1/S0025-5718-1965-0178586-1.pdf)), pozneje pa se je izkazalo, da je podoben algoritem že bistveno prej, v neobjavljenem delu iz leta 1805, odkril Carl Friedrich Gauss. Algoritem ima to bistveno lastnost, da je numerično bistveno hitrejši: $\mathcal{O}(N\log(N))$; ker numerični obseg več ne narašča kvadratično s številom elementov, je to bistveno povečalo uporabnost DFT in nekateri algoritem FFT smatrajo kot enega najbolj pomembnih algoritmov, saj je imel in še ima zelo pomemben družbeno-ekonomski vpliv. Kot zanimivost lahko tukaj povemo, da algoritem ni bil patentiran (glejte: [vir](https://en.wikipedia.org/wiki/Fast_Fourier_transform)). Zaradi simetrije v podatkih, je hitrost izračuna najvišja, če je število vzorcev enako $2^n$, kjer $n\in\mathbb{Z}$.

Diskretna Fourierova transformacija je v paketu `numpy` dokumentirana [tukaj](https://numpy.org/doc/stable/reference/routines.fft.html) in si jo bomo najprej pogledali na spodnjem primeru sinusuide z amplitudo `A`, frekvenco `fr`, frekvenco vzorčenja `fs` in dolžino vzorčenja, ki bo enaka frekvenci vzorčenja (vzorčili bomo torej 1 sekundo). DFT bomo izvedli z metodo `numpy.fft.fft()` ([dokumentacija](https://numpy.org/doc/stable/reference/generated/numpy.fft.fft.html#numpy.fft.fft))

```python
import numpy as np

A = 1
fr = 5
fs = 100
N = 100
dt = 1/fs
t = np.arange(N)*dt
x = A*np.sin(2*np.pi*fr*t)

X = np.fft.fft(x)
freq = np.fft.fftfreq(len(x), d=dt)
```

Sinusuida ima samo eno frekvenčno komponento različno od nič:

```python
(X[freq==fr], np.abs(X[freq==fr]))
```

    (array([-1.62040942e-14-50.j]), array([50.]))

```python
import matplotlib.pyplot as plt

plt.title('Časovna domena')
plt.plot(t, x, '.')
plt.xlabel('Čas [s]')
plt.ylabel('$x$')
plt.show()

plt.title('Frekvenčna domena - amplitudni spekter')
plt.plot(freq, np.abs(X), '.')
plt.xlabel('Frekvenca [Hz]')
plt.ylabel('$X$')
plt.show()
```

## Fourierove vrste in diskretna Fourierova transformacija

Diskretna Fourierova transformacija je s Fourierovimi vrstami zelo povezana. Povezavo lahko hitro razkrijemo, če predpostavimo diskretno časovno vrsto $x_i=x(\Delta t\,i)$, kjer je $\Delta t$ konstanten časovni korak, $i=0,1,\dots,N-1$ in $T_p=N\,\Delta t$, sledi:

$$
\begin{split}
c_k &= \frac{1}{T_p}\,\int_0^{T_p} x(t)\,e^{-\mathrm{i}\,2\pi\,k\,t/T_p}\,\mathrm{d}t\\
&= \frac{1}{N\,\Delta t}\,\sum_{n=0}^{N-1} x(n\,\Delta t)\,e^{-\mathrm{i}\,2\pi\,k\,\Delta t\,n/(N\,\Delta t)}\,\Delta t\\
&= \frac{1}{N}\,\underbrace{\sum_{n=0}^{N-1} x(n\,\Delta t)\,e^{-\mathrm{i}\,2\pi\,k\,n/N}}_{X_k}\\
&= \frac{X_k}{N}\\
\end{split}
$$

Poudarti je treba, da v splošnem $X_k/N\ne c_k$, saj DFT temelji na končni vrsti in je zato $X_k$ periodična vrsta.

Pri primerjanju DFT in Fourierovih vrst se pogosto naredi napaka pri razumevanju periode (predvsem zadnje diskretne točke). Pri zgornjem primeru sinusoide časovna točka pri `t=1` eksplicitno ni vključena; implicitno pa je, saj smo zgoraj spoznali, da so vzorčeni podatki periodični in velja $x_n=x_{n+N}$. Poglejmo ta detajl pobližje; Fourierove vrste so definirana kot:

$$
c_n = \frac{1}{T_p}\,\int_0^{T_p} x(t)\,e^{-\mathrm{i}\,2\pi\,n\,t/T_p}\,\mathrm{d}t
$$

Izračunamo Fourierov koeficient za frekvenco `fr`:

```python
import sympy as sym

t, fr, Tp, A = sym.symbols('t, fr, Tp, A')
π = sym.pi
i = sym.I

podatki = {fr: 5, A:1, Tp:1}

x = sym.sin(2*π*fr*t)
c = 1/Tp*sym.integrate(x*sym.exp(-i*2*π*fr*t/Tp), (t,0,Tp))

c.subs(podatki)
```

$\displaystyle - \frac{i}{2}$

Do istega rezultata pridemo prek DFT, vendar zadnja časovna točka eksplicitno ni vključena:

```python
import numpy as np

A = 1
fr = 5
fs = 100
N = 100
# spodnja fs rezultira v vključenost zadnje točke. Rezultat bo napačen!
#fs = 100/1.01010101010101
dt = 1/fs
t = np.arange(100)*dt
x = A*np.sin(2*np.pi*fr*t)

X_r = np.fft.rfft(x)
freq_r = np.fft.rfftfreq(len(x), d=dt)
```

```python
c = X_r[freq_r==fr] / len(x)
c
```

    array([-1.49438567e-16-0.5j])

Prepričajmo se, da zadnja časovna točka eksplicitno ni vključena:

```python
t[-3:]
```

    array([0.97, 0.98, 0.99])

Če v kodi zgoraj spremenimo `fs` ali število točk tako, da je zadnja točka eksplicitno vključena, rezultat ne bo enak tistemu iz Fourierovih vrst!

## Frekvenčna ločljivost in dodajanje ničel

Frekvenčna ločljivost DFT je definirana z dolžino diskretne časovne vrste $x_i=x(\Delta t\,i)$, kjer je $\Delta t$ konstanten časovni korak, $i=0,1,\dots,N-1$; dolžina take vrste je torej $T_p=N\,\Delta t$, zato sledi, da je frekvenčna ločljivost:

$$
\Delta f= \frac{1}{N\,\Delta t}.
$$

Z daljšo časovno vrsto, bi lahko imeli tudi boljšo frekvenčno ločljivost. Kadar dodatnih točk diskretne vrste ne moremo pridobiti, lahko frekvenčno ločljivost povečamo z dodajanjem ničel:

$$
\tilde{x}_n=
\begin{cases}
x_n;\quad & 0\le n\le N-1\\
0;\quad & N\le n\le L-1
\end{cases}
$$

sledi:

:::{note}

**Diskretna Fourierova transformacija z dodajanjem ničel** (ang. *zero-padding*):

$$
\tilde{X}_k = \sum_{n=0}^{L-1} \tilde{x}_n\,e^{-\mathrm{i}\,2\pi\,k\,n/L}
= \sum_{n=0}^{N-1} \tilde{x}_n\,e^{-\mathrm{i}\,2\pi\,k\,n/L}.
$$

:::

Na takšen pridobimo frekvenčno ločjivost:

$$
\Delta f = \frac{1}{L\,\Delta t},
$$

pri tem pa je treba poudariti, da gre samo za frekvenčno interpolacijo, ki nam omogoča bolj podroben vpogled; novih informacij z dodajanjem ničel ne dodajamo. Podobno kakor pri DFT, lahko ničle dodajamo v frekvenčni domeni in nato z inverzno diskretno Fourierovo transformacijo pridobimo bolj goste oz interpolirane časovne podatke.

Pri dodajanju ničel je potrebno paziti na normiranje podatkov (TBA).

Spodnji primer prikazuje uporabo dodajanja ničel; s pomočjo komentarjev v kodi raziščite delovanje.

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

A = 1
fr = 5
fs = 100
N = 25 # tukaj poskusite z 20 (sinusuida se zaključi; rezultat brez dodajanja niče je točen!)
k = 10 # dodajanje ničel
dt = 1/fs
t = np.arange(N)*dt
x = A*np.sin(2*np.pi*fr*t)

X = np.fft.fft(x)
freq = np.fft.fftfreq(len(x), d=dt)

X_kx = np.fft.fft(x, n=k*N)
freq_kx = np.fft.fftfreq(k*N, d=dt)

plt.title('Časovna domena')
plt.plot(t, x, '.')
plt.xlabel('Čas [s]')
plt.ylabel('$x$')
plt.show()

plt.title('Frekvenčna domena - amplitudni spekter')
plt.plot(freq_kx, np.abs(X_kx), '.', label=f'{k}x dodajanje ničel')
plt.plot(freq, np.abs(X), '.', label='Brez dodajanja ničel')
plt.xlabel(f'Frekvenca [Hz], $\Delta f=${1/(N*dt):3.2f}')
plt.ylabel('$X$')
plt.legend()
plt.show()
```

## Simetrija DFT za realne podatke

:::{note}

Za realne podatke v frekvenčni domeni velja:

$$
\begin{split}
\textrm{Re}\big(X(k)\big)&=\textrm{Re}\big(X(N-k)\big)\\
\textrm{Im}\big(X(k)\big)&=-\textrm{Im}\big(X(N-k)\big)\\
|X(k)|&=|X(N-k)|\\
\angle X(k)&=\angle X(N-k)
\end{split}
$$

:::

Ker je večina inženirskih podatkov v časovni domeni realnih, je smiselno, da se uporabi ustrezno prilagojena metoda hitre Fourierove transformacije; ta se v paketu `numpy` dostopa prek klica `numpy.fft.rfft()`. Inverzna DFT je dosegljiva prek metod `numpy.fft.ifft` oz. `numpy.fft.irfft` v primer realnih podatkov v časovni domeni.

Navedene lastnosti preverimo na kodi v paketu `numpy` (opomba: k časovnim signalom dodamo šum, da ni faza zelo blizu 0):

```python
import numpy as np

A = 1
fr = 5
fs = 100
N = 10
dt = 1/fs
t = np.arange(N)*dt
np.random.seed(0)
x = A*np.sin(2*np.pi*fr*t) + np.random.normal(scale=A/2, size=N)

X = np.fft.fft(x)
freq = np.fft.fftfreq(len(x), d=dt)

X_r = np.fft.rfft(x)
freq_r = np.fft.rfftfreq(len(x), d=dt)

np.testing.assert_allclose(freq[1:N//2], -freq[:N//2:-1])
np.testing.assert_allclose(np.real(X[1:N//2]), np.real(X[:N//2:-1]))
np.testing.assert_allclose(np.imag(X[1:N//2]), -np.imag(X[:N//2:-1]))

np.testing.assert_allclose(np.abs(freq[:N//2+1]), freq_r)
np.testing.assert_allclose(np.real(X[:N//2+1]), np.real(X_r))
np.testing.assert_allclose(np.imag(X[:N//2+1]), np.imag(X_r), atol=1e-15)
```

Še primer od zgoraj z `numpy.fft.rfft()`:

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

A = 1
fr = 5
fs = 100
N = 25 # tukaj poskusite z 20 (sinusuida se zaključi; rezultat brez dodajanja niče je točen!)
k = 10 # dodajanje ničel
dt = 1/fs
t = np.arange(N)*dt
x = A*np.sin(2*np.pi*fr*t)

X = np.fft.rfft(x)
freq = np.fft.rfftfreq(len(x), d=dt)

X_kx = np.fft.rfft(x, n=k*N)
freq_kx = np.fft.rfftfreq(k*N, d=dt)

plt.title('Časovna domena')
plt.plot(t, x, '.')
plt.xlabel('Čas [s]')
plt.ylabel('$x$')
plt.show()

plt.title('Frekvenčna domena - amplitudni spekter')
plt.plot(freq_kx, np.abs(X_kx), '.', label=f'{k}x dodajanje ničel')
plt.plot(freq, np.abs(X), '.', label='Brez dodajanja ničel')
plt.xlabel(f'Frekvenca [Hz], $\Delta f=${1/(N*dt):3.2f}')
plt.ylabel('$X$')
plt.legend()
plt.show()
```

(sec:krozna_konvolucija)_
## Konvolucija periodičnih podatkov

Konvolucijo funkcij v primeru Fourierove integralske transformacije smo obravnavali v poglavju {ref}`sec:konvolucija_funkcij`, tukaj si bomo pogledali posebnosti pri obravnavi dveh periodičnih vrst (glejte: {ref}`DFT_periodicno` ) enake dolžine: $x_n$ in $y_n$:

$$
X_k = \sum_{n=0}^{N-1} x_n\,e^{-\mathrm{i}\,2\pi\,k\,n/N},
$$

$$
Y_k = \sum_{n=0}^{N-1} y_n\,e^{-\mathrm{i}\,2\pi\,k\,n/N},
$$

Podobno kakor za funkcije, velja tudi za periodične časovne vrste, da je DFT konvolucije v časovni domeni produkt frekvenčnih transformirank v frekvenčni domeni.

:::{note}

Konvolucija periodičnih podatkov (tudi krožna konvolucija, ang. *circular convolution*):

$$
\textrm{DFT}\big\{x_n*y_n\big\}=X_k\,Y_k,
$$

Da se poudari, da gre za krožno konvolucijo, se kdaj uporabi tudi znak $\circledast$: $x_n\circledast y_n$.

:::

Poudarjanje periodičnosti $x_n$ n $y_n$ je nujno sicer (krožna) konvolucija ni mogoča; to bo jasno iz sledeče izpeljave:

$$
\begin{split}
\textrm{DFT}\big\{x_n\circledast y_n\big\}&=\textrm{DFT}\big\{\sum_r^{N-1}x_r\,y_{n-r}\big\}\\
&=\sum_n^{N-1}\sum_r^{N-1}x_r\,y_{n-r}\,e^{-\textrm{i}\,2\,\pi\,n\,k/N}\\
&=\underbrace{\sum_r^{N-1}x_r\,\,e^{-\textrm{i}\,2\,\pi\,r\,k/N}}_{X_k}\,\underbrace{\sum_n^{N-1}y_{n-r}\,e^{-\textrm{i}\,2\,\pi\,(n-r)\,k/N}}_{Y_k}\\
&=X_k\,Y_k
\end{split}
$$

Pri zgornji izpeljavi je treba poudariti, da vsota po $r$ v predzadnji vrstici vključuje tudi vsoto po $n$, ker pa je vsota po $n$ zaradi periodičnosti neodvisna od $r$, se lahko vsoti po $r$ in $n$ izvedeta neodvisno. Zaradi periodičnosti $y_n$, za vsak $r$ torej velja:

$$
\sum_n^{N-1}y_{n-r}\,e^{-\textrm{i}\,2\,\pi\,(n-r)\,k/N}=\sum_n^{N-1}y_{n}\,e^{-\textrm{i}\,2\,\pi\,n\,k/N}=Y_k
$$

Če vrsti $x_n$ in $y_n$ ne bi bili periodični, konvolucija končnih vrst ne bi bila tako enostavna.

notebooks/06 - Diskretna Fourierova transformacija.ipynb


# Raumluftqualität 2.0

## Zeitliche Entwicklung der CO_2-Konzentration in Räumen

In einem gut gelüfteten, leeren Raum wird sich zunächst genau so viel CO_2 befinden, wie in der Außenluft.

Wenn sich dann Personen in den Raum begeben und CO_2 freisetzen, wird die CO_2-Konzentration langsam zunehmen. Auf welchen Wert sie sich schließlich einstellt, hängt vom Außenluftvolumenstrom ab, mit dem der Raum belüftet wird.

Bei einem völlig unbelüfteten Raum wird das von den Personen produzierte CO_2 sich in der Raumluft immer stärker anreichern, wobei je Zeiteinheit die gleiche Menge an CO_2 freigesetzt wird.

### Beispiel:

In einem Raum von $15 \rm m^2$ Grundfläche bei $2.5 \rm m$ Geschosshöhe befinden sich 2 Personen, die je Person $30\,{\frac{\ell}{h}}$ CO_2 ausatmen. Die CO_2-Konzentration der Außenluft ist 400 ppM. Im Raum sollen 1200 ppM CO_2 zulässig sein.

Stellen Sie die zeitliche Entwicklung der CO_2-Konzentration in einem Diagramm dar.

Gegeben:

Raumvolumen: $V_{\rm ra} = 15 {\rm m^2}\cdot 2.5 {\rm m} = 37.5 {\rm m^3}$

CO_2-Produktion: $\dot V_{\rm sch} = 2\cdot 30 {\rm \dfrac{\ell}{h}} = 60\,000\, {\rm\dfrac{cm^3}{h}}$

Damit ergibt sich die Änderungsrate
$
  \dot k = \cfrac{\dot V_{\rm sch}}{V_{\rm ra}}
         = {\rm\dfrac{60\,000\,cm^3}{37.5\, m^3\cdot h}}
         = {\rm 1600 \dfrac{ppM}{h}}
$

Für das Schadstoffvolumen im Raum ergibt sich:

\begin{align}
  k(t)&= 400 {\rm ppM} + 1600\,{\rm\dfrac{ppM}{h}}\, t
\end{align}

Dies Ergebnis wird in den folgenden Zeilen in einem Diagramm dargestellt:

```python
import matplotlib.pyplot as plt
%config InlineBackend.figure_format = 'retina'

import pandas as pd
import numpy as np
```

```python
lt = np.linspace(0,120,13) # 10-min Schritte
df = pd.DataFrame(
    {
        't': lt,
        'k': 400 + 1600*lt/60 # 60min = 1h
    }
)

display(df.T)
ax=df.plot(x='t',y='k', label='$k = k(t)$')
ax.axhline(1200,c='r')
ax.grid()
ax.set(
    xlim=(0,120),xlabel='Zeit $t$ in $min$',
    ylabel='CO_2-Konzentration $k$ in $\mathrm{ppM}$'
);
```

Die zulässige CO_2-Konzentration wird bereits nach kurzer Zeit (etwa 30 min) erreicht. Nach etwa einer Stunde ist die Raumluftqualität inakzeptabel.

### Aufgabe

In einem Gebäude ($400 \rm m^2$ Grundfläche, $3.50 \rm m$ Raumhöhe) arbeiten 120 Personen körperlich mittelschwer belastet. Berechnen Sie unter der Voraussetzung, dass das Gebäude nicht belüftet wird, wie sich die CO_2-Konzentration im Raum entwickelt. Die CO_2-Konzentration der Außenluft beträgt 400 ppM.

Nach welcher Zeit wird die zulässige CO_2-Konzentration von 1200 ppM überschritten?

Stellen Sie den Vorgang in einem Diagramm dar.