from sympy import S, zeros, Matrix, nsimplify, factorial, maximum, lambdify, Interval, expand, symbols, pi, cos, eye
from ..genericas import matriz_inversa
from tqdm import tqdm
import numpy as np

def polinomio_lagrange(x_vals, y_vals, var=symbols('x')):
    """Genera el polinomio de interpolación de Lagrange.

    Args:
        x_vals (list): Valores x de interpolación
        y_vals (list): Valores f(x) de interpolación
        var (variable, optional): Variable sobre la que construir el polinomio. Defaults to symbols('x').

    Returns:
        p: polinomio de Lagrange.
        lista_L: Lista de coeficientes l_i de los polinomios.
    """
    lista_L, p, n = [], S(0), len(x_vals)
    for i in range(n):
        li = S(1)
        for k in range(n):
            if k != i:
                li *= (var - x_vals[k]) / (x_vals[i] - x_vals[k])

        lista_L.append(li)
        p += y_vals[i] * li

    return p, lista_L

def polinomio_newton(x_vals, y_vals, var=symbols('x'), evalf=None):
    """Genera el polinomio de interpolación de Newton.

    Args:
        x_vals (list): Valores x de interpolación
        y_vals (list): Valores f(x) de interpolación
        var (variable, optional): Variable sobre la que construir el polinomio. Defaults to symbols('x').
        evalf (int): Redondea la matriz de coeficiente y, por tanto, los coeficientes del polinomio a un número de cifras.

    Returns:
        p: polinomio de Newton.
        matriz_coeffs: Matriz de coeficientes con el método iterativo.
    """

    matriz_coeffs = zeros(len(y_vals), len(y_vals))
    for i in range(len(y_vals)):  # Asignamos la primera columna como f(x)
        matriz_coeffs[i, 0] = y_vals[i]

    for col in range(1, len(y_vals)):
        for row in range(len(y_vals) - col):
            num = matriz_coeffs[row + 1, col - 1] - matriz_coeffs[row, col - 1]
            den = x_vals[row + col] - x_vals[row]

            matriz_coeffs[row, col] = num / den

    matriz_coeffs = nsimplify(matriz_coeffs, tolerance=1e-10, rational=True)  # Esto es importante para quitar valores de redondeo
    if evalf is not None:  # si evalf es un numero redondea a ese numero de decimales
        matriz_coeffs = matriz_coeffs.evalf(evalf)

    # Aqui hacemos el polinomio
    p = matriz_coeffs[0, 0]
    for col in range(1, len(y_vals)):
        p_col = S(1)
        for i in range(0, col):
            p_col *= (var - x_vals[i])

        p += p_col * matriz_coeffs[0, col]

    return p, matriz_coeffs

def error_lagrange(f, x_vals, I=[0, 1], var=symbols('x')):
    """Calcula el error del polinomio de Lagrange, definido por PROD[(x-xi)]/(n+1)! max f^(n+1)(t)

    Args:
        f (función): Función interpolada (si se conoce).
        x_vals (list): Valores x de interpolación.
        I (list): Intervalo de interpolación.
        var (variable, optional): Variable de interpolación. Defaults to symbols('x').

    Returns:
        E_x (float): Error máximo de interpolación
    """
    E_x_fact = S(1)
    for x_val in x_vals:
        E_x_fact *= (var - x_val)

    E_x_fact /= factorial(len(x_vals))

    # Ahora derivamos la función n veces
    diff_f = f

    for _ in range(len(x_vals)):
        diff_f = diff_f.diff(var)

    max_diff_f = maximum(diff_f, var, Interval(I[0], I[1]))

    E_x = E_x_fact * abs(max_diff_f)

    return E_x

def error_maximo_estocastico(f, var=symbols('x'), grado=3, N=1000, I=[0, 1], list_x_vals=None):
    """Emplea un método estocástico para simular el error máximo de la interpolación. Para ello genera n (grado) nodos dentro del intervalo, calcula el error máximo para ese caso específico, y
    actualiza el error máximo para cada conjunto de nodos.

    Args:
        f (funcion): Función a interpolar.
        var (variable, optional): Variable de la función. Defaults to symbols('x').
        grado (int, optional): Grado de interpolación. Defaults to 3.
        N (int, optional): Número de generaciones aleatorias de nodos. Defaults to 1000.
        I (list, optional): Intervalo de interpolación. Defaults to [0, 1].
        list_x_vals (list, optional): Listado de nodos, definido por el usuario. Defaults to None.

    Returns:
        max_error (float): Error estocástico máximo.
    """
    max_error = 0

    # Hacemos la funcion por separado porque el cálculo de derivada es costoso y cada iteración de error_lagrange tarda mucho
    diff_f = f
    for _ in range(grado):
        diff_f = diff_f.diff(var)
    max_diff_f = maximum(diff_f, var, Interval(I[0], I[1]))

    for _ in tqdm(range(N)):
        if list_x_vals is None:
            x_list = np.sort(np.random.rand(grado) * (I[1] - I[0]) + I[0])
        else:
            x_list = list_x_vals

        E_x_fact = S(1) / S(factorial(grado))
        for x_val in x_list:
            E_x_fact *= (var - x_val)

        E_x = lambdify(var, expand(E_x_fact * max_diff_f))  # lambdify es para que la evaluación numérica sea mucho más ágil

        x_range = np.linspace(I[0], I[1], int(N / 10))
        y_f = [E_x(i) for i in x_range]
        max_y_f = np.max(abs(np.array(y_f)))

        if max_y_f >= max_error:
            max_error = max_y_f

        if list_x_vals is not None:
            break

    return max_error

def roots_chebyshev(n, I=[-1, 1]):
    """Calcula las raíces del polinomio de Chebyshev de grado n.

    Args:
        n (int): Grado del polinomio
        I (list, optional): Intervalo de generación del polinomio. Defaults to [-1, 1].

    Returns:
        roots (list): Lista con las raices, en forma trigonométrica.
    """
    roots = [0.5 * (I[1] + I[0]) + 0.5 * (I[1] - I[0]) * cos(S(2 * (i + 1) - 1) / S(2 * n) * pi) for i in range(n)][::-1]
    return roots

def aitken_neville(x_vals, y_vals, x0, modo='aitken'):
    """Aplica los algoritmos de Aitken y Nevile para calcular el valor de una función interpolada en un punto nuevo, dados los valores en nodos anteriores.

    Args:
        x_vals (list): Valores x de interpolación
        y_vals (list): Valores f(x) de interpolación
        x0 (float): Valor x del nodo a calcular su f(x)
        modo (str, optional): ['aitken', 'neville']. Defaults to 'aitken'.

    Returns:
        matriz_coeffs: Matriz de coeficientes de la interpolación. El elemento [0, -1] es el valor interpolado.
    """
    matriz_coeffs = zeros(len(y_vals), len(y_vals))
    for i in range(len(y_vals)):  # Asignamos la primera columna como f(x)
        matriz_coeffs[i, 0] = y_vals[i]

    for col in range(1, len(y_vals)):
        for row in range(len(y_vals) - col):
            xk = None
            if modo == 'aitken':
                xk = x_vals[0]
            elif modo == 'neville':
                xk = x_vals[row]

            num = (x0 - xk) * matriz_coeffs[row + 1, col - 1] - (x0 - x_vals[row + col]) * matriz_coeffs[row, col - 1]
            den = x_vals[row + col] - xk

            matriz_coeffs[row, col] = num / den

    matriz_coeffs = nsimplify(matriz_coeffs, tolerance=1e-10, rational=True)  # Esto es importante para quitar valores de redondeo

    return matriz_coeffs

def interpolacion_hermite(x_vals, y_vals, diff_vals, var=symbols('x'), evalf=None):
    """Aplica la interpolación de Hermite, que permite la introducción de valores de derivadas. En esta implementación solo
    se permiten valores de la primera derivada.

    Args:
        x_vals (list): Valores x de interpolación.
        y_vals (list): Valores f(x) de interpolación.
        diff_vals (list): Valores f'(x) de interpolación.
        var (variable, optional): Variable sobre la que construir el polinomio. Defaults to symbols('x').
        evalf (int): Redondea la matriz de coeficiente y, por tanto, los coeficientes del polinomio a un número de cifras.

    Returns:
        p: polinomio de Hermite.
        matriz_coeffs: Matriz de coeficientes con el método iterativo.
    """
    base_w = [S(1)]
    double_x_vals = []
    for val in x_vals:
        double_x_vals += [val, val]

    for x_val in x_vals:
        base_w += [base_w[-1] * (var - x_val), base_w[-1] * (var - x_val) ** 2]

    base_w = base_w[:-1]  # Eliminamos la componente w_2n+1, que no se emplea para el cálculo del polinomio
    matriz_coeffs = zeros(len(base_w), len(base_w))

    # Asignamos la primera columna como f(x)
    for i in range(2 * len(y_vals)):
        matriz_coeffs[i, 0] = y_vals[i // 2]

    # Asignamos la segunda columna para f'(x) o f[xa, xa+1]
    for i in range(0, 2 * len(y_vals), 2):
        matriz_coeffs[i, 1] = diff_vals[i // 2]

    for i in range(1, 2 * len(y_vals) - 1, 2):
        num = matriz_coeffs[i + 1, 0] - matriz_coeffs[i, 0]
        den = double_x_vals[i + 1] - double_x_vals[i]
        matriz_coeffs[i, 1] = num / den

    # Asignamos para el resto de columnas
    for col in range(2, 2 * len(y_vals)):
        for row in range(2 * len(y_vals) - col):
            num = matriz_coeffs[row + 1, col - 1] - matriz_coeffs[row, col - 1]
            den = double_x_vals[row + col] - double_x_vals[row]

            matriz_coeffs[row, col] = num / den

    matriz_coeffs = nsimplify(matriz_coeffs, tolerance=1e-10, rational=True)  # Esto es importante para quitar valores de redondeo
    if evalf is not None:  # si evalf es un numero redondea a ese numero de decimales
        matriz_coeffs = matriz_coeffs.evalf(evalf)

    # Aqui hacemos el polinomio
    p = S(0)
    for i in range(len(base_w)):
        p += base_w[i] * matriz_coeffs[0, i]

    return p, matriz_coeffs

def polinomio_generico(lista_condiciones, var=symbols('x'), evalf=None):
    """Calcula un polinomio que satisface las condiciones de interpolación.
       La lista de condiciones viene data por una lista de listas:
       [ [(a, b), (c, d)],  [],  [(e, f)]]
       Esto significaria: f(a) = b;  f(c) = d;  f''(e) = f
    Args:
        lista_condiciones (list): Lista de condiciones
        var (variable, optional): Variable sobre la que construir el polinomio. Defaults to symbols('x').
        evalf (int): Redondea la matriz de coeficiente y, por tanto, los coeficientes del polinomio a un número de cifras.

    Returns:
        p (polinomio): polinomio de interpolación.
        D (matriz): Matriz de coeficientes. Cada fila tiene tantas columnas como coeficientes a_i, y satisface la condición determinada: f''(3) -> 0  0 2 3·2·(3) 4·3·(3)^2 5·4·(3)^3 ...
        rhs: Matriz de rhs de la ecuación: f''(3) = 34 -> 34
        a_vals: Matrices de valores del polinomio que cumplen la ecuación D·a = rhs
    """
    # La lista de condiciones viene data por una lista de listas:
    # [ [(a, b), (c, d)],  [],  [(e, f)]]
    # Esto significaria: f(a) = b;  f(c) = d;  f''(e) = f

    # Primero contamos el número de elementos para generar el polinomio de grado n-1
    n = sum([len(i) for i in lista_condiciones])
    p_list = []
    for i in range(n):
        p_list.append(var ** i)

    # Ahora implementamos las condiciones. Cada elemento de la lista de condiciones será p, o sus derivadas, sustituyendo el elemento determinado
    D, rhs = zeros(n, n), []

    row = 0
    for diff_range in range(len(lista_condiciones)):
        # Primero aplicamos la n-derivada a ese polinomio
        for pair in lista_condiciones[diff_range]:
            for col in range(diff_range, n):
                if col == 0:
                    multiplicado_derivada = 1
                else:
                    multiplicado_derivada = factorial(col) / factorial(col - diff_range)
                D[row, col] = multiplicado_derivada * pair[0] ** (col - diff_range)  # Añadimos a la matriz su elemento, que es la derivada por x^n

            rhs.append(pair[1])
            row += 1

    try:
        a = matriz_inversa(D) * Matrix(rhs)
        a_vals = {symbols(f'a{i}'): a[i, 0] for i in range(D.shape[0])}

        p = S(0)
        for i in range(n):
            p += var**i * a_vals[symbols(f'a{i}')]
    except:
        print('La matriz de valores no tiene inversa. No existe un polinomio de interpolación que verifique las condiciones.')
        a_vals = None
        p = None

    return p, D, rhs, a_vals

def esplines(x_vals, y_vals, var=symbols('x')):
    """Genera un esplín natural cúbico.

    Args:
        x_vals (list): Valores x de interpolación.
        y_vals (list): Valores f(x) de interpolación.
        var (variable, optional): Variable sobre la que construir el esplín. Defaults to symbols('x').

    Returns:
        S_dict (dict): Diccionario con los polinomios S_i para cada par de nodos x_i x_i+1
        valores (dict): Para cada i, retorna los coeficientes a_i, b_i, c_i, d_i del polinomio S_i = a_i(x-x_i)^3 + b_i(x-x_i)^2 + c_i(x-x_i) + d_i
        D (matriz): Matriz de construcción del esplín.
        z (list): Lista de valores z_i para derivar a_i, b_i, c_i, d_i
        rhs (matriz): Matriz con los valores 6 * f[x_i, x_i+1, x_i+2]
    """
    # Primero creamos los polinomios de los esplines
    S_dict = {}
    valores = {}

    # Ahora hallamos h y r para resolver el sistema
    h_list = [x_vals[i + 1] - x_vals[i] for i in range(len(x_vals) - 1)]
    r_list = [h_list[i] / (h_list[i] + h_list[i + 1]) for i in range(len(x_vals) - 2)]

    D = 2 * eye(len(x_vals) - 2)
    for i in range(len(x_vals) - 3):
        D[i, i + 1] = 1 - r_list[i]
        D[i + 1, i] = r_list[i + 1]

    # Ahora hallamos el rhs, que está compuesto por f[a, b, c] = (f[b, c] - f[a, b])/(c-a) = ({(f(c) - f(b))/(c-b)} - {(f(b) - f(a))/(b-a)})/(c-a)
    rhs = 6 * Matrix([(((y_vals[i + 2] - y_vals[i + 1]) / (x_vals[i + 2] - x_vals[i + 1])) -
                       ((y_vals[i + 1] - y_vals[i]) / (x_vals[i + 1] - x_vals[i]))) /
                      (x_vals[i + 2] - x_vals[i])
                      for i in range(len(x_vals) - 2)])

    # Resolvemos el sistema D*z = rhs
    z = matriz_inversa(D) * rhs
    z_list = [0] + list(z) + [0]

    # Asignamos los valores de a, b, c, d, y con ello creamos los polinomios
    for i in range(len(x_vals) - 1):
        a = (z_list[i + 1] - z_list[i]) / (6 * h_list[0])
        b = z_list[i] / 2
        c = (y_vals[i + 1] - y_vals[i]) / (h_list[i]) - (2 * z_list[i] + z_list[i + 1]) / (6) * h_list[i]

        valores[i] = [a, b, c, y_vals[i]]
        S_dict[f'S_{i}'] = expand(y_vals[i] + c * (var - x_vals[i]) + b * (var - x_vals[i]) ** 2 + a * (var - x_vals[i]) ** 3)

    return S_dict, valores, D, z, rhs


import numpy as np

from si.supervised.supervised_model import SupervisedModel
from si.util.metrics import mse

class LinearRegression(SupervisedModel):
    def __init__(self, gd=None, epochs=1000, lr=0.001):
        super(LinearRegression, self).__init__()
        self.gd = gd
        self.theta = None
        self.epochs = epochs
        self.lr = lr
        self.history = None
        self.X, self.y = None, None

    def fit(self, dataset):
        if not dataset.hasLabel():
            raise Exception("Data has no labels.")
        X, self.y = dataset.getXy()
        self.X = np.hstack((np.ones((X.shape[0], 1)), X))
        self.is_fitted = True
        self.train_gd(self.X, self.y) if self.gd else self.train_closed(self.X, self.y)
        return self.X, self.y

    def train_gd(self, X, y):
        m, n = X.shape
        self.history = {}
        self.theta = np.zeros(n)
        for epoch in range(self.epochs):
            grad = 1 / m * (self.theta.dot(X.T)-y).dot(X)
            self.theta -= self.lr * grad
            self.history[epoch] = [self.theta[:], self.cost()]
        return self.theta

    def train_closed(self, X, y):
        self.theta = np.linalg.inv(X.T.dot(X)).dot(X.T).dot(y)
        return self.theta

    def predict(self, x):
        if not self.is_fitted:
            raise Exception("The model hasn't been fitted yet.")
        _x = np.hstack(([1], x))
        return np.dot(self.theta, _x)

    def cost(self, X=None, y=None):
        if not self.is_fitted:
            raise Exception("The model hasn't been fitted yet.")
        X = X if X is not None else self.X
        y = y if y is not None else self.y

        y_pred = X.dot(self.theta)
        return mse(y, y_pred) / 2

class LinearRegressionReg(LinearRegression):
    def __init__(self, gd=None, epochs=1000, lr=0.001, lbd=1):
        super().__init__(gd, epochs, lr)
        self.lbd = lbd

    def train_gd(self, X, y):
        m, n = X.shape
        self.history = {}
        self.theta = np.zeros(n)
        lbds = np.full(m, self.lbd)
        lbds[0] = 0
        for epoch in range(self.epochs):
            grad = 1 / m * (self.theta.dot(X.T)-y).dot(X)
            self.theta -= self.lr * (lbds+grad)
            self.history[epoch] = [self.theta[:], self.cost()]
        return self.theta

    def train_closed(self, X, y):
        m, n = X.shape
        identity = np.eye(n)
        identity[0, 0] = 0
        self.theta = np.linalg.inv(X.T.dot(X) + self.lbd*identity).dot(X.T).dot(y)
        return self.theta


from collections import Counter
from sklearn.neighbors import KernelDensity

import numpy as np

print("====================")
# Calculating Discrete Probability
print("Calculating Discrete Probability")

x = [0,1,0,0,0,1]
c = Counter(x)

def probability(a):
    # returns the probability of a given number a
    return float(c[a]) / len(x)

prob = probability(1)
print(prob)
print("====================")

# Calculating Continuous Probability
start = 5  # Start of the range
end = 6    # End of the range
N = 100    # Number of evaluation points

# Step size
step = (end - start) / (N - 1)
print("\nUniform linear distribution start=%s, end=%s, N=%s, Step size=%s" % (start, end, N,
                                                                             step))

# numpy.linspace - Return evenly spaced numbers over a specified interval i.e. uniformly distributed in linear space
# numpy.logspace - Return numbers spaced evenly on a log scale i.e. uniformly distributed in log space

# Generate values in the range i.e. we can generate a set of points equidistant from each
# other and estimate the kernel density at each point.
# np.newaxis might come in handy when you want to explicitly convert a 1D array to either a row vector or a column vector,
# make it as column vector by inserting an axis along second dimension

x_test_1d = np.linspace(start, end, N)

print("\nGenerated synthetic data from a uniform linear distribution:")
print(x_test_1d)

x_test = x_test_1d[:, np.newaxis] # make it as column vector by inserting an axis along second dimension

print("\nShape of synthetic data from a uniform linear distribution:")
print(x_test.shape)

# Get PDF values for each x
# Please note that kd.score_samples generates log-likelihood of the data samples.
# Therefore, np.exp is needed to obtain likelihood.
# When fitting a model your X needs to be 2D array. i.e (n_samples, n_features).
kde_model = KernelDensity(kernel='gaussian', bandwidth=0.75).fit(x_test)

kd_vals = np.exp(kde_model.score_samples(x_test))
print("\nLikelihood from uniform linear distribution synthetic data i.e. density from KDE:")
print(kd_vals)

# Approximate the integral of the PDF
probability = np.sum(kd_vals * step)
print("\nSumming the integrals of PDF from uniform linear distribution synthetic data  i.e. Probability:")
print(probability)

#Alternative using builtin SciPy integration methods
from scipy.integrate import quad

# Return the integration of a polynomial.
# The function quad is provided to integrate a function of one variable between two points

# Reshape your data using array.reshape(-1, 1) if your data has a single feature

# When you use .reshape(1, -1) it adds one dimension to the data.
# Reshape your data using array.reshape(1, -1) if it contains a single sample
# i.e. np.float64(x).reshape(1,-1) gives array([[ 0.]], dtype=float64) which is akin to [[x]]
fxn = lambda x: np.exp(kde_model.score_samples(np.float64(x).reshape(1,-1)))
probability = quad(fxn, start, end)[0]
# (quad returns a tuple where the first index is the result,# therefore the [0])
print("\nIntegral of PDF i.e. Probability ( using builtin and more accurate SciPy integration methods):")
print(probability)

print("====================")
# Generating Synthetic Data from 2 distributions - an asymmetric log-normal distribution and the other one is a Gaussian distribution
print("\nGenerating Synthetic Data from an asymmetric log-normal distribution and the other one is a Gaussian distribution")

def generate_data(mu1, sigma1, size1, mu2, sigma2, size2, seed):
    # Fix the seed to reproduce the results
    rand = np.random.RandomState(seed)

    # Draw samples from a log-normal distribution with specified mean, standard deviation, and array shape.
    # Note that the mean and standard deviation are not the values for the distribution itself,
    # but of the underlying normal distribution it is derived from.
    x = []
    # mean, standard deviation, size
    dat = rand.lognormal(mean=mu1, sigma=sigma1, size=size1)
    x = np.concatenate((x, dat))

    #loc - (Mean) where the peak of the bell exists.
    # scale - (Standard Deviation) how flat the graph distribution should be.
    # size - The shape of the returned array.
    # mean, standard deviation, size
    dat = rand.normal(loc=mu2, scale=sigma2, size=size2)
    x = np.concatenate((x, dat))
    return x

mu1, sigma1, size1 = 0, 0.3, 1000  # mean, standard deviation, size
mu2, sigma2, size2 = 3, 1, 1000  # mean, standard deviation, size
seed=17
x_train_1d = generate_data(mu1, sigma1, size1, mu2, sigma2, size2, seed) #one dimension i.e. 1d

print("\n Synthetic data from an asymmetric log-normal distribution (mean=%s, sigma=%s,size=%s) and a "
      "\n Gaussian distribution (mean=%s, standard deviation=%s, size=%s), with shape %s "
      "and length %s:" % (mu1, sigma1, size1,mu2, sigma2, size2, x_train_1d.shape, x_train_1d.size))
print(x_train_1d)
x_train = x_train_1d[:, np.newaxis] # make it as column vector by inserting an axis along second dimension

# ndarray.size - # Number of elements in the array. Caclulated as np.prod(a.shape), i.e., the product of the array’s dimensions.
print("====================")

# Generating Synthetic Data from two Gaussian distributions
print("\nGenerating Synthetic Data from two Gaussian distributions")

def generate_synthetic_data2(mu1, sigma1, size1, mu2, sigma2, size2, seed):
    # Fix the seed to reproduce the results
    rand = np.random.RandomState(seed)
    x = []
    dat = rand.normal(mu1, sigma1, size1)
    x = np.concatenate((x, dat))
    dat = rand.normal(mu2, sigma2, size2)
    x = np.concatenate((x, dat))
    return x

mu1, sigma1, size1 = 6, 1, 1000  # mean, standard deviation, size
mu2, sigma2, size2 = 3, 1, 1000  # mean, standard deviation, size
seed=17
x_train2_1d = generate_synthetic_data2(mu1, sigma1, size1, mu2, sigma2, size2, seed)
print("\n Synthetic data from two Gaussian distributions (mean=%s, sigma=%s,size=%s) and (mean=%s, sigma=%s,size=%s)\n"
      "with shape %s and length %s:" % (mu1, sigma1, size1,mu2, sigma2, size2, x_train2_1d.shape, x_train2_1d.size))
print(x_train2_1d)
x_train2 = x_train2_1d[:, np.newaxis]
print("====================")


import matplotlib.pyplot as plt
import numpy as np

# Euler Method
def euler_method(n0, decay_const, t_final, n_t_steps):
    iterations = n_t_steps
    delta_t = t_final/n_t_steps
    t1 = np.linspace(0, iterations*delta_t, iterations)
    n1 = np.zeros(t1.shape, float)
    n1[0]=n0
    for i in range(0,len(t1)-1):
        n1[i+1] = n1[i] * (1 - decay_const * delta_t )
    n1r = n1/n0
    return n1, n1r, t1

ne, ner, te = euler_method(n0=10000, decay_const=1.54e-1, t_final=20, n_t_steps=10)

#Analitical solution ...in the same points of the Euler method
def analytical_solution(n0, decay_const, t_final, n_t_steps):

    intermediate_points = n_t_steps
    delta_t = t_final/n_t_steps
    t2 = np.linspace(0, intermediate_points*delta_t, intermediate_points)
    n2 = n0 * np.exp(-decay_const * t2 )
    n2r = n2/n0
    return n2, n2r, t2

na, nar, ta = analytical_solution(n0=10000, decay_const=1.54e-1, t_final=20, n_t_steps=10)

euler_rel_error = 100*(ne-na)/na

fig = plt.figure()
ax1 = fig.add_subplot(1, 2, 1)
ax1.plot(te, ner, linestyle="-", linewidth=2, label='Euler method')
ax1.plot(ta, nar, linestyle="--", linewidth=2, label='Analytical Solution')
ax1.set_ylabel('Relative Number of $^{238}$U atoms')
ax1.set_xlabel('time in bilion years')
ax1.legend()

ax2 = fig.add_subplot(1, 2, 2)
ax2.plot(te, euler_rel_error, linestyle="-", linewidth=2, label='Deviation formthe \nexpected value')
ax2.set_ylabel('Relative Error, in %')
ax2.set_xlabel('time in bilion years')
ax2.legend()


import numpy as np
from scipy.spatial import distance
import matplotlib.pyplot as plt

def euc(a, b):
    return distance.euclidean(a, b)

class personalKNN():
    def fit(self, X_train, y_train):
        self.X_train = X_train
        self.y_train = y_train

    def predict(self, X_test):
        predictions = []

        for row in X_test:
            label = self.closest(row)
            predictions.append(label)

        return predictions

    def closest(self, row):
        smallestDist = euc(row, self.X_train[0])
        index = 0

        for i in range(1, len(self.X_train)):
            dist = euc(row, self.X_train[i])
            if dist < smallestDist:
                smallestDist = dist
                index = i

        return self.y_train[index]

class personalKMean():
    def __init__(self, k = 3, tol = 0.0001, maxIterations = 300):
        self.k = k
        self.tol = tol
        self.maxIterations = maxIterations

    def fit(self, data):
        self.clusters = {}

        for i in range(self.k):
            self.clusters[i] = data[i]

        for i in range(self.maxIterations):
            self.classifications = {}

            for i in range(self.k):
                self.classifications[i] = []

            for point in data:
                distances = [np.linalg.norm(point - self.clusters[cluster]) for cluster in self.clusters]
                classification = distances.index(min(distances))
                self.classifications[classification].append(point)

            prevClusters = dict(self.clusters)

            for group in self.classifications:
                self.clusters[group] = np.average(self.classifications[group], axis=0)

            optimized = True

            for cluster in self.clusters:
                originalCluster = prevClusters[cluster]
                curCluster = self.clusters[cluster]
                if np.sum((curCluster - originalCluster) / originalCluster * 100.0) > self.tol:
                    optimized = False

            if optimized:
                break

    def predict(self, data):
        distances = [np.linalg.norm(data - self.clusters[cluster]) for cluster in self.clusters]
        classification = distances.index(min(distances))
        return classification

from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
from sklearn import tree

iris = load_iris()

X = iris.data
y = iris.target

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=.5)

#1 Decision Tree Classifier
treeClassifier = tree.DecisionTreeClassifier()
treeClassifier.fit(X_train, y_train)

treePrediction = treeClassifier.predict(X_test)

print('Accuracy using a Decision Tree')
print(accuracy_score(y_test, treePrediction))

#2 K-Nearest Neighbors Classifier
personalClassifier = personalKNN()
personalClassifier.fit(X_train, y_train)

personalPrediction = personalClassifier.predict(X_test)

print('Accuracy using my own K Nearest Neighbor Model')
print(accuracy_score(y_test, personalPrediction))

#3 K-Means Classifier
personalMeanClassifier = personalKMean()
personalMeanClassifier.fit(X_train)

correct = 0
for i in range(len(X_test)):
    dataPoint = X_test[i]
    prediction = personalMeanClassifier.predict(dataPoint)

    if prediction == y_test[i]:
        correct += 1

accuracy = correct / len(X_test)

print('Accuracy using my own K Means Clustering Model')
print(accuracy)


import sympy as sy
import numpy as np
import random
from sympy import *

def threePointCubicApprox(x,y,xSlopePoint,yPrime):
	C3 = (y[2] - y[0])/((x[2] - x[1])*(x[2] - x[0])**2) - (y[1] - y[0])/((x[2] - x[1])*(x[1] - x[0])**2) + yPrime/((x[1]-x[0])*(x[2] - x[0]))
	C2 = (((y[1] - y[0])/(x[1] - x[0])) - yPrime)/(x[1] - x[0]) - C3*(2*x[0] + x[1])
	C1 = yPrime - 2*C2*x[0] - 3*C3*x[0]**2
	C0 = y[0] - C1*x[0] - C2*x[0]**2 - C3*x[0]**3

	return [C0,C1,C2,C3]

def threePointQuadraticApprox(x, y):
	#Inputs: Vector of x values and y values. These vectors must be equal length
	#Outputs: Coefficients for polynomial equation according to the form C0 + C1*x + C2*x^2...
	C2 = (((y[2]-y[0])/(x[2]-x[0])) - ((y[1]-y[0])/(x[1]-x[0])))/(x[2]-x[1])
	C1 = (y[1] - y[0])/(x[1]-x[0]) - C2*(x[0]+x[1])
	C0 = y[0] - C1*x[0] - C2*x[0]**2

	return [C0,C1,C2]

def twoPointLinearApprox(x, y):
	#Inputs: Vector of x values and y values. These vectors must be equal length
	#Outputs: Coefficients for polynomial equation according to the form C0 + C1*x + C2*x^2...
	C1 = (y[1] - y[0])/(x[1]-x[0])
	C0 = y[0] - C1*x[0]

	return [C0,C1]

def getValueOfPoly(c,x):
	#Inputs: Coefficients for polynomial equation according to the form C0 + C1*x + C2*x^2...
	#Inputs: x - value to get value at
	constantQuantity = len(c)

	if constantQuantity == 1:
		# Flat line
		y = c[0]
	elif constantQuantity == 2:
		# Linear
		y = c[0] + c[1] * x
	elif constantQuantity == 3:
		# Quadratic
		y = c[0] + c[1]*x + c[2]*x**2
	elif constantQuantity == 4:
		# Cubic
		y = c[0] + c[1]*x + c[2]*x**2 + c[3]*x**3
	else:
		print("Polynomial could not be calculated. Check getValueOfPoly function.")
		y = 99999999

	return y


'''
Data Characteristics
'''

import os
os.chdir("C:/Users/tprabhan/Documents/My_Books/RSADBE_2.0/R_and_Python_Programs/Python/Chapter_01")
import matplotlib.pyplot as plt
import numpy as np
import pylab

# Discrete Uniform Distribution
M = 10
mylabels = list(range(1,M+1))
prob_labels = [1/M]*M
plt.xlim([0.9,10.1])
plt.ylim([0,0.11])
plt.plot(mylabels,prob_labels,marker='o',linestyle='none')
plt.xlabel("Labels")
plt.ylabel("Probability")
plt.title("Probability of Discrete Uniform RV")
plt.show()

# Binomial Distribution
n = 10; p = 0.5
from scipy.stats import binom
p_x = binom.pmf(range(1,n+1),n,p)
plt.plot(range(1,n+1),p_x,marker='o',linestyle='none')
plt.show()

# Binomial Probabilities
n = 83; p = 0.01
from scipy.stats import binom
binom.pmf(10,n,p)
binom.pmf(20,n,p)
binom.pmf(30,n,p)
xr = range(0,84)
sum(binom.pmf(xr,n,p))

# Hypergeometric Distribution
N = 200; M = 20
n = 10
k = range(0,12)
from scipy.stats import hypergeom
np.round(hypergeom.pmf(k,N,M,n),3)

# Poisson Probabilities
from scipy.stats import poisson
poisson.pmf(0,3)
poisson.pmf(5,3)
poisson.pmf(20,3)

# Continuous Uniform Distribution
from scipy.stats import uniform
uniform.cdf(0.58)-uniform.cdf(0.35)

# Exponential Densities
from scipy.stats import expon
xr = np.arange(0,10.2,0.20)
f_x = expon.pdf(xr,scale=1)
pylab.plot(xr,f_x,'r',label='Rate=1')
f_x1 = expon.pdf(xr,scale=1/0.2)
pylab.plot(xr,f_x1,'g',label='Rate=0.2')
f_x2 = expon.pdf(xr,scale=1/0.5)
pylab.plot(xr,f_x2,'b',label='Rate=0.5')
f_x3 = expon.pdf(xr,scale=1/0.7)
pylab.plot(xr,f_x3,'y',label='Rate=0.7')
f_x4 = expon.pdf(xr,scale=1/0.85)
pylab.plot(xr,f_x4,'purple',label='Rate=0.85')
pylab.legend(loc='upper right')
plt.show()

# Exponential Densities Continued
from scipy.stats import expon
xr = np.arange(0,0.5,0.02)
f_x = expon.pdf(xr,scale=1/50)
pylab.plot(xr,f_x,'r',label='Rate=50')
f_x1 = expon.pdf(xr,scale=1/10)
pylab.plot(xr,f_x1,'g',label='Rate=10')
f_x2 = expon.pdf(xr,scale=1/20)
pylab.plot(xr,f_x2,'b',label='Rate=20')
f_x3 = expon.pdf(xr,scale=1/30)
pylab.plot(xr,f_x3,'y',label='Rate=30')
f_x4 = expon.pdf(xr,scale=1/40)
pylab.plot(xr,f_x4,'purple',label='Rate=40')
pylab.legend(loc='upper right')
pylab.show()

# Shady Normal Probabilities
from scipy.stats import norm
xr = np.arange(-4,4.1,0.1)
fx = norm.pdf(xr)
pylab.plot(xr,fx)
p1 = np.arange(0,4.02,0.02)
pylab.fill_between(p1,norm.pdf(p1))
pylab.show()
xr = np.arange(-4,4.1,0.1)
fx = norm.pdf(xr)
pylab.plot(xr,fx)
p2 = np.arange(-1.96,1.961,0.001)
pylab.fill_between(p2,norm.pdf(p2))
pylab.show()
xr = np.arange(-4,4.1,0.1)
fx = norm.pdf(xr)
pylab.plot(xr,fx)
p3 = np.arange(-2.58,2.581,0.001)
pylab.fill_between(p3,norm.pdf(p3))
pylab.show()


import numpy as np

'''
    In mathematics, Chebyshev distance (or Tchebychev distance), maximum metric,
    or L∞ metric is a metric defined on a vector space where
    the distance between two vectors is the greatest of their differences
    along any coordinate dimension.[2] It is named after Pafnuty Chebyshev.

    It is also known as chessboard distance, since in the game of chess the minimum number of
    moves needed by a king to go from one square on a chessboard to another equals the
    Chebyshev distance between the centers of the squares, if the squares have side length one,
    as represented in 2-D spatial coordinates with axes aligned to the edges of the board.
'''

objA = [22, 1, 42, 10]

objB = [20, 0, 36, 8]

npA = np.array(objA)

npB = np.array(objB)

chebyshev = np.abs(npA - npB).max()

# chebyshev = np.linalg.norm(npA -npB, ord=np.inf)

print(chebyshev)


"""Samples from normal distribution using Box-Muller transformation.

The transformation is applied to the bivariate normal distribution.
"""
import typing as t

import numpy as np

def sample_normal(num_inst: int = 1,
                  loc: float = 0.0,
                  scale: float = 1.0,
                  random_state: t.Optional[int] = None) -> np.ndarray:
    """Sample ``num_inst`` instances from normal distribution.

    Arguments
    ---------
    num_inst : :obj:`int`
        Number of samples to output.

    loc : :obj:`float`
       Mean of the normal distribution.

    scale : :obj:`float`
        Standard deviation (not the variance!) of the normal distribution.

    random_state : :obj:`int`, optional
        If not None, set numpy random seed before the first sampling.

    Returns
    -------
    :obj:`np.ndarray`
        Samples of the normal distribution with ``loc`` mean and ``scale``
        standard deviation.

    Notes
    -----
    Uses the Box-Muller bivariate transformation, which maps two samples
    from the Uniform Distribution U(0, 1) into two samples of the Normal
    Distribution N(0, 1).
    """
    if random_state is not None:
        np.random.seed(random_state)

    remove_extra_inst = False

    if num_inst % 2:
        num_inst += 1
        remove_extra_inst = True

    uniform_samples = np.random.uniform(0, 1, size=(2, num_inst // 2))

    aux_1 = np.sqrt(-2 * np.log(uniform_samples[0, :]))
    aux_2 = 2 * np.pi * uniform_samples[1, :]

    samples = np.concatenate((aux_1 * np.cos(aux_2), aux_1 * np.sin(aux_2)))

    samples = loc + scale * samples

    if remove_extra_inst:
        return samples[1:]

    return samples

def _test():
    import matplotlib.pyplot as plt
    import scipy.stats

    plt.subplot(1, 2, 1)
    vals = np.linspace(-4, 4, 100)
    plt.plot(vals, scipy.stats.norm(loc=0, scale=1).pdf(vals))
    samples = sample_normal(num_inst=1000, random_state=16)
    plt.hist(samples, bins=64, density=True)
    plt.title("N(0, 1)")

    plt.subplot(1, 2, 2)
    vals = np.linspace(-20, 20, 100)
    plt.plot(vals, scipy.stats.norm(loc=6, scale=3).pdf(vals))
    samples = sample_normal(loc=6, scale=3, num_inst=1000, random_state=32)
    plt.hist(samples, bins=64, density=True)
    plt.title("N(6, 3)")

    plt.show()

if __name__ == "__main__":
    _test()


from typing import Callable, List, Optional
import numpy as np

import lab1.src.grad.grad_step_strategy as st
import lab1.src.grad.stop_criteria as sc

DEFAULT_EPSILON = 1e-9
DEFAULT_MAX_ITERATIONS = 1e5

def gradient_descent(f: Callable[[np.ndarray], float],
                     f_grad: Callable[[np.ndarray], np.ndarray],
                     start: np.ndarray,
                     step_strategy: st.StepStrategy,
                     stop_criteria: sc.StopCriteria,
                     eps_strategy: float = DEFAULT_EPSILON,
                     eps_stop_criteria: float = DEFAULT_EPSILON,
                     max_iterations_strategy=DEFAULT_MAX_ITERATIONS,
                     max_iterations_criteria=DEFAULT_MAX_ITERATIONS,
                     trajectory: Optional[List] = None):
    strategy = st.get_step_strategy(step_strategy, f, f_grad, eps_strategy, max_iterations_strategy)
    criteria = sc.get_stop_criteria(stop_criteria, f, f_grad, eps_stop_criteria, max_iterations_criteria)
    cur_x = start
    iters = 0

    if trajectory is not None:
        trajectory.append(cur_x)

    while True:
        iters += 1
        cur_grad = f_grad(cur_x)
        step = strategy.next_step(cur_x)
        next_x = cur_x - step * cur_grad

        if criteria.should_stop(cur_x, next_x):
            return cur_x, iters

        cur_x = next_x
        if trajectory is not None:
            trajectory.append(cur_x)

        if iters == max_iterations_criteria:
            return cur_x, iters

if __name__ == '__main__':
    def foo(p):
        return p[0] ** 2 + p[1] ** 2

    def foo_grad(p):
        x, y = p[0], p[1]
        return np.array([2 * x, 2 * y])

    res, _ = gradient_descent(foo,
                              foo_grad,
                              start=np.array([3, 4]),
                              step_strategy=st.StepStrategy.DIVIDE_STEP,
                              stop_criteria=sc.StopCriteria.BY_GRAD)
    print(res)


import numpy as np
import cv2
import math

def medianFilter(image, size=3):
    return np.uint8(cv2.medianBlur(src=image, ksize=size))

def discreteFourierTransform(image):
    gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)

    dft = cv2.dft(np.float32(gray), flags = cv2.DFT_COMPLEX_OUTPUT)
    dft_shift = np.fft.fftshift(dft)

    magnitude_spectrum = 20*np.log(cv2.magnitude(dft_shift[:,:,0],dft_shift[:,:,1]))

    return magnitude_spectrum

def butterworthLowPassMask(width,height,dinit = 20,n = 1):
    width = width / 2
    height = height / 2

    hw = np.arange(-width, width)
    hh = np.arange(-height, height)

    x, y = np.meshgrid(hh, hw)
    mg = np.sqrt(x**2 + y**2)
    return 1 / (1 + (mg/dinit)**(2*n))

def butterworthHighPassMask(width,height,dinit = 20,n = 1):
    width = width / 2
    height = height / 2

    hw = np.arange(-width, width)
    hh = np.arange(-height, height)

    x, y = np.meshgrid(hh, hw)
    mg = np.sqrt(x**2 + y**2)
    return 1 / (1 + (dinit/mg)**(2*n))

def butterworthFilter(image, d=20, n=1, filter_type="low"):
    gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
    width, height = gray.shape

    mask = ""
    if filter_type == "low":
        mask = butterworthLowPassMask(width, height, d, n)
    elif filter_type == "high":
        mask = butterworthHighPassMask(width, height, d, n)

    fft = np.fft.fftshift(np.fft.fft2(np.float32(gray)))

    img_pass = mask * fft
    result = np.abs(np.fft.ifft2(np.fft.ifftshift(img_pass)))

    return result

def imsave(num, algo, img):
    imname = "radiograph_{}_{}.jpg".format(num, algo)
    cv2.imwrite("./output/" + imname, img)

if __name__ == "__main__":
    img_1 = cv2.imread("./samples/radiograph_1.jpg")
    img_2 = cv2.imread("./samples/radiograph_2.jpg")

    # Apply Median Filter with Adjusted Kernel's Size
    imsave(1, "median_filter", medianFilter(img_1, 15))
    imsave(2, "median_filter", medianFilter(img_2, 11))

    # Apply Discrete Fourier Transform
    imsave(1, "dft", discreteFourierTransform(img_1))
    imsave(2, "dft", discreteFourierTransform(img_2))

    # Apply Butterworth Lowpass Filter
    imsave(1, "butterworth_lowpass", butterworthFilter(img_1, 20, 2, "low"))
    imsave(2, "butterworth_lowpass", butterworthFilter(img_2, 20, 3, "low"))

    # Apply Butterworth Highpass Filter
    imsave(1, "butterworth_highpass", butterworthFilter(img_1, 100, 1, "high"))
    imsave(2, "butterworth_highpass", butterworthFilter(img_2, 40, 1, "high"))


## Here we will use tensorflow with Linear Regression
# Recap: Defining a linear regression in simple terms, is the approximation of a linear model
# used to describe the relationship between two or more variables.
# When more than one independent variable is present the process is called multiple linear regression.
# When multiple dependent variables are predicted the process is known as multivariate linear regression.

#Importing libraries

import numpy as np
import pandas as pd

import matplotlib.patches as mpatches
import matplotlib.pyplot as plt

import tensorflow.compat.v1 as tf
tf.disable_v2_behavior() # Disabling version 2 as eagar execution gives few codes error.

plt.rcParams['figure.figsize']=(8,6)

# in linear regression, we always want to adjust slope and intercept to the data the best way possible.

#Data read: using same example that we used in machine learning course: FuelConsumptionCo2.csv

df=pd.read_csv('D:\Py_Coursera\Deep Learning IBM AI Specialization\Building Deep Learning Models with TensorFlow\Labs\FuelConsumptionCo2.csv')
with open('Lab1_TensorFlow_LinearRegression.txt','a') as f:
    print(df.head(),file=f)
    print('\n',file=f)

# We want to predict CO2 emissions of car based on their engine size
train_x=np.asanyarray(df[['ENGINESIZE']])
train_y=np.asanyarray(df[['CO2EMISSIONS']])

# Intialize variables using tensorflow with random values
a=tf.Variable(20.0) #slope
b=tf.Variable(30.2) #intercept
y=a*train_x+b #line equation

# We have to define tow functions:
    ## Loss: MSE - minimize the square of the predicted values minus target value
    ## Optimizer method : gradient descent method - LR parameter corresponds to speed with which the optimizer should learn. Can change the method

loss = tf.reduce_mean(tf.square(y-train_y)) # reduce_mean - finds mean of tensor, square - MSE
optimizer = tf.train.GradientDescentOptimizer(
    learning_rate=0.05, use_locking=False, name='GradientDescent')# LR - 0.05 using gradient descent optimizer
#disable_eager_execution()
# To train the graph, we need to minimize the error function of our optimizer using tf.minimize()
train_op=optimizer.minimize(loss)

# To run the session for varaiables:
init=tf.global_variables_initializer()
sess=tf.Session()
sess.run(init)

loss_values = [] #initialize loss values empty matrix
train_data = [] #initilize train data empty matrix
for step in range(100):
    _, loss_val, a_val, b_val = sess.run([train_op, loss, a, b]) #running session to displya values
    loss_values.append(loss_val)
    if step % 5 == 0:
        with open('Lab1_TensorFlow_LinearRegression.txt','a') as f:
            print(step, loss_val, a_val, b_val,file=f)
            train_data.append([a_val, b_val])

#plot the cost function for linear regression using gradient descent of LR=0.05
plt.figure()
plt.plot(loss_values,'ro')

# visualize coefficient and intercept fit the data
plt.figure()
cr, cg, cb = (1.0, 1.0, 0.0)
for f in train_data:
    cb += 1.0 / len(train_data)
    cg -= 1.0 / len(train_data)
    if cb > 1.0: cb = 1.0
    if cg < 0.0: cg = 0.0
    [a, b] = f
    f_y = np.vectorize(lambda x: a*x + b)(train_x)
    line = plt.plot(train_x, f_y)
    plt.setp(line, color=(cr,cg,cb))

plt.plot(train_x, train_y, 'ro')
green_line = mpatches.Patch(color='red', label='Data Points')
plt.legend(handles=[green_line])

#Display plot
plt.show()

Building Deep Learning Models with TensorFlow/Module1/Lab1_TensorFlow_LinearRegression.py


# Necessary Packages
import numpy as np

#%% AR(1) Generation.
'''
X_t = coef * X_t-1 + n
n ~ N(0, sigma^2)
sigma = np.sqrt(margin*(1-coef*coef))
Therefore, Marginal distribution is N(0, margin)

Inputs
- n: Number of samples
- p: Number of features
- phi: Autoregressiveness
- margin: Std of the normal distribution
'''
def AR_Gauss_X1 (n, d, t, phi, sigma):

    # Initialization
    Output_X = list()

    # For each sample
    for i in range(n):

        Temp_Output_X = np.zeros([t,d])

        # For each feature
        for j in range(d):

            for k in range(t):

                # Starting feature
                if (k == 0):
                    Temp_Output_X[k,j] = np.random.normal(0,sigma)

                # AR(1) Generation
                else:
                    Temp_Output_X[k,j] = phi[j] * Temp_Output_X[k-1,j] + (1-phi[j])*np.random.normal(0,sigma)

        Output_X.append(Temp_Output_X)

    return Output_X

#%%
def AR_Gauss_X2 (n, d, t, phi, sigma, gamma):

    # Initialization
    Output_X = list()

    # For each sample
    for i in range(n):

        Temp_Output_X = np.zeros([t,2*d])

        # For each feature
        for j in range(d):

            for k in range(t):

                # Starting feature
                if (k == 0):
                    Temp_Output_X[k,j] = np.random.normal(0,sigma)

                # AR(1) Generation
                else:
                    Temp_Output_X[k,j] = phi[j] * Temp_Output_X[k-1,j] + (1-phi[j])*np.random.normal(0,sigma)

                Temp_Output_X[k,d+j] = Temp_Output_X[k,j] + np.random.normal(0,gamma)

        Output_X.append(Temp_Output_X)

    return Output_X


#!/usr/bin/env python3

import sys
import numpy as np
from math import pi, sqrt
import cvxopt
from cvxopt import matrix, solvers

def gen_eq_bloch_states(n, C=3.6, return_angles=True):
    '''
    Generates equally spaced points on the bloch sphere through a spiral.
    See here: https://www.intlpress.com/site/pub/files/_fulltext/journals/mrl/1994/0001/0006/MRL-1994-0001-0006-a003.pdf

    ==========
    Parameters
    ----------
    n (type=int): the number of points to be generated evenly on the sphere
    C (type=float, default=3.6): constant that makes sure that succesive points
                                 on S^2 will be the same Euclidean distance apart
    return_angles (type=boolean, default=True): whether to also return the angles
                                                associated with each state
    ==========

    ======
    Return
    ------
    (type=numpy.array): an array for n evenly spaced coordinates on the block sphere,
                         with their associated angles if needed
    ======
    '''
    states = np.zeros((n, 2), dtype=complex)
    h = np.zeros(n)
    theta = np.zeros(n)
    phi = np.zeros(n)

    # initialize
    h[0] = -1
    h[n-1] = -1 + 2*(n-1)/(n-1)
    theta[0] = np.arccos(h[0])
    theta[n-1] = np.arccos(h[n-1])
    phi[0] = 0
    phi[n-1] = 0
    states[0] = [np.cos(theta[0]/2), np.sin(theta[0]/2)*np.exp(1j*phi[0])]
    states[n-1] = [np.cos(theta[n-1]/2), np.sin(theta[n-1]/2)*np.exp(1j*phi[n-1])]

    # the rest of the states
    for k in range(1, n-1):
        h[k] = -1 + 2*(k)/(n-1)
        theta[k] = np.arccos(h[k])
        phi[k] = np.mod(phi[k] + C/(sqrt(n)*sqrt(1 - h[k]**2)), 2*pi)
        states[k] = [np.cos(theta[k]/2), np.sin(theta[k]/2)*np.exp(1j*phi[k])]

    if return_angles==True:
        return [states, theta, phi]
    else:
        return states

def vec(M):
    '''
    Converts an n x m matrix into a column vector, where the first n entries
    corresponds to the first column of the matrix, the next n entries to the
    second column, and so on.

    https://stackoverflow.com/a/25248378

    ==========
    Parameters
    ----------
    M (type=numpy.array): the matrix to use for the column vector
    ==========

    ======
    Return
    ------
    (type=numpy.array): the column vector
    ======
    '''
    return M.reshape((-1, 1), order="F")

def mat(v, n, m, dty=float):
    '''
    Converts a vector of length n*m to an n x m matrix where the first n entries
    corresponds to the first column of the matrix, the next n entries to the
    second column, and so on.

    ==========
    Parameters
    ----------
    v (type=numpy.array): the array to use for the matrix
    n (type=int): number of rows for the matrix
    m (type=int): number of columns for the matrix
    dty (type=type): type of the matrix
    ==========

    ======
    Return
    ------
    (type=numpy.array): the matrix
    ======
    '''
    M = np.zeros((n, m), dtype=dty)
    for i in range(n):
        for j in range(m):
            M[i][j] = v[n*j + i]
    return M

def s_min(E, W, F, n, m):
    '''
    Function that minimizes the estimated state space, S, with a fixed effect space
    according to the following quadratic program:

    minimize_{S} vec(S)^T @ (E x I_n) @ W (E^T x I_n) vec(S) - 2 vec(S)^T @ (E x I_n) @ W @ vec(F)

    subject to 0 <= (E^T x I_n) @ vec(S) <= 1 (element wise inequality)

    ==========
    Parameters
    ----------
    E (type=numpy.array): fixed effect space matrix; dim = k x m
    W (type=numpy.array): matrix that encodes the uncertainties along the diagonal
                          for each preparation/measurement pair; dim = n*m x n*m
    F (type=numpy.array): data matrix; dim = n x m
    n (type=int): number of preparations
    m (type=int): number of measurements
    ==========

    ======
    Return
    ------
    (type=numpy.array): the solution to the quadratic program
    ======
    '''
    I_n = np.identity(n, dtype=float)
    P = 2 * (np.kron(E, I_n)) @ W @ (np.kron(np.transpose(E), I_n))
    q = -2 * np.kron(E, I_n) @ W @ vec(F)
    G_0 = -np.kron(np.transpose(E), I_n)
    G_1 = np.kron(np.transpose(E), I_n)
    h_0 = (np.zeros(n*m))
    h_1 = (np.ones(n*m))

    P = matrix(P)
    q = matrix(q)
    G = matrix(np.concatenate([G_0, G_1]))
    h = matrix(np.concatenate([h_0, h_1]))

    return solvers.qp(P, q, G, h, kktsolver="chol")

def e_min(S, W, F, n, m):
    '''
    Function that minimizes the estimated effect space, E, with a fixed state space
    according to the following quadratic program:

    minimize_{E} vec(E)^T @ (I_m x S)^T @ W (I_m x S) vec(S) - 2 vec(S)^T @ (I_m x S)^T @ W @ vec(F)

    subject to 0 <= (I_m x S) @ vec(E) <= 1 (element wise inequality)

    ==========
    Parameters
    ----------
    S (type=numpy.array): fixed state space matrix; dim = k x m
    W (type=numpy.array): matrix that encodes the uncertainties along the diagonal
                          for each preparation/measurement pair; dim = n*m x n*m
    F (type=numpy.array): data matrix; dim = n x m
    n (type=int): number of preparations
    m (type=int): number of measurements
    ==========

    ======
    Return
    ------
    (type=numpy.array): the solution to the quadratic program
    ======
    '''
    I_m = np.identity(m, dtype=float)
    P = 2 * (np.transpose(np.kron(I_m, S))) @ W @ (np.kron(I_m, S))
    q = -2 * np.transpose(np.kron(I_m, S)) @ W @ vec(F)
    G_0 = -np.kron(I_m, S)
    G_1 = np.kron(I_m, S)
    h_0 = (np.zeros(n*m))
    h_1 = (np.ones(n*m))

    P = matrix(P)
    q = matrix(q)
    G = matrix(np.concatenate([G_0, G_1]))
    h = matrix(np.concatenate([h_0, h_1]))

    return solvers.qp(P, q, G, h, kktsolver="chol")

def chi_squared(S, E, F, W, n, m):
    '''
    Calculates the weighted chi^2 value.

    ==========
    Parameters
    ----------
    S (type=numpy.array): state space matrix; dim = n x k
    E (type=numpy.array): effect space matrix; dim = k x m
    F (type=numpy.array): data matrix; dim = n x m
    W (type=numpy.array): matrix that encodes the uncertainties along the diagonal
                          for each preparation/measurement pair; dim = n*m x n*m
    n (type=int): number of preparations
    m (type=int): number of measurements
    ==========

    ======
    Return
    ------
    (type=float): the chi^2 value
    ======
    '''
    sum = 0
    w_index = 0
    D = S @ E
    for i in range(n):
        for j in range(m):
            sum += ((F[i][j] - D[i][j]))**2 * W[w_index][w_index]
            w_index += 1
    return sum

def bfp(k, E_0, F, W, n, m, max_iterations=5000, convergence_threshold=10E-6):
    '''
    Finds the low-rank matrix that best fits the data matrix of frequencies.

    ==========
    Parameters
    ----------
    k (type=int): rank of best-fit matrix
    E_0 (type=numpy.array): initial estimate of effect space matrix; dim = k x m
    F (type=numpy.array): data matrix; dim = n x m
    W (type=numpy.array): matrix that encodes the uncertainties along the diagonal
                          for each preparation/measurement pair; dim = n*m x n*m
    n (type=int): number of preparations
    m (type=int): number of measurements
    max_iterations (type=int, default=5000): the max number of iterations to go through
                                             for the optimization
    convergence_threshold (type=float, default=10E-16): the convergence threshold for
                                                        the optimization
    ==========

    ======
    Return
    ------
    (type=numpy.array): an array that contains the estimated state space and effect space matrices
    ======
    '''
    S = np.zeros((n, k), dtype=float)
    E = E_0
    chi_squared_prev = 0
    chi_squared_curr = 0
    iteration = 1
    while (True):
        chi_squared_prev = chi_squared_curr
        S = mat(s_min(E, W, F, n, m)['x'], n, k)
        E = mat(e_min(S, W, F, n, m)['x'], k, m)
        chi_squared_curr = chi_squared(S, E, F, W, n, m)
        if (iteration == max_iterations or (chi_squared_curr -  chi_squared_prev) < convergence_threshold):
            break
        iteration += 1
    return [S, E]

####################################################
#==================================================#
####################################################

def aic(k, chi_squared_k, m, n):
    r_k = k*(m + n - k)
    return chi_squared_k + r_k

def is_pos_semi_def(A, tol=1e-8):
    E = np.linalg.eigvalsh(A)
    return np.all(E > -tol)

def S_min(S, E, F, W, m):
    I_m = np.identity(m)
    A_1 = np.transpose(vec(S)) @ (np.kron(E, I_m)) @ W @ (np.kron(np.transpose(E), I_m)) @ vec(S)
    A_2 = 2 * np.transpose(vec(S)) @ (np.kron(E, I_m)) @ W @ vec(F)
    A = A_1 - A_2
    return A[0][0]

def E_min(E, S, F, W, n):
    I_n = np.identity(n)
    A_1 = np.transpose(vec(E)) @ np.transpose(np.kron(I_n, S)) @ W @ (np.kron(I_n, S)) @ vec(E)
    A_2 = 2 * np.transpose(vec(E)) @ np.transpose(np.kron(I_n, S)) @ W @ vec(F)
    A = A_1 - A_2
    return A[0][0]

def cvxopt_solve_qp(P, q, G=None, h=None, A=None, b=None):
    P = .5 * (P + P.T)  # make sure P is symmetric
    args = [cvxopt.matrix(P), cvxopt.matrix(q)]
    if G is not None:
        args.extend([cvxopt.matrix(G), cvxopt.matrix(h)])
        if A is not None:
            args.extend([cvxopt.matrix(A), cvxopt.matrix(b)])
    sol = cvxopt.solvers.qp(*args)
    if 'optimal' not in sol['status']:
        return None
    return np.array(sol['x']).reshape((P.shape[1],))

def quadprog_solve_qp(P, q, G=None, h=None, A=None, b=None):
    qp_G = .5 * (P + P.T)   # make sure P is symmetric
    qp_a = -q
    if A is not None:
        qp_C = -numpy.vstack([A, G]).T
        qp_b = -numpy.hstack([b, h])
        meq = A.shape[0]
    else:  # no equality constraint
        qp_C = -G.T
        qp_b = -h
        meq = 0
    return quadprog.solve_qp(qp_G, qp_a, qp_C, qp_b, meq)[0]

def wrla_1(U, V, A, W):
    A_tilda = U @ V
    summation = 0
    w = 0
    for i in range(len(A)):
        for j in range(len(A[0])):
            summation += W[w][w] * ((A[i][j] - A_tilda[i][j])**2)
    return summation

def wrla_2(V, U, A, W):
    A_tilda = U @ V
    summation = 0
    w = 0
    for i in range(len(A)):
        for j in range(len(A[0])):
            summation += W[w][w] * ((A[i][j] - A_tilda[i][j])**2)
    return summation


'''
------------------------------------
Assignment 9 - EE2703 (Jan-May 2020)
Done by Akilesh Kannan (EE18B122)
Created on 20/03/20
Last Modified on 27/04/20
------------------------------------
'''

# Imports
import cmath
import numpy as np
import numpy.fft as fft
import matplotlib.pyplot as plt
from scipy.linalg import lstsq
from mpl_toolkits.mplot3d import Axes3D
from matplotlib import cm

# Global Variables
plotsDir = 'plots/'
PI = np.pi
figNum = 0
showAll = True

# Functions used
def hammingWindow(n):
    '''
                    0.54 + 0.46*cos(2πn/(N−1)), |n| <= (N-1)/2
        w[n] =
                    0, otherwise
    '''

    N = n.size
    window = np.zeros(N)
    window = 0.54 + 0.46*np.cos((2*PI*n)/(N-1))
    return fft.fftshift(window)

def plotSignal(t, x, figTitle, style='b-', blockFig=False, showFig=True, saveFig=False):
    global figNum
    plt.figure(figNum)
    plt.title(figTitle)
    plt.grid()
    plt.plot(t, x, style)
    if(saveFig):
        plt.savefig(plotsDir + "Fig"+str(figNum)+".png")
    if(showFig):
        plt.show(block=blockFig)
    figNum+=1

def plotSpectrum(figTitle, w, Y, magStyle='b-', phaseStyle='ro', xLimit=None, yLimit=None, showFig=False, saveFig=True, blockFig=False):
    global figNum
    plt.figure(figNum)
    plt.suptitle(figTitle)
    plt.subplot(211)
    plt.grid()
    plt.plot(w, abs(Y), magStyle, lw=2)
    plt.ylabel(r"$\|Y\|$")
    if (xLimit):
        plt.xlim(xLimit)
    if (yLimit):
        plt.ylim(yLimit)
    plt.subplot(212)
    plt.grid()
    plt.plot(w, np.angle(Y), phaseStyle, lw=2)
    plt.xlim(xLimit)
    plt.ylabel(r"$\angle Y$")
    plt.xlabel(r"$\omega\ \to$")

    if(saveFig):
        plt.savefig(plotsDir + "Fig"+str(figNum)+".png")
    if(showFig):
        plt.show(block=blockFig)
    figNum+=1

# Example 1 - sin(sqrt(2)t)

    ## Without windowing

t = np.linspace(-PI, PI, 65)[:-1]
dt = t[1]-t[0]
fmax = 1/dt
y = np.sin(cmath.sqrt(2)*t)
y[0] = 0
y = fft.fftshift(y)
Y = fft.fftshift(fft.fft(y))/64.0
w = np.linspace(-PI*fmax, PI*fmax, 65)[:-1]
plotSpectrum(r"Spectrum of $sin(\sqrt{2}t)$", w, Y, xLimit=[-10, 10], showFig=showAll)

    ## Windowing with Hamming Window

t = np.linspace(-PI, PI, 65)[:-1]
dt = t[1]-t[0]
fmax = 1/dt
n = np.arange(64)
y = np.sin(cmath.sqrt(2)*t) * hammingWindow(n)
y[0] = 0
y = fft.fftshift(y)
Y = fft.fftshift(fft.fft(y))/64.0
w = np.linspace(-PI*fmax, PI*fmax, 65)[:-1]
plotSpectrum(r"Spectrum of $sin(\sqrt{2}t) * w(t)$", w, Y, xLimit=[-8, 8], showFig=showAll)

# Question 2 - spectrum of (cos(0.86 t))**3

    ## Without windowing

t = np.linspace(-4*PI, 4*PI, 257)[:-1]
dt = t[1]-t[0]
fmax = 1/dt
y = np.cos(0.86*t)**3
y[0] = 0
y = fft.fftshift(y)
Y = fft.fftshift(fft.fft(y))/256.0
w = np.linspace(-PI*fmax, PI*fmax, 257)[:-1]
plotSpectrum(r"Spectrum of $cos^3(0.86t)$", w, Y, xLimit=[-8, 8], showFig=showAll)

    ## Windowing with Hamming Window

t = np.linspace(-4*PI, 4*PI, 257)[:-1]
dt = t[1]-t[0]
fmax = 1/dt
n = np.arange(256)
y = (np.cos(0.86*t))**3 * hammingWindow(n)
y[0] = 0
y = fft.fftshift(y)
Y = fft.fftshift(fft.fft(y))/256.0
w = np.linspace(-PI*fmax, PI*fmax, 257)[:-1]
plotSpectrum(r"Spectrum of $cos^3(0.86t) * w(t)$", w, Y, xLimit=[-8, 8], showFig=showAll)

def estimateWandD(w, wo, Y, do, pow=2):
    wEstimate = np.sum(abs(Y)**pow * abs(w))/np.sum(abs(Y)**pow) # weighted average
    print("wo = {:.03f}\t\two (Estimated) = {:.03f}".format(wo, wEstimate))

    t = np.linspace(-PI, PI, 129)[:-1]
    y = np.cos(wo*t + do)

    c1 = np.cos(wEstimate*t)
    c2 = np.sin(wEstimate*t)
    A = np.c_[c1, c2]
    vals = lstsq(A, y)[0]
    dEstimate = np.arctan2(-vals[1], vals[0])
    print("do = {:.03f}\t\tdo (Estimated) = {:.03f}".format(do, dEstimate))

# Question 3 - Estimation of w, d in cos(wt + d)
wo = 1.35
d = PI/2

print("Question 3:")
t = np.linspace(-PI, PI, 129)[:-1]
trueCos = np.cos(wo*t + d)
fmax = 1.0/(t[1]-t[0])
n = np.arange(128)
y = trueCos.copy()*hammingWindow(n)
y = fft.fftshift(y)
Y = fft.fftshift(fft.fft(y))/128.0
w = np.linspace(-PI*fmax, PI*fmax, 129)[:-1]
plotSpectrum(r"Spectrum of $cos(\omega_o t + \delta) \cdot w(t)$", w, Y, xLimit=[-4, 4], showFig=showAll, saveFig=False)
estimateWandD(w, wo, Y, d, pow=1.75)

# Question 4 - Estimation of w, d in noisy cos(wt + d)

print("\nQuestion 4:")
trueCos = np.cos(wo*t + d)
noise = 0.1*np.random.randn(128)
n = np.arange(128)
y = (trueCos + noise)*hammingWindow(n)
fmax = 1.0/(t[1]-t[0])
y = fft.fftshift(y)
Y = fft.fftshift(fft.fft(y))/128.0
w = np.linspace(-PI*fmax, PI*fmax, 129)[:-1]
plotSpectrum(r"Spectrum of $(cos(\omega_o t + \delta) + noise) \cdot w(t)$", w, Y, xLimit=[-4, 4], showFig=showAll, saveFig=False)
estimateWandD(w, wo, Y, d, pow=2.5)

# Question 5 - DFT of chirp

# chirp function used
def chirp(t):
    return np.cos(16*(1.5*t + (t**2)/(2*PI)))

t = np.linspace(-PI, PI, 1025)[:-1]
x = chirp(t)
plotSignal(t, x, r"$cos(16(1.5 + \frac{t}{2\pi})t)$", saveFig=True)
fmax = 1.0/(t[1]-t[0])
X = fft.fftshift(fft.fft(x))/1024.0
w = np.linspace(-PI*fmax, PI*fmax, 1025)[:-1]
plotSpectrum(r"DFT of $cos(16(1.5 + \frac{t}{2\pi})t)$", w, X, 'b-', 'r.-', [-75, 75], showFig=showAll, saveFig=True)

n = np.arange(1024)
x = chirp(t)*hammingWindow(n)
plotSignal(t, x, r" $cos(16(1.5 + \frac{t}{2\pi})t) \cdot w(t)$", saveFig=True)
X = fft.fftshift(fft.fft(x))/1024.0
plotSpectrum(r"DFT of $cos(16(1.5 + \frac{t}{2\pi})t) \cdot w(t)$", w, X, 'b-', 'r.-', [-75, 75], showFig=showAll, saveFig=True)

# Question 6 - Time evolution of DFT of chirp signal

# calculates DFT of x, taking every batchSize samples
def STFT(x, t, batchSize=64):
    t_batch = np.split(t, 1024//batchSize)
    x_batch = np.split(x, 1024//batchSize)
    X = np.zeros((1024//batchSize, batchSize), dtype=complex)
    for i in range(1024//batchSize):
        X[i] = fft.fftshift(fft.fft(x_batch[i]))/batchSize
    return X

# plots the STFT
def plot3DSTFT(t, w, X, colorMap=cm.viridis, showFig=showAll, saveFig=True, blockFig=False):
    global figNum

    t = t[::64]
    w = np.linspace(-fmax*PI,fmax*PI,65)[:-1]
    t, w = np.meshgrid(t, w)

    fig = plt.figure(figNum)
    ax = fig.add_subplot(211, projection='3d')
    surf = ax.plot_surface(w, t, abs(X).T, cmap=colorMap)
    fig.colorbar(surf)
    plt.xlabel(r"Frequency $\to$")
    plt.ylabel(r"Time $\to$")
    plt.title(r"Magnitude $\|Y\|$")

    ax = fig.add_subplot(212, projection='3d')
    surf = ax.plot_surface(w, t, np.angle(X).T, cmap=colorMap)
    fig.colorbar(surf)
    plt.xlabel(r"Frequency $\to$")
    plt.ylabel(r"Time $\to$")
    plt.title(r"Angle $\angle Y$")
    if saveFig:
        plt.savefig(plotsDir+"Fig"+str(figNum)+".png")
    if showFig:
        plt.show(block=blockFig)

    figNum+=1

x = chirp(t)
X = STFT(x, t)
plot3DSTFT(t, w, X, colorMap=cm.plasma)

x = chirp(t)*hammingWindow(np.arange(1024))
X = STFT(x, t)
plot3DSTFT(t, w, X, blockFig=True)


# Create a 2D Numpy Array

# Import the libraries
import numpy as np
import matplotlib.pyplot as plt

# Consider the list a, the list contains three nested lists each of equal size.

# Create a list
a = [[11, 12, 13], [21, 22, 23], [31, 32, 33]]
print(a)

# We can cast the list to a Numpy Array as follow
# Convert list to Numpy Array
# Every element is the same type
A = np.array(a)
print(A)

# Show the numpy array dimensions
print(A.ndim)

# Attribute shape returns a tuple corresponding to the size or number of each dimension.
# Show the numpy array shape
print(A.shape)

# The total number of elements in the array is given by the attribute size.
# Show the numpy array size
print(A.size)

# We simply use the square brackets and the indices corresponding to the element we would like:
# Access the element on the second row and third column
print(A[1, 2])

# We can also use the following notation to obtain the elements:
# Access the element on the second row and third column
print(A[1][2])

# We can access the element as follows
# Access the element on the first row and first column
print(A[0][0])

# This can be done with the following syntax
# Access the element on the first row and first and second columns
print(A[0][0:2])

# Similarly, we can obtain the first two rows of the 3rd column as follows:
# Access the element on the first and second rows and third column
print(A[0:2, 2])

# The numpy array is given by X and Y
# Create a numpy array X
X = np.array([[1, 0], [0, 1]])
print(X)

# Create a numpy array Y
Y = np.array([[2, 1], [1, 2]])
print(Y)

# We can add the numpy arrays as follows.
# Add X and Y
Z = X + Y
print(Z)

# We can perform the same operation in numpy as follows
# Create a numpy array Y
Y = np.array([[2, 1], [1, 2]])
print(Y)

# Multiply Y with 2
Z = 2 * Y
print(Z)

# We can perform element-wise product of the array X and Y as follows:
# Create a numpy array Y
Y = np.array([[2, 1], [1, 2]])
print(Y)

# Create a numpy array X
X = np.array([[1, 0], [0, 1]])
print(X)

# Multiply X with Y
Z = X * Y
print(Z)

# We can also perform matrix multiplication with the numpy arrays A and B as follows:
# First, we define matrix A and B:
# Create a matrix A
A = np.array([[0, 1, 1], [1, 0, 1]])
print(A)

# Create a matrix B
B = np.array([[1, 1], [1, 1], [-1, 1]])
print(B)

# Calculate the dot product
Z = np.dot(A,B)
print(Z)

# Calculate the sine of Z
print(np.sin(Z))

# We use the numpy attribute T to calculate the transposed matrix
# Create a matrix C
C = np.array([[1,1],[2,2],[3,3]])
print(C)

# Get the transposed of C
print(C.T)

# Quiz

# Consider the following list a, convert it to Numpy Array.
a = [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]
A = np.array(a)
print(A)

# Calculate the numpy array size.
print(A.size)

# Access the element on the first row and first and second columns.
print(A[0][0:2])

# Perform matrix multiplication with the numpy arrays A and B.
B = np.array([[0, 1], [1, 0], [1, 1], [-1, 0]])
X = np.dot(A,B)
print(X)


from math import log, log10, floor, ceil
from random import randrange
from numpy import base_repr
import warnings

from PyModulo import *
from PyModulo.Utility import *

class RSA:
	class PublicInfo:
		def __init__(self, N, e):
			self.N=N
			self.e=e

		def get_key(self):
			return self.N, self.e

	class PrivateInfo:
		def __init__(self, p, q, d, lowerN=None):
			self.p=p
			self.q=q
			self.d=d
			self.lowerN = lowerN if lowerN else (p-1)*(q-1)

		def get_key(self):
			return self.p, self.q, self.d, self.lowerN

	def __init__(self, NDigitRange=(20,25), priInfo=None, pubInfo=None, printDebug=False):
		if(isinstance(pubInfo,RSA.PublicInfo) and priInfo==None):
			warnings.warn("Warning, RSA with only a public key can only encrypt messages (can't derive private keys from public keys)!")

		if(not isinstance(pubInfo, RSA.PublicInfo)):
			if(not isinstance(priInfo, RSA.PrivateInfo)):
				minDig, maxDig = NDigitRange

				dnMin, dnMax = (ceil((minDig+1)/2), floor((maxDig+1)/2))
				debug(dnMin, dnMax, output=printDebug)

				diff = dnMax-dnMin
				debug("diff:", diff, output=printDebug)

				#the number of digits in the first number:
				dp = randrange(dnMin,dnMax+diff+1)
				p = rand_n_digit_prime(dp)

				#the number of digits in the second number:
				dq =randrange(dnMin,dnMax*2-dp+1)
				q = rand_n_digit_prime(dq)

				lowerN = (p-1)*(q-1)

				debug("p=%i digits (digits: %i=%i desired)"%(p, len(str(p)), dp), output=printDebug)
				debug("q=%i digits (digits: %i=%i desired)"%(q, len(str(q)), dq), output=printDebug)
				debug("**Found p and q (p:%i, q:%i)"%(p,q), output=printDebug)
				debug("lowerN [(p-1)*(q-1)]=%i"%lowerN, output=printDebug)

				#find d:
				while((dInfo:=gcd_info(d:=randrange(1,lowerN),lowerN))["GCD"]!=1):
					pass
				debug("found d=%i and :"%d, dInfo, output=printDebug)

			else:
				p, q, d, lowerN = priInfo.get_key()
				dInfo = gcd_info(d,lowerN)

			N = p*q

			debug("N=%i (digits=%i)"%(N, len(str(N))), output=printDebug)
			debug("(p-1)*(q-1):", lowerN, output=printDebug)

			e = dInfo["u"] % lowerN
			debug("Found e:",e, output=printDebug)
		else:
			p,q,d,lowerN = (-1,-1,-1,-1)
			N, e = pubInfo.get_key()

		self.pubInfo = RSA.PublicInfo(N,e)
		self.priInfo = RSA.PrivateInfo(p, q, d, lowerN)

	def get_info(self):
		return {"N": self.pubInfo.N, "e": self.pubInfo.e, "p": self.priInfo.p, "q":self.priInfo.q, "d": self.priInfo.d, "lowerN":self.priInfo.lowerN}

	def encrypt_message(self, m, encoder=lambda x: int(x, 36)):
		mInt = encoder(m)
		return power_mod(mInt, self.pubInfo.e, self.pubInfo.N)

	def decrypt_message(self, m, decoder=lambda x: base_repr(x,base=36)):
		mInt = power_mod(m, self.priInfo.d, self.pubInfo.N)
		return decoder(mInt)


import numpy as np
from sklearn.linear_model import LinearRegression

x = np.array([5, 15, 25, 35, 45, 55]).reshape((-1, 1))
y = np.array([5, 20, 14, 32, 22, 38])
print(x)
print(y)

model = LinearRegression().fit(x, y)
r_sq = model.score(x, y)
print('coefficient of determination:', r_sq)
print('intercept:', model.intercept_)
print('slope:', model.coef_)

"""new_model = LinearRegression().fit(x, y.reshape((-1, 1)))
print('intercept:', new_model.intercept_)
print('slope:', new_model.coef_)

y_pred = model.predict(x)
print('predicted response:', y_pred, sep='\n')
y_pred = model.intercept_ + model.coef_ * x
print('predicted response:', y_pred, sep='\n')
x_new = np.arange(5).reshape((-1, 1))

print(" First few points of the line :")
print(x_new)
y_new = model.predict(x_new)
print(y_new)"""


import numpy as np
import math

def lagrange(f, x_vec, x):
    return np.sum(
        [f[i] * np.prod([(x - x_vec[i]) / (x_vec[i] - x_vec[j]) if j != i else 1. for j in range(len(x_vec))])\
                      for i in range(len(x_vec))])

def newton(f, x_vec, x):
    n = len(x_vec)
    coeff = [np.sum([f[k] / np.prod([(x_vec[k] - x_vec[j]) if j != k else 1. for j in range(i + 1)]) for k in range(i + 1)]) for i in range(n)]
    return np.sum(\
                     [coeff[i] * np.prod([x - x_vec[j] for j in range(i)])\
               for i in range(n)])

def omega(x_vec, x):
    return np.prod([(x - el) for el in x_vec])

def derived(x):
    return -(24 * x * (-1 + x ** 2))/(1 + x ** 2) ** 4

def main():
    x_1 = [-3, -1, 1, 3]
    x_2 = [-3, 0, 1, 3]
    M = 4.66
    pn = [lagrange, newton]
    names = ["lagrange", "newton"]
    x_val = -0.5

    for test in zip([x_1, x_2], pn, names):
        x_vec = test[0]
        f = [np.arctan(x) for x in x_vec]
        poly = test[1]
        name = test[-1]
        print(f'Checking {name} interpolation')
        eps = np.abs(np.arctan(x_val) - poly(f, x_vec, x_val))
        print(f'|atan(x) - P(x)| = {eps}')
        upper_bound = np.abs(omega(x_vec, x_val)) * M / math.factorial(len(x_vec) + 1)
        print(f'M / (n + 1)!|w(x)| = {upper_bound}')
        print(f'is it quite good? {eps <= upper_bound}\n')

main()


import sys
sys.path.insert(0, '/Users/carol/python/ThinkBayes2/thinkbayes2/')

import numpy as np
import matplotlib.pyplot as plt

from thinkbayes2 import Pmf, Suite, CredibleInterval, Beta

# PMF for 6-sided die
pmf = Pmf()
for x in [1, 2, 3, 4, 5, 6]:
    pmf.Set(x, 1/6)
print(pmf)

# How to build up a pmf from a list of strings
pmf2 = Pmf()
for word in ['a', 'in', 'or', 'to', 'a', 'me', 'in']:
    pmf2.Incr(word, 1)
pmf2.Normalize()
print(pmf2)
print("Probability of letter a:", pmf2.Prob('a'))   # Typo p12 print pmf.Prob('the') should read print(pmf.Prob('the'))

# PMF for the Cookie problem
pmf = Pmf()
# Prior:
pmf.Set("Bowl 1", 0.5)
pmf.Set("Bowl 2", 0.5)
# Posterior:
# First multiply prior by likelihood
pmf.Mult("Bowl 1", 0.75)
pmf.Mult("Bowl 2", 0.5)
# Then normalise (we can do this because the hypotheses are mutually exclusive and collectively exhaustive,
# i.e. only one of the hypotheses can be true and there can be no other hypothesis)
pmf.Normalize()
print(pmf)

# Create a Cookie class that inherits from Pmf and represents the Cookie problem
class Cookie(Pmf):

    proportions = {
        'Bowl 1':dict(vanilla=0.75, chocolate=0.25),
        'Bowl 2':dict(vanilla=0.5, chocolate=0.5),
        }

    def __init__(self, hypos):
        Pmf.__init__(self)
        for hypo in hypos:
            self.Set(hypo, 1)
        self.Normalize()

    def Update(self, data):
        for hypo in self.Values():
            likelihood = self.Likelihood(data, hypo)
            self.Mult(hypo, likelihood)
        self.Normalize()

    def Likelihood(self, data, hypo):
        proportion = self.proportions[hypo]
        likelihood = proportion[data]
        return likelihood

# Set up the hypotheses for the Cookie problem
hypos = ['Bowl 1', 'Bowl 2']
# Initialise the prior for the Cookie problem
pmf = Cookie(hypos)
print("Prior:")
for hypo, prob in pmf.Items():
    print(hypo, prob)     # Type p14: print hypo, prob should read print(hypo, prob)
# Update the prior given one data point (we drew a vanilla cookie)
pmf.Update('vanilla')
print("Posterior:")
for hypo, prob in pmf.Items():
    print(hypo, prob)
# Draw some more cookies (with replacement) and update the prior
dataset = ['vanilla', 'chocolate', 'vanilla']
for data in dataset:
    pmf.Update(data)
print("Posterior:")
for hypo, prob in pmf.Items():
    print(hypo, prob)

# Implement the Cookie problem by writing a class that inherits from Suite and providing the Likelihood method.
# Suite implements the Update and Print methods, which are the same for all Bayesian problems,
# but not the Likelihood method, which depends on the specification of the problem.
class CookieProblem(Suite):

    proportions = {
        hypos[0]:dict(vanilla=0.75, chocolate=0.25),
        hypos[1]:dict(vanilla=0.5, chocolate=0.5),
        }

    def Likelihood(self, data, hypo):
        proportion = self.proportions[hypo]
        likelihood = proportion[data]
        return likelihood

# Set up hypotheses for Cookie problem
hypos = ['Bowl 1', 'Bowl 2']
# Initialise prior
pmf = CookieProblem(hypos)
print("Prior:")
pmf.Print()
# Draw some cookies (with replacement) and update the prior
dataset = ['vanilla', 'vanilla', 'chocolate', 'vanilla']
for data in dataset:
    pmf.Update(data)
print("Posterior:")
pmf.Print()

# Write a class for the m & m problem
class M_and_M(Suite):

    mix94 = dict(brown=30,
                 yellow=20,
                 red=20,
                 green=10,
                 orange=10,
                 tan=10)

    mix96 = dict(blue=24,
                 green=20,
                 orange=16,
                 yellow=14,
                 red=13,
                 brown=13)

    hypoA = dict(bag1=mix94, bag2=mix96)
    hypoB = dict(bag1=mix96, bag2=mix94)

    hypotheses = dict(A=hypoA, B=hypoB)

    def Likelihood(self, data, hypo):
        bag, color = data
        mix = self.hypotheses[hypo][bag]
        likelihood = mix[color]
        return likelihood

# Implement the m & m problem
hypos = 'AB' # can also be written: hypos = ['A', 'B']
pmf = M_and_M(hypos)
pmf.Update(('bag1', 'yellow'))
pmf.Update(('bag2', 'green'))
pmf.Print()

# Implement the Cookie problem without replacement
class CookieGetsEaten(Suite):

    def __init__(self, hypos, Bowl1, Bowl2):
        Suite.__init__(self, hypos)
        self.Bowl1 = Bowl1
        self.Bowl2 = Bowl2

    def Likelihood(self, data, hypo):
        if (hypo == "Bowl 1") & (data == "vanilla"):
            likelihood = self.Bowl1.num_vanilla / (self.Bowl1.num_vanilla + self.Bowl1.num_chocolate)
            Bowl1.num_vanilla -= 1
        elif (hypo == "Bowl 1") & (data == "chocolate"):
            likelihood = self.Bowl1.num_chocolate / (self.Bowl1.num_vanilla + self.Bowl1.num_chocolate)
            Bowl1.num_chocolate -= 1
        elif (hypo == "Bowl 2") & (data == "vanilla"):
            likelihood = self.Bowl2.num_vanilla / (self.Bowl2.num_vanilla + self.Bowl2.num_chocolate)
            Bowl2.num_vanilla -= 1
        elif (hypo == "Bowl 2") & (data == "chocolate"):
            likelihood = self.Bowl2.num_chocolate / (self.Bowl2.num_vanilla + self.Bowl2.num_chocolate)
            Bowl2.num_chocolate -= 1
        return likelihood

# Set up a "Bowl" object
class Bowl():
    def __init__(self, num_vanilla=20, num_chocolate=20):
        self.num_vanilla = num_vanilla
        self.num_chocolate = num_chocolate

    def __str__(self):
        return "Vanilla: {}, Chocolate {}.".format(self.num_vanilla, self.num_chocolate)

# Set up hypotheses for Cookie problem
hypos = ['Bowl 1', 'Bowl 2']
# Create two Bowl objects with the right mix of cookies in each
Bowl1 = Bowl(30, 10)
Bowl2 = Bowl(20, 20)
# Initialise prior
pmf = CookieGetsEaten(hypos, Bowl1, Bowl2)
print("Prior:")
pmf.Print()
# Draw some cookies (with replacement) and update the prior
dataset = ['vanilla', 'vanilla', 'chocolate', 'vanilla']
#dataset = ['vanilla', 'chocolate', 'chocolate', 'chocolate', 'chocolate', 'chocolate', 'chocolate', 'chocolate', 'chocolate', 'chocolate', 'chocolate', 'chocolate', 'chocolate', 'chocolate']
for data in dataset:
    pmf.Update(data)
    print("Bowl 1:", Bowl1, "Bowl 2:", Bowl2)
    print("Posterior:")
    pmf.Print()
#print("Posterior:")
pmf.Print()

# Set up a class for doing Bayesian Inference on which of 5 different dice have been rolled:
class Dice(Suite):
    def Likelihood(self, data, hypo):
        if hypo < data:
            return 0
        else:
            return 1 / hypo

# Initialise a pmf for the Dice problem
pmf = Dice([4, 6, 8, 12, 20])  # The hypotheses are: 4-sided, 6-sided, 8-sided, 12-sided, 20-sided
print("Prior:")
pmf.Print()
pmf.Update(6)  # The data is that a 6 was rolled (using one of the dice - we don't know which one)
print("Posterior:")
pmf.Print()
# Calculate a confidence interval at the 90% level for which dice the data could come from
interval = CredibleInterval(pmf, 90)   # Error p28: function Percentile has been replaced with CredibleInterval
print("Confidence interval:", interval)   # typo p28: print interval should read print(interval)
# With more data, posterior distributions based on different priors tend to converge
for roll in [6, 8, 7, 7, 5, 4]:
    pmf.Update(roll)
print("Posterior:")
pmf.Print()
# Calculate a confidence interval at the 90% level for which dice the data could come from
interval = CredibleInterval(pmf, 90)
print("Confidence interval:", interval)
# Calculate the expectation value of the pmf
print("Mean:", pmf.Mean())
# Create a Cdf object from the pmf
cdf = pmf.MakeCdf()
interval = cdf.Percentile(5), cdf.Percentile(95)
print("Confidence interval:", interval)

# Create a coin class for analysing coin-tossing problems
class Coin(Suite):
    # Define the Likelihood method (this version is slow because there is one data point for every coin toss,
    # so the method has to be called lots of times)
    def Likelihood(self, data, hypo):
        if data == "H":
            likelihood = hypo
        elif data == "T":
            likelihood = 1 - hypo
        return likelihood

# Function for plotting posterior
def plot_bias(pmf):
    bias = []
    prob = []
    for b, p in pmf.Items():
        bias.append(b)
        prob.append(p)
    plt.plot(bias, prob)

# Function for calculating maximum likelihood hypothesis
def MaximumLikelihood(pmf):
    """Returns the hypothesis with the highest probability."""
    prob, val = max((prob, val) for val, prob in pmf.Items())
    return val

# Create 101 hypotheses for the bias of the coin, ranging from 0 to 1
hypos = np.arange(0, 1.01, 0.01)
# Create pmf for coin bias hypotheses
pmf = Coin(hypos)
# Create data set of successive coin tosses
#dataset = "HHHHHTTTT"
dataset = 140 * "H" + 110 * "T"
# Update posterior
#for data in dataset:
#    pmf.Update(data)       # The Update method normalises the pmf for every data point.
pmf.UpdateSet(dataset)    # We can save time by performing all the updates first and only normalising at the end, using UpdateSet.
# Compute 90% confidence interval
interval = CredibleInterval(pmf, 90)
print("90% confidence interval:", interval)
# Compute maximum likelihood hypothesis
print("Maximum likelihood hypothesis:", MaximumLikelihood(pmf))
# Plot posterior
plot_bias(pmf)
plt.show()

# Create a fast coin class for analysing coin-tossing problems efficiently
class CoinFast(Suite):
    # This verson of Likelihood is fast because the results of all coin tosses are included in one data point: data = (heads, tails)
    # Now, the update of the posterior takes the same amount of time, no matter how many coin tosses there are.
    def Likelihood(self, data, hypo):
        heads, tails = data
        likelihood = hypo**heads * (1-hypo)**tails  # We can multiply all the likelihoods because coin tosses are independent events.
        return likelihood

# Set up hypotheses for the coin-tossing problem
hypos = np.arange(0, 1.01, 0.01)
# Initialise a CoinFast pmf
pmf = CoinFast(hypos)
# Represent the data set as a tuple of (heads, tails)
data = (140, 110)
# Update posterior
pmf.Update(data)
# Plot posterior
plot_bias(pmf)
plt.show()

# Create a class for the Beta distribution
class BetaCHW(object):
    def __init__(self, alpha=1, beta=1):   # By default __init__ makes a uniform distribution
        self.alpha = alpha
        self.beta = beta

    # Update performs a Bayesian update:
    def Update(self, data):
        heads, tails = data
        self.alpha += heads
        self.beta += tails

    # Mean calculates the mean of the distribution using a formula that involves only alpha and beta
    def Mean(self):
        return float(self.alpha) / (self.alpha + self.beta)

    # EvalPdf evaluates the probability density function (PDF) of the beta distribution
    def EvalPdf(self, x):
        return x ** (self.alpha - 1)  *  (1 - x) ** (self.beta - 1)

beta = BetaCHW()
beta.Update((140, 110))
print("Mean hypothesis:", beta.Mean())    # Typo p40: print beta.Mean() should read print(beta.Mean())

# Cromwell’s rule: avoid giving a prior probability of 0 to any hypothesis that is even remotely possible.
# If the prior goes to zero, the posterior will always be zero thereafter.

# Ex 4.1: Coin tossing problem when the reader of the coin toss has a probability y of giving the wrong reading.
class UncertainCoin(Suite):
    def Likelihood(self, data, hypo):
        heads, tails, y = data
        likelihood = (hypo * (1 - y) + (1 - hypo) * y) ** heads  *  ((1 - hypo) * (1 - y) + hypo * y) ** tails
        return likelihood

hypos = np.arange(0, 1.01, 0.01)
pmf = UncertainCoin(hypos)
data = (140, 110, 0)  # (heads, tails, y)
pmf.Update(data)
plot_bias(pmf)

pmf = UncertainCoin(hypos)
data = (140, 110, 0.1)  # (heads, tails, y)
pmf.Update(data)
plot_bias(pmf)

pmf = UncertainCoin(hypos)
data = (140, 110, 0.2)  # (heads, tails, y)
pmf.Update(data)
plot_bias(pmf)

pmf = UncertainCoin(hypos)
data = (140, 110, 0.3)  # (heads, tails, y)
pmf.Update(data)
plot_bias(pmf)

plt.show()

# Try using the Beta distribution to create a more realistic prior for the Belgain Euro coin problem
beta = Beta()               # Uniform prior
beta.Update((140, 110))
pmf = beta.MakePmf()
plot_bias(pmf)

beta = Beta(100, 100)       # Broadish prior centred on bias of 0.5
beta.Update((140, 110))
pmf = beta.MakePmf()
plot_bias(pmf)

beta = Beta(300, 300)       # Narrow prior centred on bias of 0.5 (if alpha, beta > 300, peak is just a spike and we get an error)
beta.Update((140, 110))
pmf = beta.MakePmf()
plot_bias(pmf)

interval = CredibleInterval(pmf, 90)
print("90% confidence interval:", interval)  # Since this comes out at (0.49, 0.55), it now seems less likely the coin is really biased.

plt.show()


import numpy as np

def initialize_all(y0, t0, t1, n):
    """ An initialization routine for the different ODE solving
    methods in the lab. This initializes Y, T, and h. """
    if isinstance(y0, np.ndarray):
        Y = np.empty((n, y.size)).squeeze()
    else:
        Y = np.empty(n)
	# print y0
	# print Y
    Y[0] = y0
    T = np.linspace(t0, t1, n)
    h = float(t1 - t0) / (n - 1)
    return Y, T, h

def euler(f, y0, t0, t1, n):
    """ Use the Euler method to compute an approximate solution
    to the ODE y' = f(t, y) at n equispaced parameter values from t0 to t
    with initial conditions y(t0) = y0.

    y0 is assumed to be either a constant or a one-dimensional numpy array.
    t and t0 are assumed to be constants.
    f is assumed to accept two arguments.
    The first is a constant giving the value of t.
    The second is a one-dimensional numpy array of the same size as y.

    This function returns an array Y of shape (n,) if
    y is a constant or an array of size 1.
    It returns an array of shape (n, y.size) otherwise.
    In either case, Y[i] is the approximate value of y at
    the i'th value of np.linspace(t0, t, n).
    """
    Y, T, h = initialize_all(y0, t0, t1, n)
    for i in xrange(1, n):
        Y[i] = Y[i-1] + f(T[i-1], Y[i-1]) * h
    return Y

def euler_accuracy(y0, t0, t1, N=(11, 21, 41)):
    """ Test the accuracy of the Euler method using the
    initial value problem y' + y = 2 - 2x, with y(0) = y0
    Plot your solutions over the given domain with n as 11, 21, and 41.
    Also plot the exact solution.
    Show the plot. """
    f = lambda x, y: 2 - y - 2 * x
    for n in N:
        T = np.linspace(t0, t1, n)
        plt.plot(T, euler(f, y0, t0, t1, n))
    plt.plot(T, 4 - 2 * T - 4 * np.exp(-T))
    plt.show()

# The inversion here could also be done using scipy.optimize's Newton's method.
# Currently in the lab, this function isn't required.
def backwards_euler(f, fsolve, y0, t0, t1, n):
    """ Use the backward Euler method to compute an approximate solution
    to the ODE y' = f(t, y) at n equispaced parameter values from t0 to t
    with initial conditions y(t0) = y0.

    y0 is assumed to be either a constant or a one-dimensional numpy array.
    t and t0 are assumed to be constants.
    f is assumed to accept two arguments.
    The first is a constant giving the value of t.
    The second is a one-dimensional numpy array of the same size as y.
    fsolve is a function that solves the equation
    y(x_{i+1}) = y(x_i) + h f(x_{i+1}, y(x_{i+1}))
    for the appropriate value for y(x_{i+1}).
    It should accept three arguments.
    The first should be the value of y(x_i).
    The second should be the distance between values of t.
    The third should be the value of x_{i+1}.

    This function returns an array Y of shape (n,) if
    y is a constant or an array of size 1.
    It returns an array of shape (n, y.size) otherwise.
    In either case, Y[i] is the approximate value of y at
    the i'th value of np.linspace(t0, t, n).
    """
    Y, T, h = initialize_all(y0, t0, t1, n)
    for i in xrange(1, n):
        Y[i] = fsolve(Y[i-1], h, T[i])
    return Y

def midpoint(f, y0, t0, t1, n):
    """ Use the midpoint method to compute an approximate solution
    to the ODE y' = f(t, y) at n equispaced parameter values from t0 to t1
    with initial conditions y(t0) = y0.

    y0 is assumed to be either a constant or a one-dimensional numpy array.
    t0 and t1 are assumed to be constants.
    f is assumed to accept two arguments.
    The first is a constant giving the value of t.
    The second is a one-dimensional numpy array of the same size as y.

    This function returns an array Y of shape (n,) if
    y is a constant or an array of size 1.
    It returns an array of shape (n, y.size) otherwise.
    In either case, Y[i] is the approximate value of y at
    the i'th value of np.linspace(t0, t, n).
    """
    Y, T, h = initialize_all(y0, t0, t1, n)
    for i in xrange(1, n):
        Y[i] = Y[i-1] + h * f(T[i-1] + h / 2., Y[i-1] + (h / 2.) * f(T[i-1], Y[i-1]))
    return Y

# This one isn't currently required in the lab.
def modified_euler(f, y0, t0, t1, n):
    """ Use the modified Euler method to compute an approximate solution
    to the ODE y' = f(t, y) at n equispaced parameter values from t0 to t1
    with initial conditions y(t0) = y0.

    y0 is assumed to be either a constant or a one-dimensional numpy array.
    t and t0 are assumed to be constants.
    f is assumed to accept two arguments.
    The first is a constant giving the value of t.
    The second is a one-dimensional numpy array of the same size as y.

    This function returns an array Y of shape (n,) if
    y is a constant or an array of size 1.
    It returns an array of shape (n, y.size) otherwise.
    In either case, Y[i] is the approximate value of y at
    the i'th value of np.linspace(t0, t, n).
    """
    Y, T, h = initialize_all(y0, t0, t1, n)
    for i in xrange(1, n):
        Y[i] = Y[i-1] + (h / 2.) * (f(T[i-1], Y[i-1]) + f(T[i-1], Y[i-1] + h * f(T[i-1], Y[i-1])))
    return Y

def RK4(f, y0, t0, t1, n):
    """ Use the RK4 method to compute an approximate solution
    to the ODE y' = f(t, y) at n equispaced parameter values from t0 to t
    with initial conditions y(t0) = y0.

    'y0' is assumed to be either a constant or a one-dimensional numpy array.
    't0' and 't1' are assumed to be constants.
    'f' is assumed to accept two arguments.
    The first is a constant giving the current value of t.
    The second is a one-dimensional numpy array of the same size as y.

    This function returns an array Y of shape (n,) if
    y is a constant or an array of size 1.
    It returns an array of shape (n, y.size) otherwise.
    In either case, Y[i] is the approximate value of y at
    the i'th value of np.linspace(t0, t, n).
    """
    Y, T, h = initialize_all(y0, t0, t1, n)
    for i in xrange(1, n):
        K1 = f(T[i-1], Y[i-1])
        tplus = (T[i] + T[i-1]) * .5
        K2 = f(tplus, Y[i-1] + .5 * h * K1)
        K3 = f(tplus, Y[i-1] + .5 * h * K2)
        K4 = f(T[i], Y[i-1] + h * K3)
        Y[i] = Y[i-1] + (h / 6.) * (K1 + 2 * K2 + 2 * K3 + K4)
    return Y

def compare_accuracies(N, t=2):
    """ Test the accuracies of the Euler, backwards Euler, modified Euler,
    midpoint, and RK4 methods using initial value problem
    y' + y = 2 - 2x, y(0) = 0.
    Use the different values of n in 'N', and plot h=1./(n-1)
    vs the RELATIVE error at time 't'. """
    f = lambda x, y: 2 - y - 2 * x
    exact = 4 - 2 * t - 4 * np.exp(-t)
    euler_err = [abs((euler(f, 0, 0, t, n)[-1] - exact) / exact) for n in N]
    midpoint_err = [abs((midpoint(f, 0, 0, t, n)[-1] - exact) / exact) for n in N]
    RK4_err = [abs((RK4(f, 0, 0, t, n)[-1] - exact) / exact) for n in N]
    H = [1. / (n-1) for n in N]
    plt.loglog(H, euler_err, H, midpoint_err, H, RK4_err)
    plt.show()

def simple_harmonic_oscillator(y0, t0, t, n, m=1, k=1):
    """ Use the RK4 method to solve for the simple harmonic oscillator
    problem described in the problem about simple harmonic oscillators.
    Return the array of values at the equispaced points.
    'y0', 't0', 't', and 'n' are the same as they were in the ODE solving routines.
    'm' and 'k' are constants used in the ODE. """
    f = lambda x, y: np.array([y[1], - k * y[0] / float(m)])
    return RK4(f, y0, t0, t, n)[:,0]

def damped_harmonic_oscillator(y0, t0, t, n, gamma):
    """ Use the RK4 method to solve for the damped harmonic oscillator
    problem described in the problem about damped harmonic oscillators.
    Return the array of values at the equispaced points.
    'y0', 't0', 't', and 'n' are the same as they were in the ODE solving routines.
    gamma is the parameter from the ODE. """
    gamma = .5
    f = lambda x, y: np.array([y[1], - (gamma * y[1] + y[0])])
    return RK4(f, y0, t0, t, n)[:,0]

def forced_harmonic_oscillator(y0, t0, t, n, gamma, omega):
    """ Use the RK4 method to solve for the forced harmonic oscillator
    problem. 'y0', 't0', 't', and 'n' are the same as the variables passed
    to the RK4 function. 'gamma' and 'omega' are constants. """
    f = lambda x, y: np.array([y[1], np.cos(omega * x) - y[0] - gamma * y[1] / 2])
    return RK4(f, y0, t0, t, n)[:,0]


import numpy as np

# Lebesgue constant
# L_j(x) = Pi_{i=0}^{j} (x - x_i)/(x_j - x_i)
# delta_n = max{a<=x<=b} sum_{i=0}^{n} |L_i(x)|

"""
Let $x_0, x_1, \ldots x_n$  be distinct nodes, and suppose $p(x)$ and $\hat{p}(x)$ are polynomials of degree at most $n$ satisfying $p(x_j) = y_j$ and $\hat{p}(x_j) = \hat{y}_j$, $j=0,1,\ldots, n$. If
$$  \vert y_j - \hat{y}_j\vert \leq \delta,  \quad j=0,1,\ldots, n,$$
then
$$ \Vert p - \hat{p} \Vert_{\infty} \leq \Lambda_n \delta.$$
"""

# cheyshev nodes are given by
def chebyshev_nodes(n):
    # the lebesgue constant for chebyshev nodes is
    # Delta_n = O(log(n))
    j = arange(n+1)
    return (a + b)/2 - (b - a)/2*cos(j*pi/n)

def chebyshev_fit(n):
    j = arange(n+1)
    d = ones(n+1)
    d[0] = 0.5
    d[-1] = 0.5
    return (-1)**j*d


"""
Implementing various distance measures, i.e., norms.
"""
import numpy as np

def hamming_distance(a, b):
    """
    The Hamming distance (a discrete norm between binary-valued arrays).
    """
    return np.sum(a != b)

def l1_norm(x):
    """
    The L1-norm, a continuous valued norm.
    """
    return np.sum(np.abs(x))

def l2_norm(x):
    """
    The L2-norm, a continuous valued norm.
    """
    return np.sqrt(np.sum(np.abs(x)**2))

# TODO: Why is this faster than the built-in `np.linalg.norm`?
def lp_norm(x, p):
    """
    The general LP-norm, a continuous valued norm.
    """
    return np.power(np.sum(np.power(np.abs(x), p)), 1/p)

# TODO: Divergence or distance?
def l1_divergence(a, b):
    """
    The L1 di, a continuous valued norm between two arrays of the same shape.
    """
    return np.sum(np.abs(a - b))

# TODO: Divergence or distance?
def l2_divergence(a, b):
    """
    The L2-norm, a continuous valued norm between two arrays of the same shape.
    """
    return np.sqrt(np.sum(np.abs(a - b)**2))