```python
from sympy import symbols, init_printing, pi, sqrt, solveset, Eq
from scipy.constants import c
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.ticker import (MultipleLocator, FormatStrFormatter,
                               AutoMinorLocator)

from ipywidgets.widgets import (
    interactive_output,
    BoundedFloatText,
    BoundedIntText,
    HBox,
    VBox,
    IntRangeSlider,
    Text,
    Button,
    Label,
    Layout
)
from ipywidgets import interact

from IPython.display import display
import datetime, os
from os.path import isdir
from pathlib import Path
```

```python
init_printing()
save_location = Path(r"D:\SurfDrive\backup_for_surfacePro\Work\THz_setup\Simulations")

# Defining functions
def ghz_cm(x):
    """
    Input: freq in GHZ
    Output: freq in cm
    """
    return c * 1e2 / x * 1e-9

cm_ghz = lambda x: c*1e-9*1e2/x

print(f"Save location: {save_location}")
save_location.exists()
```

    Save location: D:\SurfDrive\backup_for_surfacePro\Work\THz_setup\Simulations

    True

```python
# Symbols
w, lam, w0, z, Z_r = symbols("w, lam, w0, z, Z_r")
M, E, d_in, d_out, w0_in, w0_out, f, f0 = symbols('M, E, d_in, d_out, w0_in, w0_out, f, f0')
```

```python
## Formulas

# Beam radius
Wz = Eq(w, w0 * sqrt(1 + (z / Z_r) ** 2))
Wz
```

```python
# Focal length
fp = Eq(f, Z_r*(d_in/(E*Z_r))*(1+sqrt(1-E*(1+(d_in/Z_r)**-2)))) # positive sign but output is negative focus
fm = Eq(f, Z_r*(d_in/(E*Z_r))*(1-sqrt(1-E*(1+(d_in/Z_r)**-2)))) # negative sign but output is positive focus

fp, fm
```

```python
# Input distance
eq_din_m = Eq(d_in, f - sqrt(f ** 2 - f0 ** 2) / M) # Negative sign
eq_din_p = Eq(d_in, f + sqrt(f ** 2 - f0 ** 2) / M) # Positive sign

# output distance
eq_dout_m = Eq(d_out, f - sqrt(f ** 2 - f0 ** 2) * M) # Negative sign
eq_dout_p = Eq(d_out, f + sqrt(f ** 2 - f0 ** 2) * M) # Positive sign
# NOTE: use same sign for output distance as chosed for input distane

# Input and output distances in-terms of each other
eq_dout2 = Eq(d_out, f * (1 + (M ** 2 * (d_in / f - 1))))
eq_din2 = Eq(d_in, f * (1 + sqrt(M ** -2 - (Z_r / f) ** 2)))

eq_din_m, eq_din_p, eq_dout_m, eq_dout_p, eq_din2, eq_dout2,
```

```python
class gaussian_simulation:

    def __init__(self):

        W = BoundedFloatText(value=0.34, min=0.1, max=1, step=0.01, description="w0:")
        L = IntRangeSlider(
            value=[325, 1100], min=100, max=1500, step=1, description="GHz"
        )

        width = BoundedIntText(value=7, min=5, max=25, step=1, description="width")
        height = BoundedIntText(value=5, min=5, max=25, step=1, description="height")
        dpi = BoundedIntText(value=200, min=70, max=250, step=1, description="Dpi")

        entry1 = BoundedFloatText(
            value=-9, min=-11, max=10, step=0.1, description="Entry1"
        )
        Mirror = BoundedIntText(value=11, min=10, max=20, step=1, description="Mirror Position")

        self.save_name = Text(value="")

        save_button = Button(description="Save", layout={"width": "300px"})

        save_button.on_click(self.save_plot)

        self.print_save = Label(value="Status: ")

        row1 = HBox([W, L])
        row2 = HBox([width, height, dpi])
        row3 = HBox([entry1, Mirror])

        out = interactive_output(
            self.simulate,
            {
                "w0_": W,
                "wn": L,
                "width": width,
                "height": height,
                "dpi": dpi,
                "entry1": entry1,
                "mirror":Mirror
            },
        )

        display(
            VBox(
                [row1, row2, row3, out, self.save_name, save_button, self.print_save]
            )
        )

    def save_plot(self, event):
        if not isdir('./Simulations'):
            os.mkdir('./Simulations')

        self.fig0.savefig(f"./Simulations/{self.save_name.value}.png")
        self.fig0.savefig(f"./Simulations/{self.save_name.value}.pdf")

        t = datetime.datetime.now()
        self.print_save.value = f'File "{self.save_name.value}.png" Saved; {t.ctime()}'

    def beam_plot(self, ax, w0_, wn):

        self.gaussian_beam(w0_, wn)

        markers = '--'
        line_width = 1
        alpha = 0.7

        for c, beam in enumerate(self.beams):
            color = f"C{c}"
            ax.plot(
                z_,
                beam,
                markers+color, lw=line_width, alpha=alpha,
                label=f"{self.wn[c]:.2f} GHz ({ghz_cm(self.wn[c])*10:.2f} mm)",
                zorder=1
            )
            ax.plot(-z_, -beam, markers+color, lw=line_width, alpha=alpha, zorder=1)
            ax.plot(-z_, beam, markers+color, lw=line_width, alpha=alpha, zorder=1)
            ax.plot(z_, -beam, markers+color, lw=line_width, alpha=alpha, zorder=1)

    def simulate(self, w0_, wn, width, height, dpi, entry1, mirror):

        entry1 = round(entry1, 1)
        entry2 = round(10.4 - abs(entry1), 1)

        self.fig0, ax0 = plt.subplots(figsize=(width, height), dpi=dpi)
        self.beam_plot(ax0, w0_, wn)

        ax0.hlines(0, xmin=-z_.max(), xmax=z_.max())

        ## Annotation begins

        fontsz = 6

        # Distance b/w two entry points
        ax0.hlines(-0.2, xmin=entry1, xmax=entry2)
        plt.annotate(
            s=f"d={(entry2-entry1)}",
            xy=(0, 0),
            xytext=(-3, -0.1),
            ha="center",
            va="center",
            fontsize=fontsz
        )

        # Annotate beam waist
        ax0.vlines(x=0, ymin=0, ymax=w0_)
        plt.annotate(
            s="$w_0^{in}$=%.2f cm"%w0_,
            xy=(0, w0_+0.2),
            xytext=(0, 0.8),
            ha="center",
            va="center",
            arrowprops=dict(arrowstyle="->", connectionstyle="arc3"),
            fontsize=fontsz
        )

        # Annotate Entry1
        ax0.vlines(x=entry1, ymin=-1, ymax=1)

        plt.annotate(
            s="Main entry window:\ndiameter=2cm",
            xy=(entry1, 1),
            xytext=(entry1 + 6, 1.1),
            ha="center",
            va="center",
            arrowprops=dict(arrowstyle="->", connectionstyle="arc3"),
            fontsize=fontsz
        )

        # Annotate Entry2
        ax0.vlines(x=entry2, ymin=-0.43, ymax=0.43)

        plt.annotate(
            s="Trap bender entry:\ndiameter=0.86cm",
            xy=(entry2, -0.43),
            xytext=(entry2 - 3, -0.8),
            ha="center",
            va="center",
            # rotation = 30,
            arrowprops=dict(arrowstyle="->", connectionstyle="arc3"),
            fontsize=fontsz
        )

        # Inner entry: diameter: 0.64cm
        ax0.vlines(x=entry2+0.7, ymin=-0.32, ymax=0.32)

        # Annotate z-position

        plt.annotate(s="z=0", xy=(0, 0), xytext=(0, -0.05), ha="center", va="center",fontsize=fontsz)

        plt.annotate(
            s=f"z={entry1}",
            xy=(entry1, 0),
            xytext=(entry1 - 2, -0.2),
            ha="center",
            va="center",
            arrowprops=dict(arrowstyle="->", connectionstyle="arc3"),
            fontsize=fontsz
        )

        # d_in window
        mirror_pos = entry1-mirror
        ax0.vlines(x=mirror_pos, ymin=-self.beams.max(), ymax=self.beams.max())

        plt.annotate(
            s="Mirror($d_{in}$) at %.1f cm"%mirror_pos,
            xy=(mirror_pos+5, -self.beams.max()),
            ha="center",
            va="center",
            arrowprops=dict(arrowstyle="->", connectionstyle="arc3"),
            fontsize=fontsz
        )

        ## Annotation ends

        ax0.set_xlabel("z direction : Beam Propagation (cm)")
        ax0.set_ylabel("w(z) : Gaussian Beam radius (cm)")
        ax0.set_title(f"Gaussian Beam simulation: {wn[0]}-{wn[-1]} GHz")
        ax0.legend(bbox_to_anchor=(0.16, 0.86), ncol=3, fontsize=5)
        ax0.xaxis.set_minor_locator(AutoMinorLocator())

        plt.show()
        # plt.close()

        self.save_name.value = f"{w0_}cm-{wn[0]}-{wn[-1]}GHz_simulation"

    def gaussian_beam(self, w0_, wn):

        global z_
        z_ = np.linspace(0, 25, 10)

        self.wn = np.linspace(*wn, 10)

        beams = []
        for l in self.wn:
            temp = []
            zr = np.pi*w0_**2/ghz_cm(l)
            temp = np.array([float(
                    Wz.subs({w0:w0_, Z_r:zr, z:i}).rhs
                ) for i in z_])

            beams.append(temp)

        self.beams = np.array(beams)

start = gaussian_simulation()
```

    VBox(children=(HBox(children=(BoundedFloatText(value=0.34, description='w0:', max=1.0, min=0.1, step=0.01), In…

```python
class simulation:
    def __init__(self):

        # Beam properties
        W_in = BoundedFloatText(
            value=0.34, min=0.1, max=1, step=0.01, description="$w0_{in}$: "
        )

        D_in = BoundedFloatText(
            value=20, min=5, max=30, step=0.1, description="$d_{in}$: "
        )

        To_use = BoundedIntText(
            value=0, min=0, max=2, step=1, description="$F(w_0)$ to use: "
        )

        # Figure Properties
        Width = BoundedFloatText(value=15, min=5, max=25, step=1, description="width")
        Height = BoundedFloatText(value=6, min=5, max=25, step=1, description="height")
        Dpi = BoundedFloatText(value=100, min=70, max=300, step=1, description="Dpi")

        # Widget to save
        self.save_name = Text(value="THz_simulation_325-1100GHz")
        save_button = Button(description="Save", layout={"width": "300px"})
        save_button.on_click(self.save_plot)
        self.print_save = Label(value="Status: ")

        # Widget arrangements
        row1 = HBox([W_in, D_in, To_use])
        row2 = HBox([Width, Height, Dpi])
        row3 = VBox([self.save_name, save_button, self.print_save])

        # Plot output
        out = interactive_output(
            self.plot,
            {"w1": W_in, "d1": D_in, "to_use": To_use, "width": Width, "height": Height, "dpi": Dpi},
        )

        # widget display
        display(VBox([row1, row2, out, row3]))

    def save_plot(self, event):

        try:
            if not save_location.exists():
                save_location.mkdir()

            self.fig.savefig(save_location.joinpath(self.save_name.value + ".png"))
            self.fig.savefig(save_location.joinpath(self.save_name.value + ".pdf"))

            self.fig.savefig(f"./Simulations/{self.save_name.value}.png")
            self.fig.savefig(f"./Simulations/{self.save_name.value}.pdf")

            t = datetime.datetime.now()
            self.print_save.value = (
                f'File "{self.save_name.value}.png" Saved; {t.ctime()}'
            )

        except:
            self.print_save.value = "Error Occured."

    def plot(self, w1, d1, to_use, width, height, dpi):

        wn_size = 5
        wz_size = 20
        d_trap = -1.4

        ##########################################################################################

        # opening log file
        t = datetime.datetime.now()
        log_file = open(f"./Simulations/log_file_{to_use}.txt", "w")
        log_file.write(f"Gaussian Simulation: {t.ctime()}\n\n")
        log_file.write("##########################################################################################\n")

        # 3-sets of wavelength range for 3-different horn
        wn_set1 = np.linspace(325, 500, wn_size)
        wn_set2 = np.linspace(500, 750, wn_size)
        wn_set3 = np.linspace(750, 1100, wn_size)
        wn_list = np.array([wn_set1, wn_set2, wn_set3])

        # 3-sets of horns waist for different wavelength
        w0_out_list = np.array([0.15, 0.099, 0.066])

        w0_out_in_use = w0_out_list[to_use]
        M_factor = w0_out_in_use/w1

        w0_in_list = w0_out_list/M_factor
        M_list = w0_out_list/w0_in_list

        # Focal length and d_out in-use (Taking optimal focal length for the longest wn)
        wn_in_use = wn_list[to_use, 0]
        M_in_use = M_list[to_use]

        zc_in_use = np.pi * w1 ** 2 / ghz_cm(wn_in_use)

        f_positive = float(
            fm.subs({d_in: d1, E: (1 - M_in_use ** -2), Z_r: zc_in_use}).rhs
        )
        f_negative = float(
            fp.subs({d_in: d1, E: (1 - M_in_use ** -2), Z_r: zc_in_use}).rhs
        )

        opt_d2 = float(eq_dout2.subs({f:f_positive, M:M_in_use, d_in:d1}).rhs)
        print(f_positive, opt_d2)

        log_file.write(f"\nFocii:\nf(+ve): {f_positive:.2f}; f(-ve): {f_negative:.2f}\n\n")

        ##########################################################################################

        # Defining figure:
        self.fig, (ax1, ax2) = plt.subplots(nrows=1, ncols=2,
                                    sharey=True,
                                    figsize=(width, height), dpi=dpi
                    )
        nrows, ncols = 1, 3

        #ax1 = self.fig.add_subplot(nrows, ncols, 1)
        #ax2 = self.fig.add_subplot(nrows, ncols, 2, sharey=ax1)
        #bx1 = self.fig.add_subplot(nrows, ncols, 3)

        for ax in self.fig.axes:
            ax.grid()

        colors = "C0, C1, C2, C3, C4, C5, C6, C7, C8, C9".split(", ")
        mrkr = [".-", "x-", "--"]

        d1_beam = []
        d2_beam = []

        d1_distance = []
        d2_distance = []

        ##########################################################################################
        log_file.write("##########################################################################################\n\n")

        for num in range(len(w0_out_list)):

            log_file.write(f"w0_out: {w0_out_list[num]}cm\n\n")

            d1_beam_temp = []
            d2_beam_temp = []

            d1_distance_temp = []
            d2_distance_temp = []

            #################################################################################

            for c, wn in enumerate(wn_list[num]):

                log_file.write(f"Frequency: {wn}GHz\n")

                #################################################################################
                # optimised calculation
                temp_f0 = (np.pi * w0_in_list[num] * w0_out_list[num]) / ghz_cm(wn)

                temp_d1 = float(eq_din_p.subs({M: M_list[num], f0: temp_f0, f: f_positive}).rhs)
                temp_d2 = float(eq_dout_p.subs({M: M_list[num], f0: temp_f0, f: f_positive}).rhs)

                #################################################################################
                # d_in beam optimised
                d1_beam_array = np.linspace(temp_d1-d1+d_trap, temp_d1, wz_size)
                zr1 = (np.pi*w0_in_list[num]**2)/ghz_cm(wn)

                d1_beam_calculation = np.array([float(
                    Wz.subs({w0:w0_in_list[num], Z_r:zr1, z:i}).rhs
                ) for i in d1_beam_array])

                #################################################################################
                # d_out beam optimised
                d2_beam_array = np.linspace(0, temp_d2, wz_size)
                zr2 = (np.pi*w0_out_list[num]**2)/ghz_cm(wn)

                d2_beam_calculation = np.array([float(
                    Wz.subs({w0:w0_out_list[num], Z_r:zr2, z:i}).rhs
                ) for i in d2_beam_array])

                #################################################################################

                # Plotting
                d1_plot = np.linspace(d_trap, d1, wz_size)
                d2_plot = np.linspace(opt_d2-temp_d2, opt_d2, wz_size)

                ax1.plot(d2_plot, d2_beam_calculation, colors[c]+mrkr[num], zorder=1, label=f"{wn:.1f}GHz")
                ax1.plot(d2_plot, -d2_beam_calculation, colors[c]+mrkr[num], zorder=1)

                ax2.plot(d1_plot, d1_beam_calculation, colors[c]+mrkr[num], zorder=1)
                ax2.plot(d1_plot, -d1_beam_calculation, colors[c]+mrkr[num], zorder=1)

                #################################################################################
                 # Writing log file
                log_file.write(f"d_in:\n{d1_beam_array}\nd_in_beam: {d1_beam_calculation}\n\n")
                log_file.write(f"d_out:\n{d2_beam_array}\nd_out_beam: {d2_beam_calculation}\n\n")

                #################################################################################
                # Appending temp values
                d1_distance_temp.append(temp_d1)
                d2_distance_temp.append(temp_d2)

                d1_beam_temp.append(d1_beam_calculation)
                d2_beam_temp.append(d2_beam_calculation)

                #################################################################################
            #####################################################################################
            log_file.write("##########################################################################################\n\n")

            # Appending main values
            d1_distance.append(d1_distance_temp)
            d2_distance.append(d2_distance_temp)

            d1_beam.append(d1_beam_temp)
            d2_beam.append(d2_beam_temp)

            ######################################################################################
        ##########################################################################################
        # Making array
        d1_distance = np.array(d1_distance)
        d2_distance = np.array(d2_distance)

        d1_beam = np.array(d1_beam)
        d2_beam = np.array(d2_beam)

        ##########################################################################################
        log_file.write(f"w0_in_list: {w0_in_list}\n\n")
        log_file.write(f"w0_out_list: {w0_out_list}\n\n")
        log_file.write(f"Frequency list:\n{wn_list}\n\n")
        log_file.write(f"d_out: (Calibrated)\n{d2_distance}\n\n")
        log_file.write(f"d_in: (Calibrated)\n{d1_distance}\n\n")
        log_file.write(f"Mirror Diameter: >= {3*d1_beam[0, 0, -1]:.2f}cm\n(since longest wavelength beam waist at mirror is {d1_beam[0, 0, -1]:.2f}cm)\n\n")

        ##########################################################################################
        # ax1
        ax1.set_xlim(xmax=opt_d2)

        ax1.set(xlabel='$d_{out}$ (cm)', ylabel='$w(z)$ cm', title='THz source to Mirror')
        ax1.legend(ncol=3)

        ax1.xaxis.set_minor_locator(AutoMinorLocator(10))

        ##########################################################################################
        # ax2
        ax2.invert_xaxis()
        ax2.set_xlim(xmin=20)

        ax2.set(xlabel='$d_{in}$ (cm)', title='Mirror to trap')
        ax2.legend(title=f"f_+ = {f_positive:.2f}cm\nf_- = {f_negative:.2f}cm\nMirror Diameter: >= {3*d1_beam[0, 0, -1]:.2f}cm")

        ax2.xaxis.set_minor_locator(AutoMinorLocator(10))

        # vlines
        ax2.vlines(0, ymin=-w1, ymax=w1)
        ax2.vlines(d_trap, ymin=-0.43, ymax=0.43)

        ##########################################################################################

        plt.tight_layout()

        ##########################################################################################
        log_file.write("##########################################################################################\n")
        log_file.write("##########################################################################################\n\n")

        log_file.close()
        ##########################################################################################
        self.save_name.value = f"THz_simulation_325-1100GHz_{to_use}"

run = simulation()
```

    VBox(children=(HBox(children=(BoundedFloatText(value=0.34, description='$w0_{in}$: ', max=1.0, min=0.1, step=0…

```python

```

```python

```

```python

```


# Training of Linear Model

As I am just a beginner in Machine Learning and I have no strong intuition in underlying concepts like operations in linear algebra, it is not an easy task to comprehend the meaning of analytical solutions for linear regression. In the Internet different tutorials on linear algebra can be found, and some of them are "specially designed" for machine learning/data science entrants. The problem I see there is that even after taking one or more of those (as I did), you may still struggle in applying newly learned concepts on the spot to develop some intuition about what's going on underneath the hood. This notebook is how I explained theoretical equations from the 4th chapter of the textbook by Aureilen Geron (my version is in Russian, so don't worry if my translation mismatches the original text). Please do not consider the text below as something 100% right. If you spot a mistake, please, let me know how to correct it. Hope this notebook will be helpful for somebody else too.

## Closer Look at Linear Regression Model

Among known machine learning algorithms, something called linear model regression is one of the simplest (if not the absolutely simplest one). It is easy to express in words how it works: the algorithm is trying to find a straight line (in case of two dimensions, which is the simplest case) such that it fits all the data points the best on average. Let's visualize it!

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

np.random.seed(21) # Initialize seed in order to make the results reproducable.
X = 4 * np.random.rand(100, 1)
y_ = 5 + 2 * X
y = y_ + np.random.randn(100, 1) # Add some random noise.
plt.plot(X, y, 'b.')
plt.show()
```

### Linear Function Written in a Linear Algebra Way

As you can find in Google, the algorithm uses some technique called "least squares method" to find the line with the best fit on average. Long story short, it searches for a line such that the sum: \begin{equation}\sum^n_{i=1}(h(x_i)
-y_i)^2\end{equation}
is at its minimum. This just means that we calculate the difference between predicted and actual values for each entity, square it, and then sum them all together. Nothing very special here, if we don't go deeper (and we won't do it here).

```
plt.plot(X, y, 'b.', label='Dataset')
plt.plot(X, y_, 'r-', label='Least Squares Solution')
plt.legend(loc='lower right')
plt.show()
```

Since there is only one such line, there is also an analytical approach (i.e. deriving a formula based on a bunch of theorems) to find the equation for this line. Let's have a look at some equations and try to discover analogies between "common" forms and matrix forms.

In general, the *hypothesis function* (*h(x)* that makes predictions based on *n* features) can be written as follows:
\begin{equation}h(x)=\theta_0+\theta_1x_1+\theta_2x_2+...+\theta_nx_n=\theta_0+\sum^n_{i=1}{\theta_ix_i}\end{equation}
or in matrix form:
\begin{equation}h(x)=\theta^Tx\end{equation}
Hence,
\begin{equation}\theta^Tx=\theta_0+\sum^n_{i=1}{\theta_ix_i}\end{equation}
***θ*** is a coefficient vector, which is the synonym for list of coefficients. Notice that **x** is also a vector, but one that contains features of a particular entity. For example, it can be a list with age, area, number of rooms and so on of a house. If we use n features in our model, we must have n coefficients to make predictions. Rules for matrix multiplication differ from rules for multiplication of real numbers. To ensure that multiplication is valid, we need to check the shapes of two matrices. Say, an *m × r* matrix can be multiplied only by *r × n* matrices. That's it, to perform matrix multiplication, two inner numbers must be equal! In our case, *θ* and *x* will both be vectors (shape 100 × 1), so we need to transpose *θ*, so that its single column becomes a single row (1 × 100), and we proceed. The dot product of 1 × 100 shape by 100 × 1 shape results in a 1 × 1 solution. This makes sense, because in this example we tried to predict an outcome for only 1 entity with 100 attributes. Hopefully, this equation shines more intuitively clear for you now. Check the code below anyway.

```
# y = 5 + 2 * x
theta = np.array([5, 2])
x = np.array([1, 2])
print(theta.T.dot(x))
```

    9

```
# Adding more features: y = 25 + 4 * x1 - 7 * x2 - 16 * x3 + 2 * x4
theta = np.array([25, 4, -7, -16, 2])
x = np.array([1, 13, 2, 0, 1.5])
print(theta.T.dot(x))
```

    66.0

Notice that if the intercept (*θ*[0]) is included in theta as its first value, then *x* must have a 1 on front.

Similar job (with additional operations) is done to find losses of the model. The equation for mean squared error in matrix format looks like this:
\begin{equation}MSE(X, h_\theta)=\frac{1}{m}\sum^m_{i=1}(h(x_i)-y_i)^2=\frac{1}{m}\sum^m_{i=1}(\theta^Tx_i-y_i)^2\end{equation}

And we move on to the next equation.

### Black Magic to Find Coefficients

This equation actually drove me to reflect on equations in matrix form deeper, because previous didn't look like absolutely unclear and was kinda intuitive. But not this one. Ladies and gentlemen, the crazy ***normal equation***. This is a *closed-form solution* (using a bit of fancy math) for linear regression. We can just paste our data in it, perform some calculations and obtain coefficients that minimize losses. Cool, huh!
\begin{equation}\hat{\theta}=(X^TX)^{-1}X^Ty\end{equation}
In order to comprehend this, I decided to look at something similar:
\begin{equation}A=(X^TX)^{-1}X^TX\end{equation}
If you remember the row-column rule for matrix multiplication, we need the inner sizes of two matrices to be equal (m × r and r × n). When we transpose a matrix, its rows and columns are swapped, so `X.T @ X` in terms of sizes can be viewed as `(n × m) @ (m × n) == (n × n)`.

```
M = np.array([[1, 2, 3],
              [4, 5, 6]])
M_mult = M.T.dot(M)
print(f'{M.T.shape} x {M.shape} = {M_mult.shape}')
```

    (3, 2) x (2, 3) = (3, 3)

Now we have a special matrix with equal count of rows and columns - a ***square matrix***. I'll tell you later why we care about this. But for now consider the next two steps. First, we raise the dot product to the power of -1, which is equivalent to division of 1 by this product. In the world of linear algebra, it is called the ***inverse*** of a matrix. Second, we multiply the inverse of dot product by the dot product. There's a parallel to real numbers arithmetic. `1 / n * n == 1`, so `(X.T @ X) ** -1 @ (X.T @ X) == 1`? Not quiet. In case of matrices a multiplication like this one results in something called an ***identity matrix***. Identity matrix is an m × m matrix (i.e. a square matrix) with all 1's on its *main diagonal* (from top left to bottom right) and 0's elsewhere.
\begin{equation}I_3=\begin{bmatrix}1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1\end{bmatrix}\end{equation}

Back to the normal equation and its little sibling. We can write:
\begin{equation}A=(X^TX)^{-1}X^TX = I_n\end{equation}
\begin{equation}\hat{\theta}=(X^TX)^{-1}X^TX=(X^{-1}A)y=(X^{-1}I_n)y\end{equation}

Which now is the straightforward definition of a coefficient of a linear function. Its interpretation in a real numbers algebra is:
\begin{equation}\hat{\theta}=1\frac{y}{x}=\frac{y}{x}\end{equation}

At this point you may wonder, why we have to perform all these transpositions and matrices dot multiplications, when we could just multiply the inverse of X by y? Well, we couldn't. And the reason is that not every matrix has the inverse. If it has, such matrix is said to be ***invertible***. Otherwise, a matrix is ***singular*** (*noninvertible*).

```
try:
    np.linalg.inv(M)
except np.linalg.LinAlgError:
    print('Whoops!')
```

    Whoops!

A matrix must satisfy two properties to be invertible:

1.   It must be a square matrix
2.   Its determinant must be a non-zero value

These properties arise from the equation for the inverse of a matrix:
\begin{equation}A^{-1}=(\begin{bmatrix}a & b \\ c & d\end{bmatrix})^{-1}=\frac{1}{det(A)}\begin{bmatrix}d & -b \\ -c & a\end{bmatrix}\end{equation}

The determinant of a 2 × 2 matrix is calculated as follows:
\begin{equation}det(A)=det(\begin{bmatrix}a & b \\ c & d\end{bmatrix})=ad-bc\end{equation}

Here I'm not going to explain the meaning of the determinant. If you want more details, I recommend you to watch a series of videos on [linear algebra](https://www.youtube.com/watch?v=fNk_zzaMoSs&list=PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab) by [3Blue1Brown](https://www.youtube.com/channel/UCYO_jab_esuFRV4b17AJtAw/featured) for nice explanations of basic terms of linear algebra and their meanings.

In the reality we almost always deal with non-square matrices, and even if they are square, the determinant may still be undefined.

To summarize, the process of finding coefficients is really straightforward in its core: we just divide *y* by *x*. Due to restrictions introduced by laws of linear algebra, we have to take a detour to guaranteed perform it. When we write it in matrix form, then the really simple idea is expressed in a fancy way (for newbies in linear algebra). So now I hope it doesn't seem fancy for you, as it seemed to me.

One more thing I'd like to discuss here is related to cases with multiple attributes. If we have something like:
\begin{equation}y=\theta_0+\theta_1x_1+\theta_2x_2+\theta_3x_3\end{equation}

then finding corresponding coefficients is essentially equivalent to finding *partial derivatives*. In other word, how much changes *y* with change of a particular attribute (*x1*, *x2*, *x3*). Thus, in case of linear equation, the vector of coefficients is a vector of partial derivatives:
\begin{equation}
\theta =
\begin{bmatrix}
\theta_1=\frac{\partial}{\partial x_1}y \\
\theta_2=\frac{\partial}{\partial x_2}y \\
\theta_3=\frac{\partial}{\partial x_3}y \\
... \\
\theta_n=\frac{\partial}{\partial x_n}y
\end{bmatrix}
\end{equation}

It makes sense. Coefficients in a linear equation mean how much we need to add when corresponding variable was increased by one.


```python
import random
import time
import matplotlib.pyplot as plt
import numpy as np
from random import choices
import sympy
from sympy import Matrix
from sympy import fraction
from IPython.display import display, Markdown
```

```python
def var(v):
    return sympy.symbols(v)
```

```python
a = [2, 1, 1, 0, 3, 1, 0, 0, 4] #upper triangle 2*3*4 = 24
b = [1, 0, 0, 0, 1, 0, 0, 0, 1] #identity I = 1
c = [75, 36, 94, 93, 52, 8, 67, 57, 15, 3, 49, 61, 51, 26, 11, 23, 6, 63, 92, 79, 10, 16, 42, 66, 95] #5x5
d = [1, 6, 4, 2, 4, -1, -1, 2, 5] #dne inverse = 0
p = [1/2, 1/2, 0, 1/2, 1/2, 0, 0, 0, 1] #projection matrix P^2 = P
pe = [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1] #permutation matrix P*P.T = P.T*P = P
e = [1, 1, 1, 1, 2, 2, 1, 2, 3]
v = [var('x%d' % i) if i%(5+1)==0 else 0 for i in range(5**2)] #diagonal variable matrix: D = prod {diagonals}
v2 = [var('x%d' % i) for i in range(3**2)] #full variable matrix
F = [1, 2, 3, 2, 2, 3, 3, 3, 3]
s = [3, -2, 4, -2, 6, 2, 4, 2, 3]
o = [3, 0, 0, 0, 0, 2, 0, 1, 0]
r = [8,3,0,0,5,0,6,0,5,3,
6,7,9,5,7,5,8,0,4,5,
1,2,9,5,7,3,5,0,5,6,
8,5,0,6,8,7,0,2,7,5,
6,8,4,4,4,4,5,8,5,6,
9,2,1,2,1,8,3,9,4,5,
6,4,6,2,3,2,6,3,7,9,
4,2,9,9,2,6,4,2,3,0]

c_1 = 3
c_2 = 5
c_3 = 4
c_4 = 8
```

```python
class sq_matrix:
    def __init__(self, m, cols, title="A"):
        self.m = m
        self.cols = cols
        self.title = title
        self.m_T = self.T(self.m[:], self.cols)
        self.m_ID = [1 if i%(self.cols+1)==0 else 0 for i in range(self.cols**2)]

    '''
    Matrix defs.
    '''

    #get sq_matrix 2d index
    def get_mat_indx(self, i, j):
        return self.mat_indx(self.m, self.cols, i, j)

    #get matrix 2d index
    def mat_indx(self, m, cols, i, j):
    #gets content of a_ij of nxn matrix A
    #subtract by 1 to get math-like form, st. a11 is the top left most content
        i -= 1
        j -= 1
        return m[i*cols + j]

    #swap entry of a matrix
    def swap_entry(self, m, cols, i_1, j_1, i_2, j_2):
    #for M nxn, does m_i1j1 => mi2j2
        temp = self.mat_indx(m, cols, i_1, j_1)
        m[(i_1-1)*cols+(j_1-1)] = self.mat_indx(m, cols, i_2, j_2)
        m[(i_2-1)*cols+(j_2-1)] = temp

    #get transpose of sq_matrix
    def get_T(self):
        return self.T(self.m, self.cols)

    #gets transpose of matrix
    def T(self, m, cols):
        for i in range(1, cols+1):
            for j in range(i, cols+1):
                self.swap_entry(m, cols, i, j, j, i)

        return m

    '''
    Row/Col. handling
    '''

    #get row vector
    def get_row(self, r):
        if r > self.cols or r < 1:
            raise ValueError(f"Row must be in matrix: Given {r} for ({self.cols}x{self.cols}) matrix")

        return self.to_matrix()[r-1]

    #get row indexes
    def get_row_elems(self, r):
        if r > self.cols or r < 1:
            raise ValueError(f"Row must be in matrix: Given {r} for ({self.cols}x{self.cols}) matrix")
        r -= 1

        #start at first elem of row, ie row 2 for 3x3 is: [2*3]
        st = r*self.cols
        return [i for i in range(st, st+self.cols)]

    #get col. indexes
    def get_col_elems(self, c):
        if c > self.cols or c < 1:
            raise ValueError(f"Col. must be in matrix: Given {r} for ({self.cols}x{self.cols}) matrix")
        c -= 1

        #start at first elem of row, ie row 2 for 3x3 is: [2*3]
        st = c
        return [st+i*self.cols for i in range(0, self.cols)]

    #gets col. vector
    def get_col(self, c):
        if c > self.cols or c < 1:
            raise ValueError(f"Col. must be in matrix: Given {r} for ({self.cols}x{self.cols}) matrix")

        self.get_T()
        ans = self.to_matrix()[c-1]
        self.get_T()
        return ans

    '''
    Determinant
    '''

    #gets minor M_ij of a matrix
    def minor(self, m, cols, i, j):
        #gets M_ij by adding a_ij st

        m_n = []
        mtx = sq_matrix(m, cols)

        dels = mtx.get_row_elems(i)+mtx.get_col_elems(j)

        for x in range(cols*cols):
            if x not in dels:
                m_n.append(m[x])

        return self.det(m_n, cols-1)

    #gets determinant of sq_matrix
    def get_det(self, i=1):
        return self.det(self.m, self.cols)

    #get determinant of a matrix
    def det(self, m, cols, i=1):
        #find determinant of nxn matrix via cofactoring
        if cols < 1:
            return "must have > 1 col"

        #base case 2x2 matrix
        #print(m)
        if cols == 2:
            return m[0]*m[3]-m[1]*m[2]

        #sum of a ij * cij, for j = 1 -> n
        M_ij = lambda i,j: self.minor(m, cols, i, j)
        C_ij = lambda i,j: (-1)**(i+j) * M_ij(i, j)
        Cof_ij = lambda i,j: self.mat_indx(m, cols, i, j)*C_ij(i, j)

        return sum([Cof_ij(i, j) for j in range(1, cols+1)])

    '''
    Inverse of matrix
    '''

    def get_inverse(self, sep=False):
        return self.inverse(self.m, self.cols, sep)

    def inverse(self, m, cols, sep=False):
        det = self.det(m, cols)

        if not det:
            return sq_matrix([0],1)

        if cols==2:
            return det, sq_matrix([m[3], -m[1], -m[2], m[0]],2)

        cof_mat = cols**2*[0]

        #adj(A) = Cof^T = ((-1)^(i+j) M_ji)

        Cof_ij = lambda i,j: (-1)**(i+j) * self.minor(m, cols, i, j)

        for row in range(cols):
            for col in range(cols):
                cof_mat[row*cols + col] = Cof_ij(col+1, row+1)

        cof_mat = sq_matrix(cof_mat, cols)

        if not sep:
            cof_mat.m = [x / det if x!=0 else 0 for x in cof_mat.m]
            return cof_mat

        return det, cof_mat

    '''
    Eigenvalues
    '''

    #gets eq. det(sq_matrix - λ*I)
    def eigenvalues_lhs_mat(self):
        lmb_ID_a = sq_matrix(self.m[:], self.cols)
        lmb_ID_b = sq_matrix(self.m_ID[:], self.cols)
        lmb_ID_b.scalar(sympy.symbols('λ'))

        return (lmb_ID_a - lmb_ID_b).m

    def eigenvalues_lhs(self):
        return self.det(self.eigenvalues_lhs_mat(), self.cols)

    #gets eigen values by using lhs = 0 via sympy solve
    def get_eigenvalues(self,  no_imag=True, rational=False):
        res = sympy.solve(self.eigenvalues_lhs(), sympy.symbols('λ'))

        if rational:
            res = [sympy.Rational(i) for i in res]
        else:
            res = [sympy.N(i) for i in res]
        reals = []
        imags = []

        for i in res:
            if i.is_real:
                reals.append(float(i))

        for i in res:
            if not i.is_real:
                imags.append(i)

        if no_imag:
            return reals

        return reals, imags

    def get_trace(self):
        return self.trace(self.m, self.cols)

    def trace(self, m, cols):
        mat = sq_matrix(m, cols)

        return sum([i**2 for i in mat.get_eigenvalues()])

    '''
    Eigenvectors
    '''
    def get_eigenvectors(self):
        pass

    '''
    RREF
    '''

    #does RREF on sq_matrix
    def get_RREF(self):
        return self.RREF(self.m, self.cols)

    #does RREF via sympy rref
    def RREF(self, m, cols):
        mat = sq_matrix(m, cols)
        mtx = mat.to_matrix()
        mtx = Matrix(mtx)
        rref_mtx = list(mtx.rref()[0])
        res = sq_matrix(rref_mtx, cols)

        return res

    def get_pivots(self):
        return self.pivots(self.m, self.cols)

    def pivots(self, m, cols):
        rref = self.RREF(m, cols)
        mtx = rref.to_matrix()
        pivots = []
        for i in mtx:
            temp = list(filter(lambda x: x!=0, i))
            if temp:
                pivots.append(temp[0])

        return pivots

    def get_rank(self):
        return self.rank(self.m , self.cols)

    def rank(self, m, cols):
        return len(self.pivots(m, cols))

    def full_rank(self):
        return self.get_rank()==self.cols

    '''
    Cramer's Rule
    '''

    #finds solution x, for Ax = b, via cramer's rule
    def cramer(self, b):
        if len(b) != self.cols:
            raise ValueError(f"vector must have same length as matrix cols: Given ({len(b)}), ({self.cols}x{self.cols})")

        #x_i = det(A_i)/det(A)
        det = self.get_det()

        cols = [self.get_col(i) for i in range(1, self.cols+1)]

        A_i = [i[:] for i in [cols]*self.cols]
        temp = []

        #generate A_i's
        for i in range(len(A_i)):
            A_i[i][i] = b
            for j in A_i[i]:
                temp += j
            A_i[i] = temp
            temp = []

            A_i[i] = sq_matrix(sq_matrix(A_i[i], self.cols).m_T, self.cols)

        dets = [self.det(i.m, self.cols) for i in A_i]

        res = [d/det for d in dets]

        return res

    '''
    Check Matrix Type
    '''

    #checks if matrix is invertible
    def inv(self):
        return self.get_det()!=0

    #checks if matrix is identity
    def iden(self):
        return self.get_det()==1

    #check if sq_matrix is symmetric
    def sym(self):
        return self.m==self.m_T

    #check if sq_matrix is proj. matrix
    def proj(self):
        return self*self==self

    #check if orthogonal matrix
    def ortho(self):
        tmp = sq_matrix(self.m_T, self.cols)
        tmp_id = sq_matrix(self.m_ID, self.cols)
        return tmp*self == self*tmp == tmp_id

    def perm(self):
        for i in self.m:
            if i not in [0, 1] or i<0: return False
        return self.ortho()

    '''
    Operators
    '''

    #dot product of two vectors
    def dot(self, v1, v2):
        return sum([v1[i]*v2[i] for i in range(self.cols)])

    #matrix mul. of two matrices
    def __mul__(self, other):
        if self.cols != other.cols:
            raise ValueError(f"Both matrices must have same size: Given ({self.cols}x{self.cols}), ({other.cols}x{other.cols})")

        m_tmp = []

        for row in range(1, self.cols+1):
            for col in range(1, self.cols+1):
                res = self.dot(self.get_row(row), other.get_col(col))
                if res % 1 == 0:
                    res = int(res)
                m_tmp.append(res)

        return sq_matrix(m_tmp, self.cols)

    #multiplies matrix by scalar
    def scalar(self, s):
        self.m = [x*s if x!=0 else 0 for x in self.m]
        self.update()

    #add two matrices together
    def __add__(self, other):
        if self.cols != other.cols:
            raise ValueError(f"Both matrices must have same size: Given ({self.cols}x{self.cols}), ({other.cols}x{other.cols})")

        return sq_matrix([self.m[i]+other.m[i] for i in range(self.cols*self.cols)], self.cols)

    #sub two matrices together
    def __sub__(self, other):
        if self.cols != other.cols:
            raise ValueError(f"Both matrices must have same size: Given ({self.cols}x{self.cols}), ({other.cols}x{other.cols})")

        return sq_matrix([self.m[i]-other.m[i] for i in range(self.cols*self.cols)], self.cols)

    #checks if self matrix == other matrix
    def __eq__(self, other):
        return self.m==other.m

    #updates m_T
    def update(self):
        self.m_T = self.T(self.m[:], self.cols)

    '''
    Printing
    '''

    #generates summary of matrix
    def summary(self):
        start = time.time()
        transp = sq_matrix(self.m_T, self.cols)
        rref = self.get_RREF()
        eigenvals = self.get_eigenvalues(False)

        print(self.cols*"-----")
        print(f"{self.title} ({self.cols}x{self.cols}):")

        self.print_matrix()

        print(f"{self.title}.T:")

        transp.print_matrix()

        print(f"{self.title}^-1:")

        if not self.inv():
            print("Matrix not invertible")
        else:
            self.print_inverse()

        print(f"{self.title}_RREF:")

        rref.print_matrix()

        print(f"\nDet: {self.get_det()} | Exists A^-1: {self.inv()} | Is I_{self.cols}: {self.iden()} \n")
        print(f"Symmetric: {self.sym()} | Projection: {self.proj()} | Orthogonal: {self.ortho()} | Permutation: {self.perm()} \n")

        print(f"Real Eigenvalues: {eigenvals[0]}")
        print(f"Complex Eigenvalues: {eigenvals[1]} \n")

        print(f"Rank: {self.get_rank()} | Full Rank: {self.full_rank()}")
        print(self.cols*"-----")

        end = time.time()
        elp = end-start
        print(f"took {round(elp*1000,3)}ms")

    #returns 2d matrix rep. of 1d matrix
    def to_matrix(self):
        return [[self.get_mat_indx(row, col) for col in range(1, self.cols+1)] for row in range(1, self.cols+1)]

    #prints 2d matrix rep. via latex md
    def to_latex(self):
        #for row in range(self.cols):
        #    print(self.to_matrix()[row])
        bls = chr(92)

        res = bls + "begin{bmatrix}"
        for row in range(1, self.cols+1):
            for ele in self.get_row(row):
                res += f"{ele} &"
            res = res[:-1] + 2*bls
        res = res[:-2] + bls+"end{bmatrix}"
        return res

    def print_matrix(self):
        display(Markdown(self.to_latex()))

    def print_inverse(self):
        res = self.get_inverse(True)
        display(Markdown(f"$\\frac{1}{ {res[0]} } * $"))
        res[1].print_matrix()

    def get_heatmap(self, cm='binary', interp = 'nearest', xlims=None, ylims=None):
        self.heatmap(self.m, cm, interp, xlims, ylims)

    def heatmap(self, m, cm='binary', interp = 'nearest', xlims=None, ylims=None):
        if xlims != None:
            plt.xlim([xlims[0],xlims[1]])

        if ylims != None:
            plt.ylim([ylims[0],ylims[1]])

        np_m = np.matrix(sq_matrix(m[:], self.cols).to_matrix())
        plt.imshow(np_m, cmap=cm, interpolation=interp)
        plt.show()

    #prints 2d rep. of matrix via print(sq_matrix)
    def __str__(self):
        self.print_matrix()
        return ""
```

```python
class gen_sq_matrix(sq_matrix):
    def __init__(self):
        self.m = [0,0,0,0]
        self.cols = 2
        self.title = "A"
        self.update()

    def update(self):
        self.m_T = self.T(self.m[:], self.cols)

    def to_mat(self):
        return sq_matrix(self.m, self.cols, self.title)

    def I(self, n=None):
        if n==None or n < 2: n = self.cols
        self.m = [1 if i%(n+1)==0 else 0 for i in range(n**2)]
        self.cols = n
        self.update()

    def full_num(self, num, n=None):
        if n==None or n < 2: n=self.cols
        self.m = n**2*[num]
        self.cols = n
        self.update()

    def empty(self, n=None):
        self.full_num(0, n)
        self.update()

    def ones(self, n=None):
        self.full_num(1, n)
        self.update()

    def scalar(self, s):
        self.m = [x*s if x!=0 else 0 for x in self.m]
        self.update()

    def gauss(self, amp, sigs, mus):
        self.fapply_2d(lambda e_i, x, y: amp*np.exp(-( ((x-mus[0])**2/(2*sigs[0])) + ((y-mus[1])**2/(2*sigs[1])))))

    def fapply_1d(self, f):
        self.m = [f(self.m[x], x) for x in range(self.cols**2)]
        self.update()

    def fapply_2d(self, f):
        for row in range(self.cols):
            for col in range(self.cols):
                self.m[row*self.cols + col] = f(self.m[row*self.cols + col], row+1, col+1)
        self.update()

```

```python
class chem_balance(sq_matrix):
    def __init__(self, r1, r2):
        #r1 = reactant, r2 = reaction
        #Element: (element, #)
        #Compound [Element, ...]
        #Reactant/Reaction = [Compound, ...]
        self.r1 = r1
        self.r2 = r2
        self.contains = self.contents()
        self.num_elems = len(self.contains)
        self.elems = list(self.contains.keys())

    def contents(self):
        dic = {}
        for comp in self.r1+self.r2:
            for elem in comp:
                #print(elem)
                key = elem[0]
                val = elem[1]
                if elem[0] in dic:
                    dic[key][0] += 1
                    dic[key][1] += val
                else:
                    dic[key] = [1, val]

        return dic

    def make_rows(self):
        rows = []

        for ele, comp in enumerate(self.r1+self.r2):
            r = []
            for indx, i in enumerate(self.elems):
                udp = False
                for elem in comp:
                    key = elem[0]
                    val = -elem[1] if ele >= len(self.r1) else elem[1]
                    if key==i:
                        r.append(val)
                        udp = True

                if not udp:
                    r.append(0)

            rows.append(r)

        return rows

    def to_mat(self):
        rows = self.make_rows()

        diff = len(rows)-len(rows[0])
        if(len(rows) > len(rows[0])):
            for i in rows:
                i.append([0]*diff)
        if(len(rows) < len(rows[0])):
            for i in range(diff):
                rows.append([0]*len(rows[0]))

        mat = []
        for i in rows:
            mat += i

        mat_T = sq_matrix(mat, len(rows)).m_T
        res = sq_matrix(mat_T, len(rows))
        return res

    def balance(self):
        res = Matrix(self.to_mat().to_matrix()).nullspace()[0]

        denoms = [fraction(i)[1] for i in res]
        lcm = np.lcm.reduce(denoms)
        return lcm*res

    def latex_result(self):
        bal = self.balance()
        res = ""
        for index, comp in enumerate(self.r1+self.r2):
            res = res + "" + str(bal[index])
            for elem in comp:
                key = elem[0]
                val = elem[1]
                res = res + str(key) + "_" + str(val)

            if index==len(self.r1)-1: res = res + "\\rightarrow"
            if index!=len(self.r1+self.r2)-1 and index!=len(self.r1)-1: res = res + "+"

        display(Markdown("$" + res + "$"))

    def __str__(self):
        for i in self.r1:
            print(i)

        for j in self.r2:
            print(j)
        return ""

```

```python
ra1 = [("P", 1), ("Cl", 5)], [("H", 2), ("O", 1)]
ra2 = [("H", 3), ("P", 1), ("O", 4)], [("H", 1), ("Cl", 1)]
#rb1 = [("N", 2)], [("H", 2)]
#rb2 = [("N", 1), ("H", 3)], []
#print(ra1)
#print(rb1)
M = chem_balance(ra1, ra2)

M.latex_result()
```

$1P_1Cl_5+4H_2O_1\rightarrow1H_3P_1O_4+5H_1Cl_1$

```python
rot = [sympy.cos(var("x")), -sympy.sin(var("x")), sympy.sin(var("x")), sympy.cos(var("x"))]
M = sq_matrix(rot, 2)
M.summary()
```

    ----------
    A (2x2):

\begin{bmatrix}cos(x) &-sin(x) \\sin(x) &cos(x) \end{bmatrix}

    A.T:

\begin{bmatrix}cos(x) &sin(x) \\-sin(x) &cos(x) \end{bmatrix}

    A^-1:

$\frac1{sin(x)**2 + cos(x)**2} * $

\begin{bmatrix}cos(x) &sin(x) \\-sin(x) &cos(x) \end{bmatrix}

    A_RREF:

\begin{bmatrix}1 &0 \\0 &1 \end{bmatrix}

    Det: sin(x)**2 + cos(x)**2 | Exists A^-1: True | Is I_2: False

    Symmetric: False | Projection: False | Orthogonal: False | Permutation: False

    Real Eigenvalues: []
    Complex Eigenvalues: [exp(-I*x), exp(I*x)]

    Rank: 2 | Full Rank: True
    ----------
    took 565.649ms

```python
M = gen_sq_matrix()
M.ones(3)
M.fapply_1d(lambda e, i: i)
M = M.to_mat()
M.summary()
```

    ---------------
    A (3x3):

\begin{bmatrix}0 &1 &2 \\3 &4 &5 \\6 &7 &8 \end{bmatrix}

    A.T:

\begin{bmatrix}0 &3 &6 \\1 &4 &7 \\2 &5 &8 \end{bmatrix}

    A^-1:
    Matrix not invertible
    A_RREF:

\begin{bmatrix}1 &0 &-1 \\0 &1 &2 \\0 &0 &0 \end{bmatrix}

    Det: 0 | Exists A^-1: False | Is I_3: False

    Symmetric: False | Projection: False | Orthogonal: False | Permutation: False

    Real Eigenvalues: [0.0, -1.3484692283495343, 13.348469228349535]
    Complex Eigenvalues: []

    Rank: 2 | Full Rank: False
    ---------------
    took 25.929ms

```python

```

```python

```

```python
n = 250
gen_a = gen_sq_matrix()
gen_a.ones(n)
gen_a.scalar(1)

gen_a.fapply_2d(lambda e, x, y: np.ceil(np.sin(x/y * 180/np.pi))%2==0)
gen_a.get_heatmap('hot')

gen_a.fapply_2d(lambda e, x, y: np.floor(np.sin(x/y * 180/np.pi))%2==0)
gen_a.get_heatmap('hot')
```

```python
cols = 100
amp = 1
sig_x, sig_y = [cols*(cols/10)/2, cols*(cols/10)/2]
x_0, y_0 = [cols/2, cols/2]

M = gen_sq_matrix()
M.ones(cols)
M.fapply_2d(lambda e, x, y: x)
M = M.to_mat()
M.get_heatmap()
```


\tableofcontents
% These TeX commands run at the start to remove section numbering
\renewcommand{\thesection}{\hspace*{-1.0em}}
\renewcommand{\thesubsection}{\hspace*{-1.0em}}
\renewcommand{\thesubsubsection}{\hspace*{-1.0em}}

```python
%pylab inline
#%matplotlib qt
from __future__ import division # use so 1/2 = 0.5, etc.
import ssd
import scipy.signal as signal
from IPython.display import Audio, display
from IPython.display import Image, SVG
```

    Populating the interactive namespace from numpy and matplotlib

```python
pylab.rcParams['savefig.dpi'] = 100 # default 72
#pylab.rcParams['figure.figsize'] = (6.0, 4.0) # default (6,4)
#%config InlineBackend.figure_formats=['png'] # default for inline viewing
%config InlineBackend.figure_formats=['svg'] # SVG inline viewing
#%config InlineBackend.figure_formats=['pdf'] # render pdf figs for LaTeX
```
%Graphics embed example
%Image('fname.png',width='90%')
%Commandline for conversion to article:
%>jupyter nbconvert --to latex --template article  "Template.ipynb"
% Commandline conversion to pdf in one step:
%>jupyter nbconvert --to latex --template article  "Template.ipynb" --post pdf
\newpage

```python
import digitalcom as dc
import lab6
#reload(lab6)
```

# Lab 6 FSK Error Probability
This short notebook provides a checkout of the bit error probability tools found in `lab6.py`. The function `fsk_BEP()` in particular has been update to fix an error in calling the function `dc.BPSK_BEP()` with both the rc and tx data.

```python
def fsk_BEP(rx_data,m,flip):
	"""
	fsk_BEP(rx_data,m,flip)

	Estimate the BEP of the data bits recovered
	by the RTL-SDR Based FSK Receiver.

	The reference m-sequence generated in Python
	was found to produce sequences running in the opposite
	direction relative to the m-sequences generated by the
	mbed. To allow error detection the reference m-sequence
	is flipped.

	Mark Wickert April 2014
	"""
	Nbits = len(rx_data)
	c = dc.m_seq(m)
	if flip == 1:
	    # Flip the sequence to compenstate for mbed code difference
	    # First make it a 1xN array
	    c.shape = (1,len(c))
	    c = np.fliplr(c).flatten()
	L = int(np.ceil(Nbits/float(len(c))))
	tx_data = np.dot(c.reshape(len(c),1),np.ones((1,L)))
	tx_data = tx_data.T.reshape((1,len(c)*L)).flatten()
	tx_data = tx_data[:Nbits]
	# Convert to +1/-1 bits
	tx_data = 2*tx_data - 1
    # Note the next line contains a change from the
    # original file as both rx_data and tx_data are input:
	Bit_count,Bit_errors = dc.BPSK_BEP(rx_data,tx_data)
	print('len rx_data = %d, len tx_data = %d' % (len(rx_data),len(tx_data)))
	Pe = Bit_errors/float(Bit_count)
	print('/////////////////////////////////////')
	print('Bit Errors: %d' % Bit_errors)
	print('Bits Total: %d' % Bit_count)
	print('       BEP: %2.2e' % Pe)
	print('/////////////////////////////////////')
```

* Add noise to a $\pm 1$ binary bit sequence, then make hard decisions using the `sign()` function
* Use the hard decision data bits as simulated FSK receive data

```python
rx_data = 2*ssd.PN_gen(10000,5) - 1
rx_data = dc.cpx_AWGN(rx_data,4,1).real
rx_data = sign(rx_data)
```

```python
lab6.fsk_BEP(rx_data,5,0)
```

    kmax =  0, taumax = 0
    len rx_data = 10000, len tx_data = 10000
    /////////////////////////////////////
    Bit Errors: 109
    Bits Total: 10000
           BEP: 1.09e-02
    /////////////////////////////////////

* Are the errors counted resaonable?
* The `dc.cpx_AWGN()` function adds Gaussian noise to the input $\pm 1$ data bits, such that the probbility of a bit error is related to the Gaussian PDF tail probability by the complementary error function as:
\begin{align}
    P_e = \frac{1}{2} \text{erfc}\left(\sqrt{\frac{10^{\text{EbN0}_{\text{dB}}/10}}{2}}\right)
\end{align}
* Now compare the above experimental result to theory by setting `EbN0 = 4`

```python
EbN0 = 4
print('BEP thy = %1.3e' % (dc.erfc(sqrt(10**(EbN0/10)))/2))
```

    BEP thy = 1.250e-02

* The results are reasonable given only ~100 error events are counted

```python

```

Lab6_python/FSK Error Probability Helper.ipynb


```python
from IPython.display import HTML
import matplotlib.pyplot as plt
import matplotlib
from math import floor
import numpy as np
import pandas as pd
from scipy.optimize import newton
from scipy.optimize import brentq
from scipy import linalg
from scipy.interpolate import griddata
import statsmodels.api as sm
%matplotlib inline

# The following are the modules developed in testing here
from atmPy import aerosol
from atmPy import atmosphere as atmos
import atmPy.instruments.DMA.dma  as dma

from imp import reload
```

Before doing anything, define a simple object which will allow us to perform calculations using the properties of air.  The object ``air`` is defined in the package ``atmosphere``.  The object ``air`` is a child of the abstract class ``gas`` which has two properties, temperature and pressure.  The default conditions are $T=20^{\circ}$C and $P=1013.25$ mb.

```python
air = atmos.Air()
```

# DMA Calculations

Below are the equations used to define the calculations used by the DMA.  First, we calculate the electic mobility $Z_e$ as

\begin{equation}
Z_e = \frac{e*C_c}{3\pi\mu{D}}
\end{equation}

where $\mu$ is the viscocity as a function of temperature $T$, $C_c$ is the Cunningham correction factor for particles in the transition regime and $e$ is the .

## Viscocity

In the equation above, the viscocity of air is calcualted as

\begin{equation}
\mu = \mu_0\frac{C+T_0}{C+T}\left(\frac{T}{T_0}\right)^{1.5}
\end{equation}

This is Sutherland's formula.  In the above equation, the values are as follows:

* $C$ is the Sutherlan's constant.  For air this value is 120.
* $T_0$ is the reference temperature.  In this case, we use 291.15 K.
* $\mu_0$ is the corresponding reference viscocity.  For the given reference temperature, this value is 18.27e-6 Pa-s.

The cell below shows the calculation with a plot for a range of temperatures from 2 to 52 degrees Celsius.

```python
T0 = 275 # Initial temperature for plotting
T1 = 325 # Final temperature for plotting

def mu(T):
    air.t = T
    return air.mu()

# range of viscocity covering the range of temperature above
z = [mu(i) for i in range(T0,T1)]

# List of temperatures in Kelvin
x = list(range(275,325))

# Convert the list of temperatures to degrees Celsius
x = [float(i)-273.15 for i in x]
```

```python
# Plot the data above
fig = plt.figure()
ax = plt.axes()
plt.ylabel(r'$\mu$ (Pa-s)')
plt.xlabel(r'$T$ (C)')
plt.title('Viscocity as a function of temperature')
ax.yaxis.set_major_formatter(matplotlib.ticker.ScalarFormatter(useOffset=False))
ax.grid(True)
plt.plot(x, z)
```

## Cunningham Correction Factor

The Cunningham correction factor from the equation above is a function of particle diameter and the mean free path of the carrier gas and can be calculated as

\begin{equation}
C_c = \left[1.05\exp\left(-0.39\frac{D}{\lambda}\right)+2.34\right]\times\frac{\lambda}{D}+1
\end{equation}

for diameters less than 100 nm and

\begin{equation}
C_c = \frac{2.25\lambda}{D}+1
\end{equation}

where $D$ is the particle diameter in $\mu$m and $\lambda$ is the mean free path of the gas which can be calculated as

\begin{equation}
\lambda = \lambda_0\frac{P_0}{P}
\end{equation}

Here, $P_0$ and $\lambda_0$ define reference values.  At 0.7 atm the mean free path is 66 nm.

```python
D0 = 50  # Starting diameter
D1 = 500 # Final diameter

air.T = 20

# Get a range of Cc for plotting
cc_range = [aerosol.cc(i,air) for i in range(D0,D1)]

# Range of diameter over which to plot
D = list(range(D0,D1))
```

```python
fig = plt.figure()
ax = plt.axes()
plt.ylabel(r'$C_c$')
plt.xlabel(r'$D$ (nm)')
plt.title('Cunningham Correction Factor as a function of diameter at sea level')
ax.yaxis.set_major_formatter(matplotlib.ticker.ScalarFormatter(useOffset=False))
plt.plot(D,cc_range)
ax.grid(True)
```

Now, we can calculate the electric mobility:

```python
Zrange = [aerosol.z(D, air,1) for D in range (D0,D1)]
D = list(range(D0,D1))
```

```python
fig = plt.figure()
ax = plt.axes()
plt.ylabel(r'$Z$ (m$^2$/V$\times$s)')
plt.xlabel(r'$D$ (nm)')
plt.title('Electrical mobility for singly charged particles')
ax.yaxis.set_major_formatter(matplotlib.ticker.ScalarFormatter(useOffset=False))
plt.plot(D,Zrange)
ax.grid(True)
```

# Calculating the Diameter as Function of DMA Voltage

If we know the dimensions of the DMA, we may now calculate the expected particle diameter as a function of the flow rates and the DMA voltage.  Since the function is implicit (the mobility is a function of diameter), the diameter must be solved for iteratively.  This can be calculated as in Knutson and Whitby (1975) by equating the center-rod voltage to the electical mobility:

\begin{equation}
Z = \frac{q_c+q_m}{4\pi\Lambda{V}}
\end{equation}

The flows, $q_c$ and $q_m$, are the sheath flows at the entrance and exit.  $\Lambda$ is a DMA constant given by

\begin{equation}
\Lambda = \frac{L}{\ln\left(\frac{r_{inner}}{r_{outer}}\right)}
\end{equation}

Here, $L$ is the length of the DMA column and $r_{inner}$ and $r_{outer}$ are the inner and outer radii of the annular gap in the DMA.  For the NOAA wide, these values are as follows:

* $L=0.34054$
* $r_i = 0.0312$
* $r_o = 0.03613$

```python
noaa_dma = dma.NoaaWide()

# Flows in lpm
qc = 5
qm = 5

# Maximum number of iterations for Newton-Raphson
maxit = 1000

# Low diameter for range
Vlow = 5

# High diameter for range
Vhigh = 2500

# Define range
Vrange = list(range(Vlow,Vhigh))

air.T = 20
air.P = 840

xrange = [noaa_dma.v2d(i, air, qc,qm) for i in range(Vlow,Vhigh)]
fig = plt.figure()
ax = plt.axes()
plt.ylabel(r'$D$ (nm)')
plt.xlabel(r'Voltage (V)')
plt.title('Expected DMA Diameter: Qc=Qm=5, n = 1, P = 840, T = 20')
ax.yaxis.set_major_formatter(matplotlib.ticker.ScalarFormatter(useOffset=False))
plt.plot(Vrange,xrange)
ax.grid(True)
```

# Retrieving a Size Distribution from Scan Data

From the paper Stolzenburg and McMurry [2008], we can retrieve the size distribution using the follow equation (27, from the paper):

\begin{equation}
\frac{dN}{d\ln{D_{p1}}}\bigg|_{D^*_{p1}}=\frac{N_1\left(V_1\right)a_1^*}{\frac{Q_{a1}}{Q_{s1}}\beta_1\left(1+\delta_1\right)f_c\left(D^*_{p1},1\right)\eta_{CPC}\left(D^*_{p1}\right)}
\end{equation}

This equation is accurate only under certain circumstances.  Here,
\begin{equation}
a^*=\left(\frac{-d\ln{Z_p}}{d\ln{D_p}}\right)\bigg|_{D^*_p}
\end{equation}

and $D_p$ is the particle diameter, $D^*_p$ is the particle diameter associated with the centroid transfer function $Z^*_p$, $Q_a$ is the aerosol flow rate, $Q_s$ is the sheath flow rate, $\eta_{CPC}$ is the CPC counting efficiency and $N\left(V\right)$ is the CPC concentration at voltage $V$.

In addition, we have the two ratios
\begin{equation}
\delta = \frac{Q_s-Q_a}{Q_s+Q_a}
\end{equation}
and
\begin{equation}
\beta = \frac{Q_s+Q_a}{Q_m+Q_c}
\end{equation}

where $Q_m$ is the main excess air outlet flow rate and $Q_c$ is the clean sheath inlet flow rate.

# Reading in a DMA File

```python
dfile = 'C:/Users/mrichardson/Documents/HAGIS/SMPS/Scans/SCAN_20150306_06_05_16.txt'

# read in a file and parse the column labeled 'Date_Time' as a date object.
# Index on the first column with the header being in the third row.
# This file uses the EOL constant and therefore has to use the lineterminator = newline
fdata = pd.read_csv(dfile, parse_dates = 'Date_Time', index_col = 0, header = 2, lineterminator = '\n')

# Show the list of column headers
list(fdata.columns.values)
```

    ['DMA_Set_Diam',
     'DMA_Diam',
     'DMA_Volts',
     'DMA_Set_Volts',
     'V_ctl',
     'CPC_Ready',
     'CPC_Flw',
     'CPC_200_Cnt',
     'CPC_20_Cnt',
     'CPC_1_Cnt',
     'CPC_Display',
     'CPC_STP',
     'CPC_Conc',
     'CPC_DAQ_Cnt',
     'Sh_Pres_PSI',
     'Sh_Temp_C',
     'Sh_Q_VLPM',
     'Sh_MassQ_SLPM',
     'Sh_SetQ_VLPM',
     'Dil_Pres_PSI',
     'Dil_Temp_C',
     'Dil_Q_VLPM',
     'Dil_MassQ_SLPM',
     'Dil_SetQ_VLPM',
     'Aer_Pres_PSI',
     'Aer_Temp_C',
     'Aer_Q_VLPM',
     'Aer_MassQ_SLPM',
     'Aer_SetQ_VLPM',
     'Comment\r']

```python
# Plot the diameter as a function of time
fdata['DMA_Diam'].plot()
```

```python
# Plot the
fdata['CPC_1_Cnt'].plot()
```

## Aligning the Data

### Reading the Data using PANDAS

```python
# Retrieve the meta data from the header
meta_data = pd.read_csv(dfile,header = 0, lineterminator = '\n', nrows = 1)
list(meta_data.columns.values)
```

    ['Date',
     'Time',
     'Scan_Number',
     'Low_Voltage',
     'High_Voltage',
     'Low_Diameter',
     'High_Diameter',
     'Scan_Time',
     'Dwell_Time',
     'Lag_Time \r']

Once the data is read in, we will need to align the up and down scans.  But first, we will need to find where the scans begin and end.

```python
print(str(meta_data.Date[0]))

# Number of seconds in scan
tscan = meta_data['Scan_Time'].values[0]
tdwell = meta_data['Dwell_Time'].values[0]
vhigh = meta_data['High_Voltage'].values[0]
vlow = meta_data['Low_Voltage'].values[0]
dhigh = meta_data['High_Diameter'].values[0]
dlow = meta_data['Low_Diameter'].values[0]
```

    03/06/15

### Truncating and Padding the Data

```python
# Retrieve the concentration and diameters of interest
cpc_cnt = fdata['CPC_1_Cnt'].values/fdata['CPC_Flw'].values

#d = fdata['DMA_Diam'].values;
up_data = fdata

# Reverse the entire data frame
down_data = fdata.iloc[::-1]

cpc_up = fdata['CPC_1_Cnt'].values[0:tscan]/fdata['CPC_Flw'].values[0:tscan]
cpc_down = down_data['CPC_1_Cnt'].values[(tdwell):(tscan+tdwell)]/down_data['CPC_Flw'].values[(tdwell):(tscan+tdwell)]

# Get the lag
corr = np.correlate(cpc_up,cpc_down,mode = 'full')
corr = corr[corr.size/2:]

# Here is the lag as applied to the two sets.  The lag is divided by two because
# the lag applies to both sets (up and down)
delta = 13 # a fudge factor for alignment purposes
f=floor(corr.argmax(axis=0)/2+ delta)

print('The lag is ' + str(f))

# Shift the up data with the number of zeros padding on the end equal to the lag
cpc_up = np.pad(fdata['CPC_1_Cnt'].values[f:tscan]/fdata['CPC_Flw'].values[f:tscan],[0,f], 'constant', constant_values=(0,0));

cpc_up[np.where(np.isinf(cpc_up))]=0.0
cpc_up[np.where(np.isnan(cpc_up))]=0.0

# Padding the down scan is trickier - if the parameter f (should be the lag in the correlation)
# is larger than the dwell time, we will have a negative resize parameter - this is no good.
# Pad the front with the number of zeros that goes beyond the end (front in the reveresed array).
# This makes sense.  I guess.
pad = 0
if (f>tdwell):
    pad = f-tdwell
    f = tdwell

# Shift the down data so that we pad the front and back appropriately
cpc_down  = np.pad(down_data['CPC_1_Cnt'].values[(tdwell-f):(tscan+tdwell-(f+pad))]/
                   down_data['CPC_Flw'].values[(tdwell-f):(tscan+tdwell-(f+pad))],
                   [0,pad], 'constant', constant_values= (0,0))

# Truncate the data from the up scan
up_data = up_data.iloc[:tscan]

# Get the voltage from the up scan
vup = up_data['DMA_Volts'].values

# Get the mean of all the columns in up_data
mup = up_data.mean(axis=0)

smooth_p = 0.3
smooth_up = sm.nonparametric.lowess(cpc_up,up_data.DMA_Diam.values, frac = smooth_p, it = 1, missing='none')

# Truncate the down data.  In Chuck's code, this is tdwell:tscan+tdwell.
# But, I don't understand why this would be since we are flipping the
# distribution.  Seems to me that the conditions of interest should
# be in the range of 0 to tscan.

cpc_down[np.where(np.isinf(cpc_down))]=0.0
cpc_down[np.where(np.isnan(cpc_down))]=0.0
down_data = down_data.iloc[:tscan]
smooth_down = sm.nonparametric.lowess(cpc_down,down_data['DMA_Diam'].values, frac = smooth_p, missing='none')

# Get the down voltages.
vdown = down_data['DMA_Volts'].values

# Get the mean of all the columns in down_data
mdown = down_data.mean(axis=0)

air.t = mup.Aer_Temp_C
air.p = mup.Aer_Pres_PSI

cdhigh = noaa_dma.v2d(vhigh,air, mup.Sh_Q_VLPM, mup.Sh_Q_VLPM)
cdlow = noaa_dma.v2d(vlow,air, mup.Sh_Q_VLPM, mup.Sh_Q_VLPM)

# Number of bins for the interpolated matrix of diameters
numbins = 300

# Array for scan to interpolate diameters over; base is e
diam_interp = np.logspace(np.log10(1),np.log10(1000), numbins)

#fig = plt.figure()
#ax = plt.axes()

#plt.plot(ndiam,x,'.')
#ax.set_xscale('log')

#ax.xaxis
# plot the truncated data
plt.plot(cpc_up, 'r.', cpc_down, 'b.',smooth_up[:,1], 'r+', smooth_down[:,1], 'b+')
```

## Retrieving the Charge Distribution

The following equations were pulled from two different sources.  For particles smaller than 1 micron, we can use the Wiedensohler [1988] approximation of the bipolar charge distribution.  This looks like

\begin{equation}
f\left(N\right)=10^{\sum_{i=0}^{5}a_i\left(N\right)\left(\log{\frac{D_p}{nm}}\right)^i}
\end{equation}

where the constants defined by $a_i$ are given in the paper and in the code below, $D_p$ is the particle size in nm and $N$ is the number of charges.

For larger particles, use Gunn.  The solution in this case is

\begin{equation}
f\left(N\right)=\frac{e}{\sqrt{4\pi^2\varepsilon_0D_pkT}}\exp\left[{-\frac{N-\frac{2\pi\varepsilon_0D_pkT}{e^2}\ln\left(\frac{c_{NI+}Z_{I+}}{c_{NI-}Z_{I-}}\right)}{\frac{4\pi\varepsilon_0D_pkT}{e^2}}}\right]
\end{equation}

where $\varepsilon_0$ is the dielectric constant, $e$ is the elementary electronic charge, $k$ is Boltzman's constant and $c_{I\pm}$ and $Z_{I\pm}$ are the ion concentration and mobility respectively.  The concentration of negative and positive ions is assumed to be equal and the ratio of the positive to negative mobility was measured to be 0.875 by Wiedensohler et al. [1986].

Now, let's plot the charging efficiency over a multiplicity of diameters to see if we have this right.

```python
# Set the diameter range to 50 - 500 nm
dp0 = 50
dp1 = 500
plt.plot(list(range(dp0,dp1)),[aerosol.ndistr(i) for i in range(dp0,dp1)])
plt.ylabel(r'Charging Efficiency')
plt.xlabel(r'$D$ (nm)')
plt.title('Charging efficiency of singly charged particles')
plt.grid()
```

This plot compares favorably with the results from **Table 2** in Wiedensohler [1987].  The code for the charging efficiency was taken from the Igor SMPS code and some of the coefficients are slightly different, so the results should be slightly different.

## Solve for Diameter with a Known Mobility

### Example

What follow is an example of how to use the function above.  We can test this using the functions above for the electic mobility.  Start with a 213 nm particle at 23 degrees Celsius and 850 mb.

```python
air.t = 23
air.p = 850

z = aerosol.z(213,air,1)
print(z)
```

    8.61682465115e-09

The resulting mobility is $8.61\times10^{-9}$ m$^2$/(Vs).  Using this, we can plug the mobility in and solve using a starting diameter of 100 nm.

```python
d0 = 1e-9
aerosol.z2d(z,air,1)
```

    213.0

The result is a particle of diameter 213 nm.

## Solve for the FWHM at the given Diameter

```python
'''
Return the full-width, half-max of the transfer function in diameter space.
This implementation ignores diffusion broadening.
@param dp: particle size in nm
@param qa: aerosol flow rate in lpm
@param qs: aerosol flow rate in lpm
@param T: temperature in degrees Celsius
@param P: pressure in millibars
@return: Width of transfer function in nm.
'''
def xferFWHM(dp,qa,qs,gas):
    beta = float(qa)/float(qs)

    # Retrieve the center mobility
    Zc = aerosol.z(dp,gas,1)

    # Upper bound of the mobility
    Zm = (1-beta/2)*Zc

    # Lower bound of the mobility
    Zp = (1+beta/2)*Zc

    return aerosol.z2d(Zm, gas, 1)-aerosol.z2d(Zp, gas, 1)
```

### Example of Using FWHM Function

The following is an example of how to use the transfer function to find the FWHM.

```python
air.t = 20
air.p = 850
xferFWHM(100,1,10,air)
```

    5.6422676853702995

The result is a width that is 5.64 nm.  This compares favorably with Chuck's work which shows a width of 5.61nm for the same conditions.

## Bringing it all together

Now that we have all of the raw functionality in order, we can begin to put a distribution together by looping.

```python
# Make sure the conditions are good
air.t = mup.Aer_Temp_C
air.p = mup.Aer_Pres_PSI

# Calculate the diameters to use
sup = [noaa_dma.v2d(i,air, mup.Sh_Q_VLPM, mup.Sh_Q_VLPM) for i in up_data.DMA_Set_Volts.values]
#output_sd = []
ls = len(sup)
dlogd = np.zeros(ls) # calculate dlogd
fwhm = np.zeros(ls) # hold width
dnlogd =  np.zeros(ls)

for e,i in enumerate(sup):
    try:
        fwhm[e] = xferFWHM(i, mup.Aer_Q_VLPM, mup.Sh_Q_VLPM,air )
        #output_sd[e] = smooth_up[e,1]
        dlogd[e] = np.log10(i+fhwm[e]/2)-np.log10(i-fhwm[e]/2)
        #dnlogd[e] = smooth_up[e,1]/dlogd[e]
    except (ValueError,ZeroDivisionError):
        fwhm[e] = np.nan
        print('Handling divide by zero error')
    except:
        fwhm[e] = np.nan
        print('Handling unknown error: ' + str(sys.exc_info()[0]))
print(sup[100])
```

    116.157403696

```python
print(up_data.DMA_Set_Volts.values)
```

    [   37.419477    38.004535    38.59874     39.202236    39.815168
        40.437683    41.069931    41.712065    42.364238    43.026608
        43.699335    44.382579    45.076506    45.781283    46.497079
        47.224066    47.96242     48.712319    49.473942    50.247473
        51.033099    51.831007    52.641391    53.464446    54.300369
        55.149362    56.011629    56.887378    57.776819    58.680167
        59.597638    60.529455    61.47584     62.437023    63.413233
        64.404707    65.411682    66.434402    67.473112    68.528063
        69.599507    70.687704    71.792915    72.915406    74.055448
        75.213314    76.389283    77.583639    78.796669    80.028664
        81.279922    82.550744    83.841435    85.152306    86.483673
        87.835856    89.20918     90.603977    92.020581    93.459334
        94.920582    96.404677    97.911976    99.442842   100.997643
       102.576754   104.180554   105.80943    107.463773   109.143982
       110.850462   112.583623   114.343881   116.131662   117.947395
       119.791517   121.664472   123.566711   125.498692   127.460879
       129.453746   131.477771   133.533442   135.621254   137.741709
       139.895317   142.082598   144.304077   146.560289   148.851777
       151.179093   153.542797   155.943457   158.381652   160.857969
       163.373003   165.92736    168.521655   171.156512   173.832565
       176.550458   179.310846   182.114394   184.961775   187.853675
       190.79079    193.773827   196.803505   199.880552   203.005709
       206.179728   209.403374   212.677421   216.002659   219.379887
       222.809919   226.293579   229.831707   233.425154   237.074785
       240.781479   244.546127   248.369635   252.252925   256.196931
       260.202601   264.270901   268.402809   272.59932    276.861444
       281.190207   285.58665    290.051833   294.586829   299.19273
       303.870645   308.6217     313.447038   318.347821   323.325229
       328.380459   333.514727   338.729271   344.025345   349.404224
       354.867202   360.415594   366.050736   371.773985   377.586717
       383.490331   389.48625    395.575915   401.760794   408.042373
       414.422166   420.901707   427.482557   434.1663     440.954543
       447.848922   454.851095   461.962748   469.185592   476.521366
       483.971837   491.538796   499.224065   507.029495   514.956964
       523.008379   531.18568    539.490833   547.925839   556.492727
       565.193559   574.03043    583.005466   592.120828   601.37871
       610.781341   620.330982   630.029933   639.880529   649.88514
       660.046174   670.366077   680.847333   691.492465   702.304035
       713.284645   724.436938   735.763599   747.267353   758.95097
       770.817262   782.869085   795.109339   807.540971   820.166974
       832.990386   846.014293   859.24183    872.676182   886.320582
       900.178313   914.252712   928.547165   943.065115   957.810054
       972.785532   987.995153  1003.442579  1019.131527  1035.065774
      1051.249155  1067.685565  1084.37896   1101.333359  1118.552841
      1136.041552  1153.803702  1171.843565  1190.165483  1208.773867
      1227.673195  1246.868017  1266.362952  1286.162692  1306.272004
      1326.695728  1347.438779  1368.50615   1389.902912  1411.634215
      1433.70529   1456.121448  1478.888086  1502.010684  1525.494806
      1549.346105  1573.570322  1598.173287  1623.160923  1648.539244
      1674.314358  1700.492469  1727.079878  1754.082985  1781.508288
      1809.362389  1837.651993  1866.383908  1895.56505   1925.202443
      1955.30322   1985.874627  2016.924021  2048.458876  2080.486783
      2113.01545   2146.052706  2179.606504  2213.68492   2248.296157
      2283.448544  2319.150543  2355.410747  2392.237884  2429.640818
      2467.628551  2506.210227  2545.395132  2585.192698  2625.612504
      2666.664279  2708.357903  2750.703412  2793.710999  2837.391014
      2881.753972  2926.81055   2972.571593  3019.048116  3066.251304
      3114.192521  3162.883304  3212.335374  3262.560633  3313.57117
      3365.379263  3417.997382  3471.438192  3525.714555  3580.839536
      3636.826403  3693.688631  3751.439908  3810.094132  3869.665423]

## Correct for Multiple Charges

In the below function, we will loop through the concentrations starting from the lowest diameter and look for multiply charged particles that would have been mistakenly classified as larger particles.  These misclassified particles will be removed from the bins and of those larger particles and placed in the current bin.  To do this, we will have to search for the bin which contains the nearest value to the multiply charged value.  This can be achieved by using a search function demonstrated below:

```python
# The following shows two different ways to get the index closest to the value 136.5

index = min(enumerate(sup), key =lambda x: abs(x[1]-136.5))
idx = (np.abs(np.asarray(sup) - 136.5)).argmin()
print(idx)
print(index)
```

    118
    (118, 136.50272095690903)

This function will return both the index and the value in a list.

```python
def chargeCorr(diam,dn,gas,n=3,pos_neg=-1):
    '''
    Correct the input concentrations for multiple charges.

    This function does not return anything as it handles array input by reference.

    Parameters
    ----------
    diam:       array of float
                array of diameters in nm
    dn:         array of integers
                Array of particle concentrations corresponding to diameter 'diam'
    gas:        gas object
                Gas object that defines the properties of the gas
    n:          int, optional
                Number of charges to consider.  Default is 3.
    pos_neg:    int, optional
                Positive or negative one indicating whether to consider positive or negative charges.
                Default is -1.

    Returns
    -------
    None

    '''

    dn_removed = np.zeros(len(dn))

    single_frac = np.zeros(len(dn))

    # Flip both the incoming diamter array and the concentration distribution
    rdiam = diam[::-1]
    rdn = dn[::-1]

    dn_raw = dn
    dn_work = dn
    dn_raw = dn

    # We are working backwards, so we need to have the length to get this all right...
    l = len(dn)-1

    # Find the value closest to diameter d in the array diam
    fmin = lambda d: (np.abs(np.asarray(diam) - d)).argmin()

    for i,d in enumerate(rdiam):

        # Get the fraction of particles that are singly charged
        single_frac[i] = aerosol.ndistr(d,pos_neg,gas.t)

        #print("Diameter is " + str(d))
        #print("Single charge fraction is " + str(single_frac[i]))

        for j in reversed(range(2,n+1)):
            ne = j*pos_neg

            # Ratio of singly charge particles to particles with charge ne
            c_rat = single_frac[i]/aerosol.ndistr(d,ne,gas.t)

           # print("c_rat = " + str(c_rat))
            #print("charge is " + str(ne))
            #print("charge efficiency is " + str(aerosol.ndistr(d,ne,gas.t)))

            z = aerosol.z(d,gas,1)

            z_mult = abs(ne*aerosol.z(d,gas,pos_neg))

            #print("Z_mult is " + str(z_mult))

            d_mult = aerosol.z2d(z_mult, gas, 1)
            #print("d_mult is " + str(d_mult))

            # Do NOT try to move particles for which we don't have a diameter
            if (d_mult >= diam[0]):
                # Find the index of the multiple charges
                k = fmin(d_mult)

                #print("k is " + str(k))
                #print("l-i is = " + str(l-i))

                # Calculate the number to move
                n2move = min(dn_raw[l-i]/c_rat, dn_work[l-i])

                #print("n2move is " + str(n2move))

                #print("dn[k] is = " + str(dn[k]))

                dn[k]  += n2move
                dn_work[l-i] -= n2move

                #print("dn[k] + n2move is = " + str(dn[k]))

                #print("dn[l-i] is = " + str(dn[l-i]))

                dn[l-i] -= n2move

                #print("dn[l-i] - n2move is = " + str(dn[l-i]))

        # Correct for single charging
    single_frac = single_frac[::-1]
    plt.plot(dn, 'g')
    #print(dn[0])
    #print(single_frac[0])
    dn = dn/single_frac

    plt.plot(dn,'b')
    #print(single_frac)

    return None

```

### Demonstation of Correction

Continuing with the above examples from the file.

```python
plt.plot(smooth_up[:,1])
plt.grid()

ax = plt.axes()

smooth_up[45,1]
print(smooth_up[200,1])
```

```python
# Copy the array so that we don't have to run all the way back
output_sd = np.copy(smooth_up[:,1])  # Space for the size distribution
#plt.plot(output_sd)
chargeCorr(sup,output_sd,air)
#plt.plot(output_sd, 'r', smooth_up[:,1], 'b')
#plt.plot(output_sd)
#ax = plt.axes()
#ax.set_xlim(0,50)

```

```python
plt.plot(output_sd, '+')
ax = plt.axes()
plt.grid()
```


```
%matplotlib inline
from sympy import var, Matrix, eye, init_printing, roots
init_printing()
```

```
var("a:5")
var("b:5")
var("s");
```

```
A = Matrix([[0, 1, 0], [0, 0 , 1], [0, 0, 0]])
b = Matrix([[0], [0], [1]])
c = Matrix([[-1], [0], [1]])
x0 = Matrix([[1], [0], [0]])
```

```
FT = c.T*(s*eye(3) - A).inv()*b
FT[0].factor()
```

```
A.eigenvals()
```

```
MS = (s*eye(3) - A).row_join(b).col_join(-c.T.row_join(Matrix([[0]])))
MS
```

```
pMS = MS.det()
pMS.factor()
```

```
C = (b.row_join(A*b)).row_join(A*A*b)
C
```

```
C.det()
```

```
O = c.T.col_join((c.T*A).col_join(c.T*A*A))
O
```

```
O.det()
```

```
((s+1)**3).expand()
```

```
fp = Matrix([[-1], [-3], [-3]])
fp
```

```
Afp = A + b*fp.T
Afp
```

```
Afp.eigenvals()
```

```
MSp = (s*eye(3) - Afp).row_join(b).col_join(-c.T.row_join(Matrix([[0]])))
MSp
```

```
pMS = MS.det()
pMS.factor()
```

```
Tp, Jp = Afp.jordan_form()
Jp
```

```
bJp = Tp.inv()*b
cJp = Tp.T*c
```

```
MSp = (s*eye(3) - Jp).row_join(bJp).col_join(-cJp.T.row_join(Matrix([[0]])))
MSp
```

```
FTp = cJp.T*(s*eye(3) - Jp).inv()*bJp
FTp[0].factor()
```

```
Q = c*c.T
Q
```

```
Q.eigenvals()
```

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

```
from control import care, ss, forced_response, tf
from numpy import matrix, eye, linspace, sign
from numpy.linalg import eig, det
```

```
r = 1
```

```
A = matrix(A.tolist(), dtype=float)
b = matrix(b.tolist(), dtype=float)
c = matrix(c.tolist(), dtype=float)
Q = matrix(Q.tolist(), dtype=float)
cero = matrix([[0], [0], [0]])
```

```
P, L, G = care(A=A, B=b, Q=Q, R=r, S=cero, E=eye(3))
```

    /Users/roberto/miniconda/lib/python2.7/site-packages/control/mateqn.py:464: FutureWarning: comparison to `None` will result in an elementwise object comparison in the future.
      if S != None and len(shape(S)) == 1:
    /Users/roberto/miniconda/lib/python2.7/site-packages/control/mateqn.py:467: FutureWarning: comparison to `None` will result in an elementwise object comparison in the future.
      if E != None and len(shape(E)) == 1:
    /Users/roberto/miniconda/lib/python2.7/site-packages/control/mateqn.py:484: FutureWarning: comparison to `None` will result in an elementwise object comparison in the future.
      if S==None and E==None:
    /Users/roberto/miniconda/lib/python2.7/site-packages/control/mateqn.py:565: FutureWarning: comparison to `None` will result in an elementwise object comparison in the future.
      elif S != None and E != None:

```
A.T*P + P*A - P*b*(b.T)*P + Q
```

    matrix([[  3.55271368e-15,   6.66133815e-15,   2.66453526e-15],
            [  6.66133815e-15,   9.76996262e-15,   3.55271368e-15],
            [  2.66453526e-15,   3.55271368e-15,   8.88178420e-16]])

```
vals, vecs = eig(P)
```

```
vals
```

    array([ 9.12696152,  0.06789405,  1.61377159])

```
fo1 = matrix(G.T)
fo1
```

    matrix([[ 1.        ],
            [ 2.64943591],
            [ 2.50975533]])

```
(r*b.T*P).T
```

    matrix([[ 1.        ],
            [ 2.64943591],
            [ 2.50975533]])

```
Afo1 = A - b*fo1.T
Afo1
```

    matrix([[ 0.        ,  1.        ,  0.        ],
            [ 0.        ,  0.        ,  1.        ],
            [-1.        , -2.64943591, -2.50975533]])

```
Afp = matrix(Afp.tolist(), dtype=float)
Afp
```

    matrix([[ 0.,  1.,  0.],
            [ 0.,  0.,  1.],
            [-1., -3., -3.]])

```
ssp = ss(Afp, b, c.T, 0)
```

```
sso1 = ss(Afo1, b, c.T, 0)
```

```
r=10
P, L, G = care(A=A, B=b, Q=Q, R=r, S=cero, E=eye(3))
fo2 = matrix(G.T)
Afo2 = A - b*fo2.T
sso2 = ss(Afo2, b, c.T, 0)
```

```
r=0.1
P, L, G = care(A=A, B=b, Q=Q, R=r, S=cero, E=eye(3))
fo3 = matrix(G.T)
Afo3 = A - b*fo3.T
sso3 = ss(Afo3, b, c.T, 0)
```

```
heavyside = lambda x: 0.5 * (sign(x) + 1)
t = linspace(0, 10, 1000)
v = heavyside(t)
```

```
_,  yp,  xp = forced_response(ssp, T=t, U=v)
_, yo1, xo1 = forced_response(sso1, T=t, U=v)
_, yo2, xo2 = forced_response(sso2, T=t, U=v)
_, yo3, xo3 = forced_response(sso3, T=t, U=v)

up  = -xp.T*fp
uo1 = xo1.T*fo1
uo2 = xo2.T*fo2
uo3 = xo3.T*fo3
```

```
f = figure(figsize=(18, 7))
style.use("ggplot")
p1, = plot(t, -yp)
p2, = plot(t, -yo1)
p3, = plot(t, -yo2)
p4, = plot(t, -yo3)
p5, = plot(t, v)

ax = f.gca()
ax.set_ylim(-1, 1.2);

legend([p1, p2, p3, p4, p5], [r"$y_p(t)$", r"$y_{O_1}(t)$", r"$y_{O_2}(t)$", r"$y_{O_3}(t)$", r"$\delta$"]);
```

```
f = figure(figsize=(18, 7))
style.use("ggplot")
p1, = plot(t, up)
p2, = plot(t, uo1)
p3, = plot(t, uo2)
p4, = plot(t, uo3)

ax = f.gca()
ax.set_ylim(-0.2, 1.3);

legend([p1, p2, p3, p4], [r"$u_p(t)$", r"$u_{O_1}(t)$", r"$u_{O_2}(t)$", r"$u_{O_3}(t)$"]);
```

IPythonNotebooks/Teoria de Control I/Control optimo.ipynb