# **7.1 Propagation of a straight crack**

[](https://moodle.rwth-aachen.de/mod/page/view.php?id=643780) part 1

        Starting point

In the notebook [6.3](../tour6_energy/6_3_localized_energy_dissipation.ipynb#top) we have analyzed the correspondence between the damage function inducing softening and the propagation of discontinuity macroscopically observable as debonding in a pullout test. We mentioned the correspondence between the locally and globally energy evaluated energy dissipation indicating the energy dissipated at a material point can be used to estimate the total amount of dissipated energy by relating it to a localized crack of a known known surface area. In case of an interface area between two material components, like CFRP sheet and concrete, the area is well defined.
Let us now apply the same concept to a crack propagation through a concrete specimen. Even though additional aspects, like two-dimensional stress and strains states, need to be considered, the general concept of energy dissipation introduced previously remains the similar.

        Where are we heading

In the present notebook we apply the damage model inducing material softening to a two-dimensional case of stable crack propagation. In particular, we are going to use the three-point bending test as a means of  rendering an elementary crack-propagation scenario, namely a straight crack propagating through a cross section. After discussing the test setup, we provide the test results in terms of load-deflection curve. Then, we will construct a numerical model that can reproduce such a behavior. Similarly to the previous lecture, we will consider the link between fracture and damage concepts that are both included in any finite element computation involving strain-softening material behavior

# **Why notched three-point bending test?**

An isolated tensile crack propagation can be initiated using a notched specimen. The most common configurations used to study the cracking behavior for a tensile crack denoted as mode I are the wedge splitting test and the notched, three-point bending test. Both these tests aim to characterize the material behavior in terms of the softening law describing the relation between the tensile stress transmitted across the localization zone and the corresponding crack opening.

Due to its simplicity, three-point-bending test on a notched concrete has become a standard (RILEM) to determine the fracture energy $G_\mathrm{f}$ characterizing the cracking behavior of concrete. The test induces a single crack in the notched section propagating straight upwards from the notch in a stable manner. The energy is dissipated in a local region in the crack vicinity so that it can be directly ascribed to the area of emerging crack surface.

The numerical simulation of this model can be readily performed using the material model with the damage function presented in notebook [5.1](../tour5_damage_bond/5_1_Introspect_Damage_Evolution_Damage_initiation.ipynb). An example of the geometry and boundary conditions of the three-point bending test is provided by the  [Petersson (1982)](https://portal.research.lu.se/portal/files/4705811/1785208.pdf) test series using the following setup.

# **Boundary value problem and its FE discretization**

The numerical model simulating this test is assuming a [plain stress](https://en.wikipedia.org/wiki/Plane_stress) described by the stress tensor with the enumeration of spatial dimensions using the index variables $a, b$ = [1,2]
$$
\sigma_{ab}
=
\left[
\begin{array}{cc}
\sigma_{11} & \sigma_{12} \\
\sigma_{21} & \sigma_{22}
\end{array}
\right]
$$
and $\sigma_{33} = \sigma_{13} = \sigma_{23} = 0$. The finite element discretization in this model applies the symmetry condition at the middle section of the beam. Upon loading loading, the damage will localize
at the tip of the notch and propagate upwards.

The discretization is using quadrilateral finite elements with bilinear shape functions. A fineness of the regular mesh is defined by the number of elements in $x$ and $y$ directions denoted as `n_e_x` and `n_e_y`, respectively. A maximum deflection is given by the parameter `w_max`. The maximum number of iterations to find an equilibrium within a single load increment is controlled by the parameter `k_max`.

```python
%matplotlib widget
from bmcs_bending.bending3pt_2d import BendingTestModel
from ibvpy.tmodel.mats2D import MATS2DScalarDamage
bt = BendingTestModel(material_model='scalar damage',
                      n_e_x=6, n_e_y=16, w_max=-2, k_max=500)
bt.time_line.step=0.03
bt.history.warp_factor=100 # multiplier for displacement plotting
bt.cross_section.trait_set(B=50)
bt.geometry.trait_set(L=2000, H=200, a=100, L_cb=1);
```

# **Material model**

Elastic material matrix is defined with the parameters `E` and `nu`. Note that the lateral
deformation is switched off by setting `nu = 0.0` here. This choice is induced by the need to
avoid the spurious failure of the compression zone in the final stage of simulation.
This "numerical trick" is justified by the need to keep the model simple. The damage evolution in response to tensile strain is reflected relatively well, so that the model is sufficient to demonstrate the evaluation of fracture energy upon tensile crack propagation.

```python
E = 30000
bt.material_model_.trait_set(E = E, nu = 0.0); # note nu = 0.0 to avoid compressive failure
```

## Damage function

The exponential damage function with the two parameters $\kappa_0$ defining the onset of damage and $\kappa_\mathrm{f}$ controlling the slope of the exponential softening branch at the onset of damage presented in notebook [5.1](../tour5_damage_bond/5_1_Introspect_Damage_Evolution_Damage_initiation.ipynb#exp_slope). Assuming the concrete tensile strength $f_\mathrm{ct} = 3.3 \mathrm{[MPa]}$, we can set the parameters $\kappa_0$ and the slope of the softening branch $\kappa_\mathrm{f}$ as follows

```python
f_ct = 3.3
kappa_0 = f_ct / E
bt.material_model_.omega_fn = 'exp-slope'
bt.material_model_.omega_fn_.trait_set(kappa_0=kappa_0, kappa_f=0.02)
```

$\displaystyle \begin{cases} 0 & \text{for}\: \kappa \leq \kappa_{0} \\1 - \frac{\kappa_{0} e^{\frac{- \kappa + \kappa_{0}}{- \kappa_{0} + \kappa_\mathrm{f}}}}{\kappa} & \text{otherwise} \end{cases}$

To achieve a fast convergence of the nonlinear simulation, the tangential algorithmic stiffness will be activated by setting the parameter `D_alg = 1`. Note that `D_alg = 0` corresponds to the secant stiffness which requires large number of iterations but can cope with large stress redistribution within the simulated domain.

```python
bt.material_model_.trait_set(D_alg=1, eps_max=1);
```

The important feature of the provided damage model is the possibility to evaluate the fracture energy, i.e. the amount of energy to achieve a zero stress state. This value can be accessed as a `G_f` property of the material model.

```python
bt.material_model_.G_f
```

    0.06435413610343986

## Strain norm

[](https://moodle.rwth-aachen.de/mod/page/view.php?id=643780) part 2

The scalar damage material model can combine different measures of equivalent strain with a different type of damage function.
The task of the equivalent strain norm is to transform the two-dimensional strain tensor
$$
\varepsilon_{ab}
=
\left[
\begin{array}{cc}
\varepsilon_{11} & \varepsilon_{12} \\
\varepsilon_{21} & \varepsilon_{22}
\end{array}
\right]
$$
to a scalar value $\kappa$. The equivalent strain norms already mentioned in the notebook [5.1](../tour5_damage_bond/5_1_Introspect_Damage_Evolution_Damage_initiation.ipynb#strain_norm) play the role of an elastic limit. Their visual inspection
can be performed using the model component of the material model `MATSScalarDamage`.

```python
bt.material_model_.strain_norm = 'Rankine' # 'Masars', 'Energy'
bt.material_model_.strain_norm_.interact()
```

    VBox(children=(HBox(children=(VBox(children=(Tree(layout=Layout(align_items='stretch', border='solid 1px black…

Further strain norms can be chosen from the options
 - Rankine strain norm
 - Masar's strain norm
 - Energy norm

These norms have to be combined with an elastic threshold to distinguish the elastic and inelastic domains.
Inspect the visual representation of the equivalent strain measure by changing the selector above from `Rankine` to either `Masars` or `Energy`

The mathematical expression delivering the three provided strain norms look as follows:

**Masars strain norm:**
$$
 \kappa = \sqrt{ \sum_{I=1}^{3} \left< \varepsilon_I \right>^2 } =  \sqrt{ \left< \varepsilon_I \right>^2\delta_{II} }
$$
where $\varepsilon_I$ denotes the principal strains. The second version of the expression uses the index notation with a summation over an index that repeats in a product of two terms (see [Einstein summation rule](https://en.wikipedia.org/wiki/Einstein_notation)). The symbol $\delta_{II}$ represents an identity mapping called Kronecker delta, with $\delta_{IJ} = 1$ if $I=J$ and $\delta_{IJ} = 0$ otherwise.

**Rankine strain norm:**
$$
\kappa = \dfrac{1}{E} \max_{I=1}^{3} \sigma_I
$$
where $\sigma_I$ are the principal values of the stress tensor, i.e.
$$\sigma_{ab} = D^\mathrm{el}_{abcd} \varepsilon_{cd}
$$

**Energy norm**
$$
\kappa = \dfrac{1}{E} \sqrt{ \boldsymbol{\varepsilon} : \boldsymbol{D}^\mathrm{el}: \boldsymbol{\varepsilon} }
 = \dfrac{1}{E} \sqrt{ \varepsilon_{ab} D^\mathrm{el}_{abcd} \varepsilon_{cd} }
$$
representing circular or spherical shape of the elastic domain in 2D or 3D, respectively.

## The importance of algorithmic stiffness

The rate of convergence strongly depends on the quality of prediction of the stress state for the next step. The parameter `D_alg` can be used ton control the calculation by choosing between the secant and algorithmic, tangential stiffness. Derivation of the material stiffness might appear tedious at the first sight but in fact, it only requires the evaluation of the chain derivatives involved in the constitutive law.

Let us exemplify the derivation of the algorithmic material stiffness on the example of the isotropic damage model. In contrast to the version presented in notebook [5.1]() for the bond-slip law, we are applying the damage model to a 2D continuum described by the strain and stress tensor tensors $\boldsymbol{\varepsilon} = \varepsilon_{ab}$
and $\boldsymbol{\sigma} = \sigma_{ab}$, where letter indexes $a,b,c,d = [1,2]$ enumerate the orthogonal directions of the cartesian coordinate system. For example, $\varepsilon_{11} = \varepsilon_{xx}$. Then the stress-strain relation prescribed by the damage model is given as

\begin{align}
\sigma_{ab} = (1 - \omega(\kappa(\varepsilon_{cd}))) D_{abcd}^\mathrm{el} \varepsilon_{cd}
\end{align}

where $\kappa(\varepsilon_{cd})$ represents the equivalent strain described above, and $\omega(\kappa)$ represents the particular damage function. The algorithmic stiffness is derived as the derivative of the above stress tensor with respect to a strain tensor. This means that every stress component must be differentiated with respect to every strain component. The result of this expression is a rank 4 tensor $D_{abcd}$ describing the instantaneous stiffness of a material point undergoing damage. The resulting expression delivers the material stiffness in the following form

\begin{align}
D_{abcd} &= \dfrac{\mathrm{d} \sigma_{ab}}{\mathrm{d}\varepsilon_{cd}} \\
& =
\left(1 - \omega(\kappa(\varepsilon_{cd}))\right) D_{abcd}^\mathrm{el} -
\dfrac{\mathrm{d} \omega}{\mathrm{d}\kappa}
\dfrac{\partial \kappa}{\partial\varepsilon_{cd}} D_{abcd}^\mathrm{el} \, \varepsilon_{cd} \, \theta_\mathrm{alg,stiff}
\end{align}

The parameter `D_alg` is denoted here as $\theta_{\mathrm{alg,stiff}}$ and is provided as an input parameter in the material model. By setting this parameter to zero, the second term in the above expression is canceled. Then, only the secant stiffness is available. The importance of the algorithmic stiffness can be studied in the simulation of the bending test. The default value  $\theta_\mathrm{alg,stiff} = 1$ is activating the algorithmic stiffness. By setting $\theta_\mathrm{alg,stiff} = 0$ one can see the strong reduction of the performance.

However, in some situation, for example when simulating simultaneous crack propagation in reinforced specimens the tangential stiffness might induce divergence. The reason for such behavior is the fact that the bond stress can rapidly change the sign. In these situations, advanced control strategies are required.

# **Simulation example**

[](https://moodle.rwth-aachen.de/mod/page/view.php?id=643780) part 3

Fist, let us ensure that the `Rankine` strain norm is used in the model.

```python
bt.material_model_.strain_norm='Rankine'
bt.reset()
bt.run()
```

```python
bt.interact()
```

    Warning: Update progress bar from model - not implemented yet

    VBox(children=(HBox(children=(VBox(children=(Tree(layout=Layout(align_items='stretch', border='solid 1px black…

## Evaluation of energy dissipation

If a standard finite element discretization is combined with a softening material model,
 1. it will always induce localization into a band of finite elements at the propagating stress concentration.
 2. the area below the stress strain curve of a material model represents the energy dissipated by a unit volume of a material.

As a consequence, the total amount of the dissipated energy can be calculated as a product of the specific dissipation energy $G_\mathrm{f}$ $\mathrm{[J/mm^3]}$ and the volume of elements constituting the crack band $V_\mathrm{diss}$ $\mathrm{[mm^2]}$. In general,
$$
G_\mathrm{total} = G_\mathrm{f} V_\mathrm{diss}
$$
In a notched bending test, the crack band is running from the notch straight through the cross section. Therefore, it is possible to directly calculate the dissipative volume $V_\mathrm{diss}$ as
$$
 V_\mathrm{diss} = (H - a) B L_\mathrm{cb}
$$

```python
V_diss = (bt.geometry.H - bt.geometry.a)*bt.cross_section.B * bt.geometry.L_cb
V_diss
```

    5000.0

The specific energy $G_\mathrm{f}$ is available as an attribute of the material model. It has the value

```python
bt.material_model_.G_f # N/mm
```

    0.06435413610343986

Thus, the total dissipated energy should read

```python
G_total = V_diss * bt.material_model_.G_f
G_total
```

    321.7706805171993

The total dissipated energy is available in the `hist` model component as a last element of the recorded array, so that we can pick it using the index `-1`

```python
import numpy as np
bt.hist.W_t[-1]
```

    282.5506041549446

# **Parametric study**

[](https://moodle.rwth-aachen.de/mod/page/view.php?id=643780) part 4

What happens when we change something? In particular, what happens if we change the volume of the dissipative zone $V_\mathrm{diss}$?

        Run in a loop along the changing width of the crack band

```python
Fw_dict = {}
G_dict = {}
```

```python
L_cb_list = [1, 2, 4]
for L_cb in L_cb_list:
    print('calculating F-w and G for crack band L_cb = %g [mm]' % L_cb)
    bt.reset()
    bt.geometry.L_cb = L_cb
    bt.run()
    Fw_dict[L_cb] = bt.hist['Fw'].Fw
    G_dict[L_cb] = bt.hist['energy'].G_t[-1]
```

    calculating F-w and G for crack band L_cb = 1 [mm]
    calculating F-w and G for crack band L_cb = 2 [mm]
    calculating F-w and G for crack band L_cb = 4 [mm]

        ... let us put the results into a diagram

```python
import matplotlib.pylab as plt
fig, (ax, ax_G) = plt.subplots(1,2, figsize=(8,3), tight_layout=True)
fig.canvas.header_visible=False
for L_cb, (F, w) in Fw_dict.items():
    ax.plot(-w,-F,label='L_cb = %g' % L_cb)
ax.legend()
ax.set_xlabel(r'$w$ [mm]');
ax.set_ylabel(r'$F$ [N]');
G_list = [G_dict[L_cb] for L_cb in L_cb_list]
ax_G.plot(L_cb_list, G_list, marker='H')
ax_G.set_xlabel(r'$L_\mathrm{cb}$ [mm]')
ax_G.set_ylabel(r'$G_\mathrm{total}$ [kJ]');
ax_G.set_ylim(ymin=0, ymax=1.1 * np.max(G_list))
```

    Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …

    (0.0, 575.4982493017241)

        ... mesh dependent results

By changing the element size within the crack band we change the volume of material which dissipates energy. As observed, a larger element size leads to a larger amount of energy dissipation which makes the structural response more ductile. Why does it happen? The displacement approximated within a finite element is continuous function. Using low-order elements, with, e.g. bilinear shape functions, means that the strain within the element is of even lower order, i.e. constant or linear. Therefore, strain cannot localize within a smaller size than the size of the element through which the crack propagates. As a consequence, upon softening, an element exhibits a nearly uniform increasing strain which results in energy dissipation over its whole volume. The obtained results are therefore biased by the mesh size which documents an ill-posed model behavior.

There are several techniques how to avoid this mesh dependency. The principle idea of these so called regularization methods is to ensure a prescribed amount of energy dissipation upon localization. The most simple technique is sketched and demonstrated in the following parametric study.

# **How to make the results mesh independent?**

The finite element codes solve this problem by adjusting the slope of the softening branch to keep the energy dissipation within a crack band invariant for a changed size of an element. This is the most common technique used in commercial finite element codes.

The above examples explain that the correspondence between the local value of $G_\mathrm{f}$ and the total energy $G_\mathrm{total}$ is established by realizing that energy dissipation is linked with the crack propagation. Indeed, if $G_\mathrm{f}$ is related to a unit area of the crack surface, the total energy dissipation in the whole test with a localized crack of the area $V_\mathrm{diss}$ is
obtained as
$$
G_\mathrm{total} = G_\mathrm{f} V_\mathrm{diss}.
$$

In the above example, the crack was represented by a band of elements with the width specified by the $L_\mathrm{cb}$ model parameter. Since the crack developed over the whole notched cross section, we can express the dissipative volume as

$$
 V_\mathrm{diss} = (H - a) B L_\mathrm{cb}
$$

To ensure that the amount of dissipated energy for a changed $V_\mathrm{diss}$ due to different mesh size does not change, we need to correspondingly adjust effective value of dissipated energy in depending on $L_\mathrm{cb}$. In other words, we need to introduce
$$
G_\mathrm{f}^{L_\mathrm{cb}} = \dfrac{G_\mathrm{f}}{L_\mathrm{cb}}
$$
so that the total energy becomes independent on $L_\mathrm{cb}$
$$
 G_\mathrm{total} = G_\mathrm{f}^{L_\mathrm{cb}} (H-a) B L_\mathrm{cb}
 = G_\mathrm{f} (H-a) B.
$$

This scaling must be achieved by adjusting the material parameters of the model depending on the size of the discretization element $L_\mathrm{cb}$. In present model we use the exponential damage function with the two parameters $\kappa_0$ and $\kappa_\mathrm{f}$ controlling the onset of damage and the slope of the descending branch, respectively. The above specified scaling of energy dissipation can be achieved by scaling the parameter
$$
\kappa_{\mathrm{f}}^{L_\mathrm{cb}} = \dfrac{\kappa_{\mathrm{f}}}{L_\mathrm{cb}}
\implies
G_\mathrm{f}^{L_\mathrm{cb}} \approx \dfrac{G_\mathrm{f}}{L_\mathrm{cb}}
$$.

To verify, that this scaling correspondingly affects the effective amount of energy dissipation by evaluating the value of $G_{\mathrm{f}}^{L_\mathrm{cb}}$ for the values of $L_\mathrm{cb} = [1,2,3,4]$. These values should lead to the corresponding fractions [$1, \frac{1}{2}, \frac{1}{3}, \frac{1}{4}$] of $G_\mathrm{f}$. Assuming the reference value $\kappa_f$ corresponding to the length $L_\mathrm{cb} = 1$ we obtain

```python
kappa_f = 0.0336 # reference value of kappa for L_cb = 1
kappa_f_range = kappa_f / np.array([1,2,3,4])
G_f_range = []
for kappa_f_ in kappa_f_range:
    bt.material_model_.omega_fn_.kappa_f = kappa_0 + kappa_f_
    G_f_range += [bt.material_model_.G_f]
np.array(G_f_range) / G_f_range[0]
```

    array([1.        , 0.49416785, 0.32557841, 0.24129989])

Which roughly correspond to the desired fractions

Let us now apply this scaling by changing a single line in the parametric study. This line is denoted as `REGULARIZATION`.

```python
Fw_reg_dict = {}
G_reg_dict = {}
```

```python
L_cb_list = [1, 2, 4]
kappa_f = 0.0336
for L_cb in L_cb_list:
    print('calculating F-w and G_total for crack band L_cb = %g [mm]' % L_cb)
    bt.reset()
    bt.geometry.L_cb = L_cb # change the size of the crack band
    bt.material_model_.omega_fn_.kappa_f = kappa_f / L_cb #### REGULARIZATION ####
    print('with scaled effective specific dissipation energy G_f', bt.material_model_.G_f)
    try:
        bt.run()
    except StopIteration:
        print('simulation interupted due to slow convergence', L_cb)
    Fw_reg_dict[L_cb] = bt.hist['Fw'].Fw
    G_reg_dict[L_cb] = bt.hist['energy'].G_t[-1]
```

    calculating F-w and G_total for crack band L_cb = 1 [mm]
    with scaled effective specific dissipation energy G_f 0.1092317778327407
    calculating F-w and G_total for crack band L_cb = 2 [mm]
    with scaled effective specific dissipation energy G_f 0.053795255499048136
    calculating F-w and G_total for crack band L_cb = 4 [mm]
    with scaled effective specific dissipation energy G_f 0.02608239216637152

```python
import matplotlib.pylab as plt
fig, (ax, ax_G) = plt.subplots(1,2, figsize=(8,3), tight_layout=True)
fig.canvas.header_visible=False
for L_cb, (F, w) in Fw_reg_dict.items():
    ax.plot(-w,-F,label='L_cb = %g' % L_cb)
ax.legend()
ax.set_xlabel(r'$w$ [mm]');
ax.set_ylabel(r'$F$ [N]');
G_list = [G_reg_dict[L_cb] for L_cb in L_cb_list]
ax_G.plot(L_cb_list, G_list, marker='H')
ax_G.set_xlabel(r'$L_\mathrm{cb}$ [mm]')
ax_G.set_ylabel(r'$G_\mathrm{total}$ [kJ]');
ax_G.set_ylim(ymin=0, ymax=1.1 * np.max(G_list));
```

    Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …

Even though the difference in the predicted peak of the load deflection curve shown left is still apparent, the amount of dissipated energy up to the control displacement of $w=2$ mm does not change with the element size any more. Let us remark that the applied scaling of $\kappa_\mathrm{f}$ was only approximate. A more sophisticated scaling and a smaller size of load increment would be required to achieve even a better match of the load deflection curves.

The described kind of scaling can be found in the most common finite-element codes. In the present example, the position and profile of the crack was known a-priori. Finite element codes implementing material models with softening behavior must implement a general procedure for the described crack band regularization. This regularization is performed in each material point by including the size of the containing element and the orientation of principle strains to reflect the width of the softening zone correctly.

The described procedure can be regarded as a _quick fix_. It makes the standard finite-element method applicable to simulation cracking for problems with straight crack evolution, i.e. tensile cracks. It is, however, insufficient for crack propagation with non-negligible shear stress within the localization zone. Indeed, most of the commercially available software based on continuous finite-element approximation of the displacement field cannot correctly predict the shear crack propagation.  Pragmatic solutions, including an additional parameter like a shear retention factor, are provided in some software packages (ATENA).

More advanced methods address the problem of mesh independence using
 - Nonlocal averaging of state variables over a defined size of a process zone
 - Gradient methods of damage and plasticity including the gradient of state variables into the constitutive law

They are mentioned here for completeness and as an optional further reading.

**Note the dimensionality of $G_\mathrm{f}$:** To be precise, let us note that in contrast to the example shown in notebook [6.3](tour6_energy/6_3_localized_energy_dissipation.ipynb) for the CFRP pullout simulation, the damage function was applied to the bond-slip law, describing the behavior of a unit area of the interface. On the other hand, in the present notebook, we applied the damage function in a two-dimensional constitutive law.

      6.3 Softening and fracture energy

    7.2 Identification of fracture energy

```python

```


# Exam 2021

## Part 1 Theory (40 points)

1. List the important dimensionless numbers for a forced convection and explain the physical
meaning of the numbers and how they are used in finding the heat transfer coefficient for the
cross flow conditions for a cylinder. You should explain why these dimensionless numbers are
important and usefule for the real-world problems. (8 point)

  Nusselt number: Ratio of convection to pure conduction heat transfer

  $$ Nu = \frac{hL}{k_f} $$

  Reynolds number: Ratio of the inertia and viscous forces

  $$Re = \frac{VL}{\mu/\rho}$$

  Prandtl number: Ratio of the momentum and thermal diffusivities

  $$Pr = \frac{c_p \mu}{k}$$

  The emperical relationship between the dimensionless numbers can be found in the experiments
  for the given conditions and dimensions. These empirical relationships are valid and can be
  used to solve real world problems using the dimensionless numbers for the problems.

2. Explain how steady-state 2D conduction problem can be solved numerically.
State the main equations, assumptions and methodology. You can provide a minimal example
to explain the methodology.

  The 2D conduction problem can be generally solved by solving the heat diffusion equations by
finite difference method. The heat diffusion equation for each element to solve is

  $$\frac{\partial}{\partial x}\left(k \frac{\partial T}{\partial x}\right) + \frac{\partial}{\partial y}\left(k \frac{\partial T}{\partial y}\right) + \dot{q} = \rho c_p \frac{\partial T}{\partial t}$$

  By assuming a steady state condition, k being constant and no internal heat source, the equation
  is simplified to

  $$\frac{\partial^2 T}{\partial x^2} + \frac{\partial^2 T}{\partial y^2} = 0$$

  By applying a finite difference method and proper boundary equations, a partial differential
  equation for each node can be approximated by a linear equation of temperature of the node and
  its surrounding.

  $$\begin{equation}a T_{m-1, n} + b T_{m+1, n} + c T_{m, n-1} + d T_{m, n+1} - \\ \quad \left(a + b + c + d + e \frac{h \delta x}{k}\right)T_{m, n} + e\frac{h \Delta x}{k}T_\infty = 0\end{equation}$$

  When all the equations for nodes are set, we have N finite difference-equations with N unknowns.

  $$\begin{equation}a_{11} T_1 + a_{12}T_2 + \cdots + a_{1N}T_N = C_1 \\ a_{21} T_1 + a_{22}T_2 + \cdots + a_{2N}T_N = C_2 \\ \vdots \\ a_{N1} T_1 + a_{N2}T_2 + \cdots + a_{NN}T_N = C_N\end{equation}$$

  $$ \mathbf{A} \mathbf{T} = \mathbf{C} $$

  This can be solved by any root finding algorithm or by finding the inverse matrix of the
  coefficient matrix $\mathbf{A}$.

3. State the three most important laws for solving th heat transfer problem in general. Give a
example of a heat exchanger to explain how these laws are applied to solve the problems. (8 pt)

  The three most important laws are mass conservation, energy conservation and Newton's cooling law.
  For the heat exchanger problem, the mass conservation law let us use the relationship between the
  mass flow in and out for a steady state problem which is:
  $$\dot{m}_in = \dot{m}_out$$
  By applying the energy conservation,
  $$h \dot{m}_in + \dot{Q} = \dot{m}_out $$,
  we can get $\dot{Q}$ from given temperature at the inlet and outlet. We also can calculate the
  unknown temperature on the other side of stream. Finally, a desired area of heat exchanger can be
  found by the Newton's cooling law.

4. Explain the conditions under which the thermodynamic state of a fluid is determined. Provide
an example using CoolProp / IAPWS library for each of R134a, steam (saturated / superheated) and
humid air. (8 pt)

  You need at least three independent properties of a fluid to determine its thermodynamic state.
Usually they are pressure, temperature and its composition. For steam, it is generally the steam
quality, pressure and temperature, and for humid air, it is pressure, dry bulb temperature and
relative or specific humidity. Often it is possible to calculate pressure or temperature from
other properties such as enthalpy(energy content), mass and/or volume together with the
information of composition. For saturated steam, you only need one parameter: since the quality
is 1 and the temperature is dependent on pressure or vice versa.

```python
# For R134a
from thermal_system_calculation import FluidState, Fluid

state_for_R134a = FluidState(
    fluid=Fluid.R134A,
    pressure_pa=20e5,
    temp_k=300,
)
# In this case, we provide pressure, temperature and the type of fluid which gives information of
# the composition to get the state.

# For saturated steam
from iapws import IAPWS97

steam = IAPWS97(P=1, x=1)

# In this case, we provide pressure and the steam quality. Since it is saturated steam, we don't
# need to provide information for temperature.

# For humid air
from thermal_system_calculation import get_humid_air_state

humid_air_state = get_humid_air_state(
    pressure_pa=1e5,
    temperature_k=300,
    relative_humidity=0.5
)
# In this case, we provide pressure, temperature and relative humidity that provides information
# of water content, hence composition of the air.
```

5. Compare analysis of a heat exchanger using logarithmic mean temperature difference and the
effectiveness-NTU method? State the following in your comparison: procedure for the analysis,
conditions for each analysis is effective, how empirical the method is, main equations and
accuracy of the analysis. (8 pt)

  Logarithmic mean temperature method is effective used when temperature of inlet and outlet
  for one stream is known and only one temperature condition is known for the other stream. We can
  use the energy conservation law to calculate the unknown temperature, which also provides
  information of the rate of heat exchange. From the logarithmic mean temperature calculated
  as belows,

  $$\Delta T_{lm} = \frac{\Delta T_2 - \Delta T_1}{\log \left(\Delta T_2 / \Delta T_1\right)}$$,

  we can calculate required area of heat exchange area using the Newton's cooling law. The method
  is analytic for a simple geometry and arrangement (parallel or counter flow). One needs to use
  empirical correction factor for a complex arrangement.

  The effectiveness-NTU method is more effectively  used when the outlet temperature are not known
  for both streams. We can find an empirical function of effectiveness with input of the number of
  transfer unit (NTU) and the ratio of minimum and maximum heat capacity flow rate for different
  flow arrangement and type of heat exchangers.

  $$\varepsilon = f\left(NTU, \frac{C_{\min}}{C_{\max}}\right)$$

  $$NTU = \frac{UA}{C_{\min}}$$

  $$C = \dot{m} c_p$$

  When $\varepsilon$ is found from the empirical function, temperature of the unknowns are found
  using the definition of the effectiveness. The definition is given for the counter flow as belows.

  $$\varepsilon = \frac{T_3 - T_4}{T_3 - T_1}$$

  where $T_4$ is inlet temperature on the hot side, $T_4$ is outlet temperature on the hot side and
  $T_1$ is inlet temperature on the cold side.

  With all the temperature found, we can calculate the heat transfer rate. The method is effective
  when exploring different temperature conditions at the inlets.

## Part 2 Simple Analysis (20 points)
1. How much thermal power should be added to make superheated steam at 600K, 16 bar having mass
 flow of 1.5 kg/s from saturated steam at 16 bar. (5 pt)

```python
from iapws import IAPWS97

pressure = 16 / 10 # MPa
mass_flow = 1.5 # kg/s

state_superheated_steam = IAPWS97(P=pressure, x=1, T=600)
state_saturated_steam = IAPWS97(P=pressure, x=1)
change_in_enthalpy_kj_per_kg = state_superheated_steam.h - state_saturated_steam.h
heating_power = mass_flow * change_in_enthalpy_kj_per_kg

print(f'Thermal power added: {heating_power:.2f} kW')
```

    Thermal power added: 453.45 kW

2. A room is being ventilated with a fresh air with a rate of 1 kg/s. In the room, there is a
 humidifier that sprays water at 20oC at a rate of 0.002kg/s. There is
also an electric heater heating up the room with 1kW power. What should be the temperature and
the humidity of the fresh air entering the room if the room is maintained at 20 oC
with relative humidity of 40%.

```python
from thermal_system_calculation import get_humid_air_state, FluidState, Fluid

# First get the state of the humidity air
temp_out = 20 + 273.15 # K
rel_humidity_out = 0.4
humid_air_out = get_humid_air_state(
    pressure_pa=101325,
    temperature_k=temp_out,
    relative_humidity=rel_humidity_out
)
specific_humidity_out = humid_air_out.specific_humidity_kg_per_kg_dry_air

# From the mass balance, we can calculate the specific humidity of the air in
mass_flow_humid_air_in = 1
mass_flow_added_water = 0.002
mass_flow_humid_air_out = mass_flow_humid_air_in + mass_flow_added_water
mass_flow_vapor_out = mass_flow_humid_air_out * (specific_humidity_out /
                                                 (1 + specific_humidity_out))
mass_flow_vapor_in = mass_flow_vapor_out - mass_flow_added_water
specific_humidity_in = mass_flow_vapor_in / (mass_flow_humid_air_in - mass_flow_vapor_in)
print(f"Specific humidity of inlet air is {specific_humidity_in:.6f} kg/kgDryAir")

# From the energy balance, we can calculate the specific enthalpy of the humid air in
enthalpy_flow_humid_air_out = mass_flow_humid_air_out * \
                              humid_air_out.specific_enthalpy_j_per_kg_humid_air
water_state = FluidState(Fluid.WATER, pressure_pa=101325, temp_k=20+273.15)
enthalpy_added_by_water = mass_flow_added_water * water_state.specific_enthalpy
heating_power = 1000 # W
enthalpy_flow_humid_air_in = enthalpy_flow_humid_air_out - enthalpy_added_by_water - heating_power
specific_enthalpy_humid_air_in = enthalpy_flow_humid_air_in / \
                                 (mass_flow_humid_air_in - mass_flow_vapor_in)
print(f"Specific enthalpy flow of inlet air is {enthalpy_flow_humid_air_in:.3f} J/kg/s")

# Now we can find the state of the humid air in
humid_air_state_in = get_humid_air_state(
    specific_humidity_kg_per_kg_dry_air=specific_humidity_in,
    pressure_pa=101325,
    specific_enthalpy_j_per_kg_dry_air=specific_enthalpy_humid_air_in
)

print(f"Inlet humid air has\n"
      f"\tTemperature: {humid_air_state_in.temperature_k - 273.15:.1f} degC\n"
      f"\tRelative humidity: {humid_air_state_in.relative_humidity:.3}")
```

    Specific humidity of inlet air is 0.003814 kg/kgDryAir
    Specific enthalpy flow of inlet air is 33587.201 J/kg/s
    Inlet humid air has
    	Temperature: 23.9 degC
    	Relative humidity: 0.208

3. Liquefied hydrogen at 20K under atmospheric pressure is stored in the vacuum insulated tank.
The tank is made of stainless steel which has thickness of 12mm and thermal conductivity of 14
W/Km. Heat resistance from the convection is negligible. The vacuum insulation has thermal
conductivity of 0.004 W/Km. How thick should the insulation be to achieve the boil-off rate of 0.15% volume per day at 100% filling of the tank. The tanks has a rectangular shape with a
dimension of 10m x 20m x 20m. The density of hydrogen for the given condition is 71kg/m3
and the heat of vaporization is 222.9 kJ/kg. The surrounding temperature is 25oC. You can ignore the heat transfer through the corners and edges. (5 pt)

```python
# First we will calculate the rate of heat transfer from the information of the boil-off gas rate
specific_heat_vap = 222.9 * 1000 # J/kg
volumne_loss = 0.0015
total_volume = 10 * 20 * 20 # m3
density = 71 # kg/m3
volume_vaporizing_rate = total_volume * volumne_loss / 24 / 3600
mass_vaporizing_rate = volume_vaporizing_rate * density
heat_transfer_rate = mass_vaporizing_rate * specific_heat_vap # W

# We assume followings:
#  Steady state
#  Constant physical properties within hydrogen liquid and insulation
#  Heat resistance for the steel is negligible
#  The heat transfer through the corners is negligible
area = 10 * 20 * 4 + 20 * 20 * 2 #m2
heat_conductivity = 0.004 # W/Km
temp_surrounding = 25 + 273.15 # K
temp_hydrogen = 20 # K
thickness = (heat_conductivity * area * (temp_surrounding - temp_hydrogen)) / heat_transfer_rate #m

print(f"The thickness of the insulation should be {thickness * 1000:.1f} mm")
```

    The thickness of the insulation should be 1619.8 mm

4.  We are designing the shell-and-tube type heat exchanger which has two-passes on tube side.
On the tube side, hot water flows in at 95oC with a mass flow of 1.5 kg/s and flows
out at 60oC. On the shell side, cold water flows in at 36oC with a mass
flow of 4 kg/s. What is the heat transfer rate assuming there is no heat loss to the surrounding?
What is the temperature of the cold water at outlet?
What will be the required area of heat exchange given the overall heat transfer coefficient is
150 W/m2K. (5pt)

```python
# Since we know the temperature in and out on the hot side and inlet temperature of the cold side,
# we can use analysis using logarithmic mean temperature difference. We can use the package from
# the semester.

from thermal_system_calculation import HeatExchangerLMTD, FlowHeatExchanger, Fluid, FlowType

flow_hot = FlowHeatExchanger(
    fluid=Fluid.WATER,
    temp_in_k=95 + 273.15,
    mass_flow_kg_s=1.5,
    temp_out_k=60 + 273.15,
    is_in_tube=True
)
flow_cold = FlowHeatExchanger(
    fluid=Fluid.WATER,
    temp_in_k=36 + 273.15,
    mass_flow_kg_s=4,
    is_in_tube=False
)
heat_exchanger = HeatExchangerLMTD(
    flow_type=FlowType.ShellAndTube,
    flow1=flow_hot,
    flow2=flow_cold,
    u_h=150,
    number_tube_passes=2,
    is_parallel_flow=False
)

heat_exchanger.solve_temperature()
print(f'Outlet temperature for the cold side is {flow_cold.temp_out - 273.15:.1f}degC')
print(f'Required area of heat exchange is {heat_exchanger.get_heat_exchange_area():.1f} m2')

```

    Outlet temperature for the cold side is 49.2degC
    Required area of heat exchange is 47.2 m2

## Part 3 Real-world problem (40 points)

We are designing a HVAC system for a accommodation block with volume of 30m3. The HVAC
system should be designed to meet the following design requirements.

- The HVAC system should provide the conditioned air to the accommodation so that the entire
volume of the accommodation block has 20 air changes per hour.
- The conditioned air supplied by the HVAC system shall have temperature of 25oC and relative
humidity, 50%.
- For cooling the air, a heat exchanger of shell-and-tube type is used. The air is passing in the
shell as being cooled down by refrigerant R134A. Inlet and outlet temperature of the refrigerant
is constant at -10oC.
- Water is sprayed to the air, if necessary, to keep the humidity at the desired level.
- For heating the air, a heat exchanger of shell-and-tube type is used. The air is passing in the
shell as being heated by saturated steam at 8 barg.
- The HVAC system should be able to meet above requirements for both summer and winter conditions.
 The condition of the inlet air to the HVAC system for each season is given as follows:

| season | Temperature (oC) | Relative humidity (%) |
| ------- | --------- | -------- |
| Summer | 36 | 80 |
| Winter | 5 | 10 |

Assumptions:

- Steady state conditions
- The pressure of the air is assumed to be constant across the system. (p=101325Pa)
- The energy added by the fan is negligible.

### Overall system calculation

```python
from thermal_system_calculation import Fluid, FluidState, get_humid_air_state, \
    ForcedConvectionTubeBanksCrossFlow, TubeBankConfiguration, TubeBankArrangement, \
    FlowHeatExchanger, HeatExchangerLMTD, FlowType, HeatExchangerEffectivenessNTU
import iapws as steam
import numpy as np
# Calculate the mass flow of the dry air, water vapor and humid air at the outlet of the HVAC
# system.

volume_accommodation = 30 #m3
rate_air_change = 60 # /hour
volume_flow_m3_per_s = volume_accommodation * rate_air_change / 3600
temp_air_out = 25 + 273.15 # K
rel_humid_air_out = 0.5 # 50%
humid_air_state_out = get_humid_air_state(
    pressure_pa=101325,
    temperature_k=temp_air_out,
    relative_humidity=rel_humid_air_out
)
mass_flow_air_out_kg_s = volume_flow_m3_per_s / \
                         humid_air_state_out.specific_volume_m3_per_kg_humid_air
mass_flow_dry_air_out_kg_s = mass_flow_air_out_kg_s / \
                         (1 + humid_air_state_out.specific_humidity_kg_per_kg_dry_air)
mass_flow_water_vapor_air_out_kg_s = mass_flow_air_out_kg_s - mass_flow_dry_air_out_kg_s

print("Mass flow:")
print(f"\tHumid air: {mass_flow_air_out_kg_s:.3e} kg/s")
print(f"\tDry air: {mass_flow_air_out_kg_s:.3e} kg/s")
print(f"\twater vapor: {mass_flow_water_vapor_air_out_kg_s:.3e} kg/s")
```

    Mass flow:
    	Humid air: 5.887e-01 kg/s
    	Dry air: 5.887e-01 kg/s
    	water vapor: 5.786e-03 kg/s

1. In summer, how much vapor from the inlet air should be removed at the cooling unit to have the
same vapor mass flow as the air leaving the HVAC? What is the temperature of the air leaving
the cooling unit?

```python
temp_air_in_summer = 36 + 273.15

# By the mass balance, the dry air of inlet and outlet should be the same.
mass_flow_dry_air_in_kg_s = mass_flow_dry_air_out_kg_s
humid_air_state_in_summer = get_humid_air_state(
    pressure_pa=101325,
    temperature_k=temp_air_in_summer,
    relative_humidity=0.8
)
mass_flow_water_vapor_air_in_summer_kg_s = mass_flow_dry_air_in_kg_s * \
                                    humid_air_state_in_summer.specific_humidity_kg_per_kg_dry_air
mass_flow_water_vapor_removed_kg_s = mass_flow_water_vapor_air_in_summer_kg_s - \
                                     mass_flow_water_vapor_air_out_kg_s

print("Mass flow:")
print(f"\tWater vapor in summer: {mass_flow_water_vapor_air_in_summer_kg_s:.3e}kg/s")
print(f"\tWater vapor removed in summer: {mass_flow_water_vapor_removed_kg_s:.2e}kg/s")

temp_air_out_cooler_unit = humid_air_state_out.dew_point_temperature_k
print(f"Temperature at the outlet of the vapor is the same as the dew point temperature of the "
      f"outlet air of the HVAC system: {temp_air_out_cooler_unit - 273.15:.2f}degC")
```

    Mass flow:
    	Water vapor in summer: 1.795e-02kg/s
    	Water vapor removed in summer: 1.22e-02kg/s
    Temperature at the outlet of the vapor is the same as the dew point temperature of the outlet air of the HVAC system: 13.87degC

2. How much heat power is provided at the heating section to achieve the outlet temperature of the
HVAC system? What is the steam consumption?

```python
# We need the humid air state of the air leaving the cooler for its specific enthalpy.
humid_air_state_cooler_out = get_humid_air_state(
    pressure_pa=101325,
    temperature_k=temp_air_out_cooler_unit,
    relative_humidity=1,
)
change_in_specific_enthalpy_j_per_kg_dry_air = \
    humid_air_state_out.specific_enthalpy_j_per_kg_dry_air- \
    humid_air_state_cooler_out.specific_enthalpy_j_per_kg_dry_air
heat_power = change_in_specific_enthalpy_j_per_kg_dry_air * mass_flow_dry_air_out_kg_s
print(f"Heating power is {heat_power:.2f}W")
```

    Heating power is 6650.64W

4. In winter, what is the maximum water vapor flow that the inlet air can have without
condensation. What should be done to the air to inhold more water to meet the design requirement?

```python
# We need the humid air state of the inlet air in winter.
temp_air_in_winter = -10 + 273.15
rel_humid_air_in_winter = 0.1

humid_air_state_in_winter = get_humid_air_state(
    pressure_pa=101325,
    temperature_k=temp_air_in_winter,
    relative_humidity=rel_humid_air_in_winter
)
mass_flow_water_vapor_air_in_winter_kg_s = mass_flow_dry_air_out_kg_s * \
    humid_air_state_in_winter.specific_humidity_kg_per_kg_dry_air

humid_air_state_in_winter_saturated = get_humid_air_state(
    pressure_pa=101325,
    temperature_k=temp_air_in_winter,
    relative_humidity=1
)
mass_flow_water_vapor_saturated_air_in_winter_kg_s = mass_flow_dry_air_out_kg_s * \
    humid_air_state_in_winter_saturated.specific_humidity_kg_per_kg_dry_air
print(f"Mass flow (kg/s):\n"
      f"\twater vapor out: {mass_flow_water_vapor_air_out_kg_s:.3e}\n"
      f"\twater vapor winter: {mass_flow_water_vapor_air_in_winter_kg_s:.3e}\n"
      f"\twater vapor winter (Saturated): {mass_flow_water_vapor_saturated_air_in_winter_kg_s:.3e}"
)
print("Required water vapor flow is much higher than what the winter air can contain at the given"
      " temperature. The air must be heated first.")
```

    Mass flow (kg/s):
    	water vapor out: 5.786e-03
    	water vapor winter: 9.341e-05
    	water vapor winter (Saturated): 9.362e-04
    Required water vapor flow is much higher than what the winter air can contain at the given temperature. The air must be heated first.

5. How much heat power is required for the steam heater to meet the air condition in the winter?
irst, calculate the power with an assumption that the enthalpy change due to adding water and
heat of vaporization is negligible. Then, calculate the power without the assumption. Compare the
result and evaluate the assumption.

If we assume that the enthalpy added by the water and heat of vaporization are negligible

$$h_\mathrm{air, out} = h_\mathrm{air, in} + q_\mathrm{heating}$$

```python
heating_power = (humid_air_state_out.specific_enthalpy_j_per_kg_dry_air -
                 humid_air_state_in_winter.specific_enthalpy_j_per_kg_dry_air) * \
                mass_flow_dry_air_out_kg_s
print(f"Heating power with the assumption is {heating_power:.2f}W")
```

    Heating power with the assumption is 35021.34W

Without the assumption,

$$h_\mathrm{air, out} = h_\mathrm{air, in} + \left( h_\mathrm{water,added} - h_\mathrm{water, fg} \right)  \frac{m_\mathrm{water, added}}{m_\mathrm{dry air}} + q_\mathrm{heating}$$

```python
temp_water = 20 + 273.15
water_state = steam.IAPWS97(P=0.1, T=temp_water)
vapor_state = steam.IAPWS97(
    P=humid_air_state_out.partial_pressure_water_vapor_pa / 1e6,
    T=temp_air_out,
)
h_water = water_state.h * 1000
h_vap = (vapor_state.h - water_state.h) * 100
print(h_water)
print(h_vap)
mass_flow_water_added = mass_flow_water_vapor_air_out_kg_s - \
                        mass_flow_water_vapor_air_in_winter_kg_s
heating_power_no_assumption = (
    humid_air_state_out.specific_enthalpy_j_per_kg_dry_air -
    humid_air_state_in_winter.specific_enthalpy_j_per_kg_dry_air -
    (h_water - h_vap) * mass_flow_water_added / mass_flow_dry_air_out_kg_s
) * mass_flow_dry_air_out_kg_s
print(f"Heating power without the assumption is {heating_power_no_assumption:.2f}W")
error_perc = abs(heating_power_no_assumption - heating_power) / heating_power_no_assumption * 100
print(f"There is about {error_perc:.1f}% error due to the assumption.")

```

    84011.81116713626
    246328.90515683347
    Heating power without the assumption is 35945.29W
    There is about 2.6% error due to the assumption.

### Heat exchanger for a cooling unit

1. In summer, calculate the rate heat exchange (kW) required to cool down the air to condense the
vapor at the dew point of 13.87degC.

```python
q_cooling = (humid_air_state_in_summer.specific_enthalpy_j_per_kg_dry_air -
             humid_air_state_cooler_out.specific_enthalpy_j_per_kg_dry_air) * \
            mass_flow_dry_air_out_kg_s

print(f"Rate of heat exchange: {q_cooling:.2f}W")
```

    Rate of heat exchange: 44430.97W

2. The heat exchanger can be modeled as a series of cross-flow of air across of a bank of tubes.
For a initial design, we will assume the outer diameter of the tube is 10mm, horizontal spacing
is 10mm and vertical spacing is 10mm. There are 30 rows of tubes and 30 tubes per row. A length
of each bank is 1m and the width of the air channel is 0.4m. Calculate the convection heat
transfer coefficient for this single bank. You can assume that the temperature at the surface
of the tube is the same as the cooling water, 10degC.

```python
# Assumption
# - Steady state
# - Constant properties

# First we need to calculate the inlet velocity of the air.
width_channel = 0.6 # m
length_tube = 0.4 # m
sectional_area = width_channel * length_tube
mass_flow_air_in_summer = mass_flow_dry_air_out_kg_s + mass_flow_water_vapor_air_in_summer_kg_s
volume_flow_air_in_summer = \
    humid_air_state_in_summer.specific_volume_m3_per_kg_humid_air * mass_flow_air_in_summer
velocity_air_summer = volume_flow_air_in_summer / sectional_area
print("Flow and velocities: \n"
      f"\tmass flow: {mass_flow_air_in_summer:.2f} kg/s\n"
      f"\tvolume flow: {volume_flow_air_in_summer:.2f} m3/s\n"
      f"\tvelocity: {velocity_air_summer:.2f} m/s\n")

# Boundary conditions
temp_surface = 10 + 273.15 # K
temp_air = 36 + 273.15 # K
pressure = 101325 # K
tube_diameter = 0.01 # m

# Configuration
configuration = TubeBankConfiguration(
    arrangement=TubeBankArrangement.Staggered,
    vertical_spacing=0.020,
    horizontal_spacing=0.020,
    number_rows=30,
    number_tubes_each_row=30
)

# Define the convection instance
convection = ForcedConvectionTubeBanksCrossFlow(
    temp_surface=temp_surface,
    temp_infinity=temp_air,
    fluid=Fluid.AIR,
    characteristic_length=tube_diameter,
    pressure_pa=pressure,
    velocity=velocity_air_summer,
    configuration=configuration
)

# Calculate the heat transfer coefficient
print(f"Heat transfer coefficient is {convection.h} W/m2K")

```

    Flow and velocities:
    	mass flow: 0.60 kg/s
    	volume flow: 0.54 m3/s
    	velocity: 2.23 m/s

    Heat transfer coefficient is 96.32229620430356 W/m2K

3. What is the rate of heat exchange and the outlet temperature of the air and cooling water for the
heat exchanger that has two banks in a series assuming that the overall heat transfer
coefficient is the same as the convection heat transfer coefficient of a single bank? Use a
suitable heat exchanger analysis to calculate them.
(Hint 1: You can use HeatExchangerLMTD or HeatExchangerNTUEffectiveness classes depending on the
conditions)

```python
number_banks = 2
heat_exchanger_area_per_bank = tube_diameter * np.pi * length_tube * \
                               configuration.number_tubes_total
temp_water_in = 10 + 273.15
mass_flow_water = 2 # kg/s
air_flow = FlowHeatExchanger(
    fluid=Fluid.AIR,
    temp_in_k=temp_air_in_summer,
    mass_flow_kg_s=mass_flow_air_in_summer,
    is_in_tube=False
)
water_flow = FlowHeatExchanger(
    fluid=Fluid.WATER,
    temp_in_k=temp_water_in,
    mass_flow_kg_s=mass_flow_water,
    is_in_tube=True
)

heat_exchanger = HeatExchangerEffectivenessNTU(
    flow_type=FlowType.ShellAndTube,
    flow1=air_flow,
    flow2=water_flow,
    u_h=convection.h,
    area=heat_exchanger_area_per_bank * number_banks,
    number_shell_passes=1
)
heat_exchanger.solve_temperature()
print(f"Temperature:\n"
      f"\tAir in: {temp_air_in_summer - 273.15} degC\n"
      f"\tAir out: {air_flow.temp_out - 273.15} degC\n"
      f"\tWater in: {temp_water_in - 273.15} degC\n"
      f"\tWater out: {water_flow.temp_out - 273.15} degC\n")
print(f"Rate of heat exchange is {heat_exchanger.heat_transfer_rate:.2f}W")
```

    Temperature:
    	Air in: 36.0 degC
    	Air out: 11.591106956429883 degC
    	Water in: 10.0 degC
    	Water out: 11.759740075874845 degC

    Rate of heat exchange is 14764.78W

4. Why are there discrepancies from the rate of heat exchange required between the two analysis
2-a and 2-c? What can you say about using the heat exchange analysis for the medium that condenses
or vaporizes during the heat exchange?

The discrepancy comes from that the heat transfer from condensation of the water vapor is not
considered in the heat exchanger analysis.


```python
__author__ = 'Guillermo Damke <gdamke@gmail.com>, Mauricio Araya <maray@inf.utfsm.cl>' # single string; emails in <>
__version__ = '20200806' # yyyymmdd; version datestamp of this notebook
__datasets__ = []  # enter used datasets by hand
__keywords__ = ['Introduction to Machine Learning', 'Exploratory Analysis', 'La Serena School for Data Science']
```

# Practical Introduction to Machine Learning

*In original form by Mauricio Araya, Universidad Técnica Federico Santa María, Chile. Adaptated for DataLab by Guillermo Damke.*

Credits: Francisco Foster, Matthew Graham, Pavlos Protopapas

#### This notebook is part of the curriculum of the 2019 La Serena School for Data Science

#### Topics to be covered in this notebook:

* Download and utilize astronomical data
* Regression: parametric and non-parametric
* Data labelling: Classification (supervised) and Clustering (unsupervised)
* Data characterization

## 1 - SDSS Data

We will download data from the Sloan Digital Sky Survey (SDSS), arguably the all-time favorite dataset for Machine Learning in Astronomy. We could have used data from UCI or Kaggle, but I think SDSS data is very appropaite for this school ;).

## 1.1 - Download Stellar Photometry Data using AstroML

We could access the SDSS data directly. Instead, we will start "cheating" a little bit by using the AstroML package.

AstroML is a popular library for "Machine Learning and Data Mining for Astronomy" and you can find more information in its website (https://www.astroml.org/)

In particular, we will download a dataset of RR Lyrae variable stars:

```python
from astroML.datasets import fetch_rrlyrae_combined

sdss_star_feat, sdss_star_type = fetch_rrlyrae_combined()
```

and the fetched data come as Numpy arrays.

We will use the Pandas package to manage the data.

```python
import pandas as pd
import warnings
warnings.simplefilter(action='ignore', category=Warning)

%matplotlib inline
pd.set_option('display.max_rows',10)
```

and next we load the data into a Pandas DataFrame.

We need to define the column names, which are photometric colors in this case (i.e., the magnitude difference between two photometric filters).

```python
star_feat=pd.DataFrame(sdss_star_feat)
star_feat.columns=['u-g', 'g-r', 'r-i', 'i-z']

```

We can do a plot of two columns of the dataset with Pandas:

```python
star_feat.plot.scatter('u-g', 'g-r', s=1)
```

And also see quickly how the dataset looks like:

```python
star_feat
```

      u-g
      g-r
      r-i
      i-z

      0
      1.250999
      0.394000
      0.137000
      0.061999

      1
      1.048000
      0.339001
      0.151999
      0.023001

      2
      1.008001
      0.341999
      0.129000
      0.203001

      3
      0.965000
      0.392000
      0.149000
      0.150000

      4
      1.040001
      0.333000
      0.125999
      0.101999

      ...
      ...
      ...
      ...
      ...

      93136
      0.962999
      0.059000
      -0.025999
      -0.025000

      93137
      1.059999
      0.185001
      0.050999
      -0.023998

      93138
      1.044001
      0.212000
      0.035000
      0.002001

      93139
      1.064999
      0.172001
      0.042000
      0.003000

      93140
      1.125999
      0.065001
      -0.017000
      -0.057999

93141 rows × 4 columns

In addition, the dataset also have associated *labels*. Labels usually indicate a *category* a single entry in the data can belong to.

Let's add the `sdss_star_type` data array as a column into the DataFrame. We can do this quickly because both star_feat and sdss_star_type already match.

```python
star_feat['Type'] = sdss_star_type
```

And we can inspect the distribution of both groups in the same type of plot as above.
This is a "color-color" diagram, which is a usual way to present astronomical photometric data.

```python
import matplotlib.pyplot as plt

fig, ax = plt.subplots()
star_feat[star_feat['Type']==0].plot.scatter('u-g','g-r',c='red',ax=ax, s=2)
star_feat[star_feat['Type']==1].plot.scatter('u-g','g-r',c='blue',ax=ax, s=2)
```

## 1.2 - Download Galaxy Photometry Data (with Redshifts)

This dataset are galaxies with known (spectroscopically confirmed) redshifts and colour magnitudes. We're interested in determining the redshift of a galaxy from its colors (photometric redshift).

The data was provided by Dr. Matthew J. Graham (and was retrieved from: http://www.astro.caltech.edu/~mjg/sdss_gal.csv.gz). However, we have already placed it locally:

Again, we will load the data into a Pandas DataFrame:

```python
galaxy_feat = pd.read_csv('/nbdata/sdss_gal.csv.gz', low_memory=False)
galaxy_feat
```

      u-g
      g-r
      r-i
      i-z
      redshift

      0
      1.88235
      0.95459
      0.44631
      0.32659
      0.091214

      1
      1.97871
      0.95931
      0.46358
      0.32285
      0.117409

      2
      1.84007
      0.92670
      0.40268
      0.32295
      0.091852

      3
      1.89717
      1.09666
      0.47545
      0.34684
      0.153276

      4
      0.98144
      0.38145
      0.34404
      0.04365
      0.090731

      ...
      ...
      ...
      ...
      ...
      ...

      1841292
      1.76952
      1.16660
      0.57567
      0.45292
      0.091798

      1841293
      2.04845
      1.07646
      0.48183
      0.41557
      0.090638

      1841294
      1.19402
      1.83407
      1.08653
      0.49640
      0.567134

      1841295
      1.28313
      1.80604
      0.81906
      0.51448
      0.445512

      1841296
      1.67367
      0.87776
      0.47917
      0.33946
      0.091880

1841297 rows × 5 columns

It can be seen that the data contains almost 2 million entries (1,841,297 rows). We will use the "sample" function to get a random sample of the data and then plot them. This is a common way to inspect the distribution of data in large datasets. In addition, we will pass the "alpha" parameter to the plot function so that the points are semi-transparent.

```python
gal_sample = galaxy_feat.sample(n=1000)
gal_sample.plot.scatter('g-r','redshift',color='gray',alpha=0.2)
```

## 2 - Regression

Regression is about *predicting values of continous variables.*

$$y' = f(x' \mid \mathbf{X},\mathbf{y})$$

where $y \in \mathbb{R}^n$, $x \in \mathbb{R}^m$ and $\mathbf{y}$ and $\mathbf{X}$ are the target and non-target features for all the samples respectively.

$y'$ is the result that we want to predict given a new $x'$.
In this case, we will use the SDSS Galaxy data (well... a portion of it) to predict *redshift* from the *g-r* color.

We split the `gal_sample` sample and split it as 0.75/0.25 for training/testing subsets (simple, but not correct).

```python
train_data = gal_sample[:750]
test_data = gal_sample[750:]
y_train = train_data['redshift']
X_train = train_data['g-r']
# Formatting hack for training function...
print( f"X_train before reshape: shape: {X_train.shape}; ndim: {X_train.ndim}")
X_train=X_train.values.reshape(len(X_train), 1);
print( f"X_train after reshape: shape: {X_train.shape}; ndim: {X_train.ndim}")

```

    X_train before reshape: shape: (750,); ndim: 1
    X_train after reshape: shape: (750, 1); ndim: 2

## 2.1 - Parametric Regression

We can condense the information found in $\mathbf{X}$ and $\mathbf{y}$ by imposing a *parametric model*, meaning to *optimize* (also known as "fit") certain parameters for the given data. Now our model is
$$y' = f(x' ; \theta^*)$$
where
$$\theta^* = \underset{\theta}{\operatorname{argmax}} \left\{ Pr(Y = f(X;\theta) \mid \mathbf{X}, \mathbf{y}) \right\}$$
which under a linear model and a Gaussian noise $\epsilon$ assumption ($Y = f(X) + \epsilon $) it becomes
$$ \theta^* = \underset{\theta}{\operatorname{argmin}} \left\{ \sum_i (y_i - f(X_i;\theta))^2 \right\}$$.

Consider now a straight line as our model,
$$ f(x;\theta) = a x + b$$
where our parameters are $\theta = \{a,b\}$.

We will use the linear model from `scikit-learn`

```python
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import PolynomialFeatures

import numpy as np

# Define an instance of regression:
regression = LinearRegression(fit_intercept=True)
regression.fit(X_train, y_train) # Optimize parameters (run the fit)

# And evaluate our model:
regression_line = lambda x: regression.intercept_ + regression.coef_ * x
print('The equation of the regression line is: y = {} + {} * x'.format(regression.intercept_, regression.coef_[0]))
```

    The equation of the regression line is: y = -0.03217708375474193 + 0.28755690704801035 * x

Plot the model over the train data:

```python
fig, ax = plt.subplots()
x_vals = np.linspace(0, 3, 100)

train_data.plot.scatter('g-r','redshift',color='gray',alpha=0.1,label='data',ax=ax)
ax.plot(x_vals, regression_line(x_vals), color='red', linewidth=1.0, label='regression line')
plt.legend()
```

Not very good... lets try another linear model!

$$ y = ax^2 + bx + c $$

Note that **linear** means "linear in its parameters" (a,b,c,d).

```python
gen_poly_terms = PolynomialFeatures(degree=2)
X_train_with_poly = gen_poly_terms.fit_transform(X_train)
poly_regression = LinearRegression(fit_intercept=False)
poly_regression.fit(X_train_with_poly, y_train)
print( f"Coefficients: {poly_regression.coef_}")

```

    Coefficients: [-0.13782023  0.46266373 -0.0635039 ]

```python
fig, ax = plt.subplots()
coef = poly_regression.coef_
poly = lambda x: coef[0] + coef[1] * x + coef[2] * x*x
train_data.plot.scatter('g-r','redshift',color='gray',alpha=0.1,label='data',ax=ax)
ax.plot(x_vals, poly(x_vals), color='red', linewidth=1.0, label='regression line')
plt.legend()
```

Not very good either. Let's try a third linear model:

$$ y = ax^3 + bx^2 + cx + d $$

```python
gen_poly_terms = PolynomialFeatures(degree=3)
X_train_with_poly = gen_poly_terms.fit_transform(X_train)
poly_regression = LinearRegression(fit_intercept=True)
poly_regression.fit(X_train_with_poly, y_train)
display(poly_regression.coef_)
poly_regression.intercept_
```

    array([ 0.        ,  0.11246419,  0.19096219, -0.05431771])

    -0.004964948343947673

```python
fig, ax = plt.subplots()
coef = poly_regression.coef_
inter = poly_regression.intercept_
poly = lambda x: inter + coef[1] * x + coef[2] * x*x + coef[3]*x*x*x
train_data.plot.scatter('g-r','redshift',color='gray',alpha=0.1,label='data',ax=ax)
ax.plot(x_vals, poly(x_vals), color='red', linewidth=1.0, label='regression line')
plt.legend()
```

Does it describe with our test data?

```python
fig, ax = plt.subplots()
test_data.plot.scatter('g-r','redshift',color='gray',alpha=0.1,label='data',ax=ax)
ax.plot(x_vals, poly(x_vals), color='red', linewidth=1.0, label='regression line')
plt.legend()
```

We have basically *learned* the parameters!

This is not the best we can do of course!, we can:
* Change the function/model
* Use more dimensions
* Go non-linear...
* Use more/better data
* Use regularized models
* etc...

## 2.2 - Non-parametric Regression

Consolidating data into model parameters have some advantages and drawbacks. An alternative is to use *non-parametric* models. Now, we want to predict:

$$ y' = f(x'; \mathbf{X}, \mathbf{y}, \theta_0) $$

For example, consider a model based on assigning the same Gaussian function (Kernel) to each sample:

$$ K_\sigma(x)=\frac{1}{\sqrt{2\pi}\sigma}exp\left(\frac{-x^2}{2\sigma^2}\right)$$

so that:

$$ y'=\frac{\sum_{i=1}^n K_\sigma(x'-x_i)y_i}{\sum_{i=1}^nK_\sigma(x'-x_i)}$$

Please note that $\theta_0 = \sigma$.

Also, note that non-parametric $\neq$ no parameters!

```python
# Define a Gaussian Kernel Regressor:
def GKR(x_predict, x_data, y_data, sigma):
    dmat = np.tile(x_data,len(x_predict))
    dmat = dmat - np.tile(x_predict,(len(x_data),1))
    K = np.exp(-(dmat*dmat)/(2*sigma*sigma))/(np.sqrt(2*np.pi)*sigma)
    return(K.T.dot(y_data) / K.sum(axis=0))
```

And let's build a funtion to explore how the effect of sigma in the model:

```python
def plot_gkr(sigma=0.1):
    y_gkr=GKR(x_vals,X_train,y_train,sigma)
    fig, ax = plt.subplots()
    train_data.plot.scatter('g-r','redshift',color='gray',alpha=0.1,label='data',s=sigma*500,ax=ax)
    ax.plot(x_vals, y_gkr, color='red', linewidth=1.0, label='regression line')
    plt.legend()
```

Use the slider to change the value of sigma!

```python
from ipywidgets import interact
interact(plot_gkr,sigma=(0.01,1.0,0.01))
```

    interactive(children=(FloatSlider(value=0.1, description='sigma', max=1.0, min=0.01, step=0.01), Output()), _d…

    <function __main__.plot_gkr(sigma=0.1)>

However, there are much smarter ways to do this... for example Gaussian Processes! (which we will implement below)

## 3 - Labelling

Consider now the SDSS star photometry data. As we mentioned above, labels tell us about what category a datum belongs to.

Let's pick a training and a test samples in a 0.75/0.25 ratio.

Warning: we will do this *naively* (i.e., wrongly). We will improve this in other topics of the School...

```python
N=2000
star_sample=star_feat[-1:-N-1:-1] # Pick the last 2000 elements.
star_sample = star_sample.sample(n=N) # Randomize
star_train = star_sample[:int(N*0.75)]
star_test = star_sample[int(N*0.75):]
star_train
```

      u-g
      g-r
      r-i
      i-z
      Type

      91944
      0.892000
      0.331999
      0.142000
      0.046000
      0.0

      91376
      1.134999
      0.353001
      0.134999
      0.037001
      0.0

      92400
      1.025000
      0.376001
      0.095999
      0.070002
      0.0

      92940
      1.139000
      0.168999
      0.043001
      -0.011000
      1.0

      92616
      1.108000
      0.388000
      0.160999
      0.074999
      0.0

      ...
      ...
      ...
      ...
      ...
      ...

      91500
      1.085001
      0.302999
      0.115000
      0.035999
      0.0

      92197
      0.865000
      0.396000
      0.132999
      0.243000
      0.0

      92640
      1.100000
      0.305000
      0.105000
      0.083000
      0.0

      92553
      0.803001
      0.323000
      0.181999
      0.047001
      0.0

      92034
      0.995001
      0.318998
      0.125000
      0.042000
      0.0

1500 rows × 5 columns

Plot the training sample:

```python
fig, ax = plt.subplots()
star_train[star_train['Type']==0].plot.scatter('u-g','g-r',c='red',ax=ax, label='Type 0')
star_train[star_train['Type']==1].plot.scatter('u-g','g-r',c='blue',ax=ax, label='Type 1')
```

and plot the test sample:

```python
fig, ax = plt.subplots()
star_test[star_test['Type']==0].plot.scatter('u-g','g-r',c='red',ax=ax, label='Type 0')
star_test[star_test['Type']==1].plot.scatter('u-g','g-r',c='blue',ax=ax, label='Type 1')
```

What fraction does the `Type=1` class represent in each sample?

```python
print( f"Fraction in star_train: {star_train['Type'].sum()/len(star_train)}")
print( f"Fraction in star_test: {star_test['Type'].sum()/len(star_test)}")
print( f"Fraction in star_feat: {star_feat['Type'].sum()/len(star_feat)}")
```

    Fraction in star_train: 0.23666666666666666
    Fraction in star_test: 0.256
    Fraction in star_feat: 0.005185686217669985

## 3.1 - Classification (Supervised)

Classification is *labelling based on previously annotated samples.*

### Discriminative Classification Models
Think on a boundary dividing data. The boundary divides data into classes. In 2 dimensions, the boundary is a line/curve; in 3 dimensions, a surface; in 4 dimensions, a volume; and so on. This is what is called a discriminative model.

#### Support Vector Machines
*Vocabulary:* This is a  discriminative (non-parametric) linear model for a supervised batch  learning problem

This model is implemented in the `scikit-learn` package.

```python
from sklearn.svm import SVC

clf = SVC(kernel='linear') # Create an instance of the model.
clf.fit(star_train[['u-g','g-r']], star_train['Type']) # Train the model.
y_pred = clf.predict(star_test[['u-g','g-r']]) # Predict the outcome using the test data.
```

add a column to the test data with the predicted values:

```python
star_test['Predict'] = y_pred
star_test
```

      u-g
      g-r
      r-i
      i-z
      Type
      Predict

      92422
      1.207999
      0.396000
      0.140000
      0.022000
      0.0
      0.0

      91441
      1.031000
      0.375999
      0.160999
      0.065001
      0.0
      0.0

      92673
      1.164999
      0.212002
      0.071999
      0.046001
      1.0
      1.0

      91580
      1.223999
      0.275000
      0.101000
      0.048000
      0.0
      0.0

      91672
      1.086000
      0.388000
      0.143999
      0.072001
      0.0
      0.0

      ...
      ...
      ...
      ...
      ...
      ...
      ...

      92521
      1.166000
      0.365999
      0.121001
      0.049999
      0.0
      0.0

      91915
      0.887001
      0.336000
      0.153000
      0.144999
      0.0
      0.0

      92152
      0.828001
      0.344999
      0.174000
      0.152000
      0.0
      0.0

      91723
      1.052999
      0.191999
      0.022001
      0.063000
      0.0
      1.0

      91765
      0.706999
      0.313000
      0.137001
      0.139000
      0.0
      0.0

500 rows × 6 columns

What fraction of the dataset is now predicted as Type=1?

```python
print( f"Predicted fraction: {star_test['Predict'].sum()/len(star_test)}")
```

    Predicted fraction: 0.296

As said above, a SVM will find a boundary that divides 2-dimensional data in two regions. Let's see what the model is:

```python
fig, ax = plt.subplots()
star_test[star_test['Predict']==0.0].plot.scatter('u-g','g-r',c='red',ax=ax)
star_test[star_test['Predict']==1.0].plot.scatter('u-g','g-r',c='blue',ax=ax)

# Compute the boundary
w = clf.coef_[0]
a = -w[1] / w[0]
yy = np.linspace(-0.1, 0.4)
xx = a * yy - clf.intercept_[0] / w[0]

ax.plot(xx, yy, '-k')
```

How well do the model perform in terms correctly predicting the class? We can plot the False Positives (FP), False Negatives (FN), True Positives (TP) and True Negatives (TN).

```python
FP = star_test[star_test['Predict']==1.0]; FP = FP[FP['Type']==0.0]
FN = star_test[star_test['Predict']==0.0]; FN = FN[FN['Type']==1.0]
TP = star_test[star_test['Predict']==1.0]; TP = TP[TP['Type']==1.0]
TN = star_test[star_test['Predict']==0.0]; TN = TN[TN['Type']==0.0]
fig, ax = plt.subplots()
TP.plot.scatter('u-g','g-r',c='blue',ax=ax,label="TP")
TN.plot.scatter('u-g','g-r',c='red',ax=ax,label="TN")
if len(FP) > 0:
    FP.plot.scatter('u-g','g-r',c='magenta',ax=ax,label="FP",marker='+',s=100)
if len(FN) > 0:
    FN.plot.scatter('u-g','g-r',c='green',ax=ax,label="FN",marker='+',s=100)
```

#### Radial Basis Function Kernel
We can construct a hyperplane (line) in other space by transforming data to that space, and then come back. This is done using kernels

$${\displaystyle K(\mathbf {x} ,\mathbf {x'} )=\exp \left(-{\frac {\|\mathbf {x} -\mathbf {x'} \|^{2}}{2\sigma ^{2}}}\right)}$$

```python
from scipy.ndimage import gaussian_filter
def plot_svm_rbf(gamma=20.0):
    clf_rbf = SVC(kernel='rbf', gamma=gamma)
    clf_rbf.fit(star_train[['u-g','g-r']], star_train['Type'])
    y_pred_rbf = clf_rbf.predict(star_test[['u-g','g-r']])
    star_test['PredictRBF']=y_pred_rbf
    xlim = (0.7, 1.35)
    ylim = (-0.15, 0.4)
    xx, yy = np.meshgrid(np.linspace(xlim[0], xlim[1], 101),
                     np.linspace(ylim[0], ylim[1], 101))
    Z = clf_rbf.predict(np.c_[ xx.ravel(),yy.ravel()])
    Z = Z.reshape(xx.shape)
    Z = gaussian_filter(Z, 2)
    FP = star_test[star_test['PredictRBF']==1.0]; FP = FP[FP['Type']==0.0]
    FN = star_test[star_test['PredictRBF']==0.0]; FN = FN[FN['Type']==1.0]
    TP = star_test[star_test['PredictRBF']==1.0]; TP = TP[TP['Type']==1.0]
    TN = star_test[star_test['PredictRBF']==0.0]; TN = TN[TN['Type']==0.0]
    fig, ax = plt.subplots()
    TP.plot.scatter('u-g','g-r',c='red',ax=ax,label="TP")
    TN.plot.scatter('u-g','g-r',c='blue',ax=ax,label="TN")
    if len(FP) > 0:
        FP.plot.scatter('u-g','g-r',c='green',ax=ax,label="FP",marker='+',s=100)
    if len(FN) > 0:
        FN.plot.scatter('u-g','g-r',c='magenta',ax=ax,label="FN",marker='+',s=100)
    ax.contour(xx, yy, Z, [0.5], colors='k')
```

Use the slider to change the value of gamma:

```python
interact(plot_svm_rbf,gamma=(0.1,300,10))
```

    interactive(children=(FloatSlider(value=20.0, description='gamma', max=300.0, min=0.1, step=10.0), Output()), …

    <function __main__.plot_svm_rbf(gamma=20.0)>

## 3.2. Clustering (Unsupervised)

Now think trying to put labels but without knowing previous examples on the Galaxy data... but using now all the dimensions (i.e., all photometric colors)!

*Clustering* permits us to find groups of objects that represent candidate categories. Clustering encompass techniques of *unsupervised learning* that allows us to explore the dataset.

```python
gal_sample
```

      u-g
      g-r
      r-i
      i-z
      redshift

      1468556
      3.01926
      1.71147
      0.86178
      0.43081
      0.461934

      670110
      2.18623
      1.18099
      0.54244
      0.39244
      0.160182

      1356314
      2.36819
      1.81206
      0.69280
      0.40051
      0.323880

      714818
      1.14686
      1.79037
      0.71109
      0.44540
      0.456535

      1142081
      0.55318
      1.63138
      1.22388
      0.36813
      0.645293

      ...
      ...
      ...
      ...
      ...
      ...

      745739
      1.30799
      0.77225
      0.44653
      0.28673
      0.072807

      748510
      0.96663
      1.72807
      0.98652
      0.47680
      0.523442

      928723
      3.46602
      1.59095
      0.91258
      0.30901
      0.490486

      549788
      1.19003
      0.62021
      0.35800
      0.27124
      0.109164

      186015
      1.01825
      0.30288
      0.23969
      0.07127
      0.039368

1000 rows × 5 columns

Plot the data in all the photometric colors:

```python
def gal_4proj(axes):
    ((ax1, ax2), (ax3, ax4)) = axes
    gal_sample.plot.scatter('u-g','redshift',color='gray',alpha=0.1,ax=ax1)
    gal_sample.plot.scatter('g-r','redshift',color='gray',alpha=0.1,ax=ax2)
    gal_sample.plot.scatter('r-i','redshift',color='gray',alpha=0.1,ax=ax3)
    gal_sample.plot.scatter('i-z','redshift',color='gray',alpha=0.1,ax=ax4)
```

```python
fig, axes = plt.subplots(2,2, figsize=(8,8))
gal_4proj(axes)
```

### Gaussian Mixture Model

Consider a Gaussian Mixture Model:

$$ \mathcal{N}(x; \mu, \Sigma) = \frac{\exp \left(-{\frac{1}{2}}( x - \mu )^{\mathrm {T}}\Sigma^{-1}(x - \mu )\right)}{\sqrt {(2\pi )^{k}|\Sigma| }}$$
$$ p(x) = \displaystyle\sum_{j=1}^{k} \phi_j\mathcal{N}(x; \mu_j, \Sigma_j)$$
$$\displaystyle\sum_{j=1}^{k} \phi_j = 1 $$

Such a model is implemented in `Scikit-learn`:

```python
from sklearn.mixture import GaussianMixture
colors = ['red','blue','green','magenta','cyan','orange']

def clust_4proj(mix,axes,n):
    for dim in range(4):
        ax = axes[int(dim/2),dim%2]
        labels=mix.predict(gal_sample)
        for i in range(n):
            gal_sample[labels==i].plot.scatter(dim,'redshift',color=colors[i],alpha=0.1,ax=ax)
```

```python
n = 4 # Set number of clusters:
mix = GaussianMixture(n_components=n,covariance_type='full', max_iter=100)
mix.fit(gal_sample)
fig, axes =plt.subplots(2,2)
clust_4proj(mix,axes,n)
```

*Vocabulary:* This is a generative parametric linear model for a unsupervised batch  learning problem

Let's plot the clusters found by the GMM, as a function of the number of clusters *n*:

```python
import matplotlib as mpl
def GMM_4proj(gmm,axes,n):
    for clust in range(n):
        for dim in range(4):
            dims=[dim,4]
            ax = axes[int(dim/2),dim%2]
            cov = gmm.covariances_[clust]
            cov = cov[dims][:,dims]
            v, w = np.linalg.eigh(cov)
            u = w[0] / np.linalg.norm(w[0])
            angle = np.arctan2(u[1], u[0])
            angle = 180 * angle / np.pi  # convert to degrees
            v = 2. * np.sqrt(2.) * np.sqrt(v)
            ell = mpl.patches.Ellipse(gmm.means_[clust,dims], v[0], v[1],
                     180 + angle, color=colors[clust])
            ell.set_clip_box(ax.bbox)
            ell.set_alpha(0.3)
            ax.add_artist(ell)
```

```python
def show_clusters(n=2): # Set initial n=2
    mix = GaussianMixture(n_components=n,covariance_type='full', max_iter=100)
    mix.fit(gal_sample)
    fig, axes =plt.subplots(2,2)
    gal_4proj(axes)
    GMM_4proj(mix,axes,n)
```

Use the slider to change the number of clusters interactively!

```python
interact(show_clusters,n=(2,6,1))
```

    interactive(children=(IntSlider(value=2, description='n', max=6, min=2), Output()), _dom_classes=('widget-inte…

    <function __main__.show_clusters(n=2)>

## 4.- Characterizing

#### Dimensionality Reduction (PCA)

*Principal Component Analysis (PCA)* is a technique used to reduce the dimmensionality of the data.

Consider the Singular Value Decomposition of your data (in matrix form):

$$\mathbf{X} = \mathbf{U}\mathbf{\Sigma}\mathbf{W}^T$$

Then, you can compute an affine transformation of your data such that:

$${\displaystyle {\begin{aligned}\mathbf {X} ^{T}\mathbf {X} &=\mathbf {W} \mathbf {\Sigma } ^{T}\mathbf {U} ^{T}\mathbf {U} \mathbf {\Sigma } \mathbf {W} ^{T}\\&=\mathbf {W} \mathbf {\Sigma } ^{T}\mathbf {\Sigma } \mathbf {W} ^{T}\\&=\mathbf {W} \mathbf {\Sigma'}\mathbf {W} ^{T}\end{aligned}}}$$

Meaning that:

$$\begin{align}
\mathbf{T} & = \mathbf{X} \mathbf{W} \\
           & = \mathbf{U}\mathbf{\Sigma}\mathbf{W}^T \mathbf{W} \\
           & = \mathbf{U}\mathbf{\Sigma}
\end{align}$$

PCA for dimensionality reduction is basically:

$$ \mathbf{T}_L = \mathbf{U}_L\mathbf{\Sigma}_L = \mathbf{X} \mathbf{W}_L  $$

```python
n=4
mix = GaussianMixture(n_components=n,covariance_type='full', max_iter=100)
mix.fit(gal_sample)
labels=mix.predict(gal_sample)
```

We will use the implementation of PCA from `scikit-learn`:

```python
from sklearn import decomposition
```

```python
pca = decomposition.PCA(n_components=3)
pca.fit(gal_sample)
lowd = pca.transform(gal_sample)
```

```python
print( f"We reduced the dataset dimensionality from {gal_sample.shape[1]} to {lowd.shape[1]} dimensions.")
```

    We reduced the dataset dimensionality from 5 to 3 dimensions.

Let's plot the 3 principal components in 3D, color-coded by the four clusters found using Gaussian Mixture.

Use the cursor and mouse buttons to manipulate the visualization!

```python
# Note: you may need to execute this cell twice to visualize the interactive cube:

%matplotlib notebook
from mpl_toolkits.mplot3d import Axes3D
fig = plt.figure(1, figsize=(7, 5))
ax = Axes3D(fig, rect=[0, 0, .95, 1], elev=48, azim=134)
ax.scatter(lowd[:, 0], lowd[:, 1], lowd[:, 2], c=labels, cmap=plt.cm.gist_rainbow,
           edgecolor='k')
ax.set_xlabel("Axis 0")
ax.set_ylabel("Axis 1")
ax.set_zlabel("Axis 2")
```

    <IPython.core.display.Javascript object>

    Text(0.5, 0, 'Axis 2')

How are the original 5 dimensions related to the 3 components in the PCA? We can print the the coefficients of the 5 input columns with respect of the 3 new axes:

```python
pca_comp = pd.DataFrame(pca.components_)
pca_comp.columns = [['u-g', 'g-r', 'r-i', 'i-z','redshift']]
pca_comp
```

      u-g
      g-r
      r-i
      i-z
      redshift

      0
      0.994614
      0.101993
      0.015333
      0.007181
      0.007357

      1
      -0.093950
      0.814131
      0.449603
      0.169529
      0.312219

      2
      0.043519
      -0.566532
      0.669644
      0.112974
      0.464717

As a test, we can check if the the reduced dimensions in the PCA keep the original clusters in the data.

We will apply the Gaussian Mixture Model with 4 components to the PCA results:

```python
mix = GaussianMixture(n_components=n,covariance_type='full', max_iter=100)
mix.fit(lowd)
labels_low = mix.predict(lowd)
```

Let's plot the new labels. Compare to the previous Figure!

```python
# Note: you may need to execute this cell twice to visualize the interactive cube:

fig = plt.figure(1, figsize=(7, 5))
ax = Axes3D(fig, rect=[0, 0, .95, 1], elev=48, azim=134)
ax.scatter(lowd[:, 0], lowd[:, 1], lowd[:, 2], c=labels_low, cmap=plt.cm.gist_rainbow,
           edgecolor='k')
ax.set_xlabel("Axis 0")
ax.set_ylabel("Axis 1")
ax.set_zlabel("Axis 2")
plt.show()
```

    <IPython.core.display.Javascript object>

### Summary

In this practical introduction, we have used data from the SDSS to introduce 3 usual tasks performed in Data Science, namely, Regression, Classification and Clustering, and dimensionality reduction through PCA.