# [DRAFT] A box code for volumetric integration

The basic method I'm implementing here is from {cite:p}`ethridgeNewFastMultipoleAccelerated2001`.

Also:
{cite:p}`langstonFreespaceAdaptiveFMMBased2011` extended the method to 3D and {cite:p}`malhotraPVFMMParallelKernel2015b` made it distributed and fast.

{cite:p}`gholamiFFTFMMMultigrid2016a` demonstrates that an FMM method based on these ideas can be very competitive with other state of the art volumetric Poisson solver

### Next steps:

* Split into nearfield and far-field and run the full box code.
* Do some runtime benchmarks with the box code.
* Function extension by zero as default.

## Setup

```
from tectosaur2.nb_config import setup

setup()
```

```
import numpy as np
import matplotlib.pyplot as plt
import sympy as sp

from tectosaur2 import gauss_rule
from tectosaur2.laplace2d import single_layer
```

```
x, y = sp.symbols("x, y")
```

```
# sym_soln = 2 + x + y + x ** 2 + y * sp.cos(6 * x) + x * sp.sin(6 * y)
```

```
gaussian_centers = np.array([[0.1, 0.1], [0, 0], [-0.15, 0.1]])
alpha = 250
ethridge_sym_soln = 0
for i in range(3):
    r2_i = (x - gaussian_centers[i, 0]) ** 2 + (y - gaussian_centers[i, 1]) ** 2
    ethridge_sym_soln += sp.exp(-alpha * r2_i)
```

```
ethridge_sym_laplacian = sp.diff(sp.diff(ethridge_sym_soln, x), x) + sp.diff(
    sp.diff(ethridge_sym_soln, y), y
)
ethridge_soln_fnc = sp.lambdify((x, y), ethridge_sym_soln, "numpy")
ethridge_laplacian_fnc = sp.lambdify((x, y), ethridge_sym_laplacian, "numpy")
```

## Singular quadrature, poor convergence.

```
def run(obs_pt, nq):
    q_vol, qw_vol = gauss_rule(nq)

    qx_vol, qy_vol = np.meshgrid(q_vol, q_vol)
    q2d_vol = np.array([qx_vol.flatten(), qy_vol.flatten()]).T.copy()
    q2d_vol_wts = (qw_vol[:, None] * qw_vol[None, :]).flatten()
    fxy = -ethridge_laplacian_fnc(q2d_vol[:, 0], q2d_vol[:, 1])

    u_particular = (
        (single_layer.kernel(obs_pt, q2d_vol)[:,0,:,0] * q2d_vol_wts[None, :])
        .dot(fxy)
        .reshape(obs_pt.shape[0])
    )
    return u_particular
```

```
obs_pt = np.array([[-0.1, -0.1]])
correct = ethridge_soln_fnc(obs_pt[0, 0], obs_pt[0, 1])

steps = np.arange(2, 1200, 10)
ests = np.array([run(obs_pt, s) for s in steps])

difference = np.linalg.norm(ests - correct, axis=1) / np.linalg.norm(correct)

plt.plot(steps, np.log10(difference))
plt.show()
```

## Two-dimensional interpolation

```
import quadpy

def clencurt(n1):
    """Computes the Clenshaw Curtis quadrature nodes and weights"""
    C = quadpy.c1.clenshaw_curtis(n1)
    return (C.points, C.weights)

# TODO: is there a quadpy function that does tensor products?
def tensor_product(x, w):
    rect_x, rect_y = np.meshgrid(x, x)
    rect_pts = np.array([rect_x.flatten(), rect_y.flatten()]).T
    rect_w = np.outer(w, w).flatten()
    return rect_pts, rect_w

def clencurt_2d(n):
    return tensor_product(*clencurt(n))

def cheblob(n):
    """Computes the chebyshev lobatto."""
    pts = clencurt(n)[0]
    wts = (-1) ** np.arange(n).astype(np.float64)
    wts[0] *= 0.5
    wts[-1] *= 0.5
    return pts, wts  # tensor_product(pts, wts)
```

```
eps = np.finfo(float).eps

def barycentric_tensor_product(evalx, evaly, interp_pts, interp_wts, fnc_vals):
    """
    eval_pts is (N, 2)
    interp_pts is (Q,)
    interp_wts is (Q,)
    fnc_vals is (P, Q^2)
    """

    dx = evalx[:, None] - interp_pts
    dy = evaly[:, None] - interp_pts

    idx0, idx1 = np.where(dx == 0)
    dx[idx0, idx1] = eps
    idx0, idx1 = np.where(dy == 0)
    dy[idx0, idx1] = eps

    kernelX = interp_wts[None, :] / dx
    kernelY = interp_wts[None, :] / dy
    kernel = (kernelX[:, None, :] * kernelY[:, :, None]).reshape(
        (-1, fnc_vals.shape[1])
    )
    return (
        np.sum(kernel[None, :] * fnc_vals[:, None, :], axis=2)
        / np.sum(kernel, axis=1)[None, :]
    )
```

```
nobs = 100
zoomx = np.array([-1, 1])
zoomy = np.array([-1, 1])
xs = np.linspace(*zoomx, nobs)
ys = np.linspace(*zoomy, nobs)
obsx, obsy = np.meshgrid(xs, ys)
obsx_flat = obsx.flatten()
obsy_flat = obsy.flatten()
obs2d = np.array([obsx_flat, obsy_flat]).T.copy()

nI = 90
Ix, Iwts = cheblob(nI)
Ipts, Iwts2d = tensor_product(Ix, Iwts)
```

```
F = ethridge_laplacian_fnc(Ipts[:, 0], Ipts[:, 1])
F_interp = barycentric_tensor_product(obsx_flat, obsy_flat, Ix, Iwts, np.array([F]))
F_interp2d = F_interp.reshape(obsx.shape)
F_correct = ethridge_laplacian_fnc(obsx_flat, obsy_flat).reshape(obsx.shape)
```

```
plt.figure(figsize=(8.5,8.5))
plt.subplot(2,2,1)
levels = np.linspace(np.min(F_correct), np.max(F_correct), 7)
cntf = plt.contourf(
    Ipts[:, 0].reshape((nI, nI)),
    Ipts[:, 1].reshape((nI, nI)),
    F.reshape((nI, nI)),
    levels=levels,
    extend="both",
)
plt.contour(
    Ipts[:, 0].reshape((nI, nI)),
    Ipts[:, 1].reshape((nI, nI)),
    F.reshape((nI, nI)),
    colors="k",
    linestyles="-",
    linewidths=0.5,
    levels=levels,
    extend="both",
)
plt.plot(Ipts[:,0], Ipts[:,1], 'ro', markersize=0.5)
plt.colorbar(cntf)
plt.xlim(zoomx)
plt.ylim(zoomy)

plt.subplot(2,2,2)
levels = np.linspace(np.min(F_correct), np.max(F_correct), 7)
cntf = plt.contourf(obsx, obsy, F_interp2d, levels=levels, extend="both")
plt.contour(
    obsx,
    obsy,
    F_interp2d,
    colors="k",
    linestyles="-",
    linewidths=0.5,
    levels=levels,
    extend="both",
)
plt.colorbar(cntf)
plt.xlim(zoomx)
plt.ylim(zoomy)

plt.subplot(2,2,3)
levels = np.linspace(np.min(F_correct), np.max(F_correct), 7)
cntf = plt.contourf(obsx, obsy, F_correct, levels=levels, extend="both")
plt.contour(
    obsx,
    obsy,
    F_correct,
    colors="k",
    linestyles="-",
    linewidths=0.5,
    levels=levels,
    extend="both",
)
plt.colorbar(cntf)
plt.xlim(zoomx)
plt.ylim(zoomy)

plt.subplot(2, 2, 4)
levels = np.linspace(-5, 1, 7)
err = np.log10(np.abs(F_correct - F_interp2d)) / np.log10(np.mean(np.abs(F_interp2d)))
cntf = plt.contourf(obsx, obsy, err, levels=levels, extend="both")
plt.contour(
    obsx,
    obsy,
    err,
    colors="k",
    linestyles="-",
    linewidths=0.5,
    levels=levels,
    extend="both",
)
plt.colorbar(cntf)
plt.xlim(zoomx)
plt.ylim(zoomy)
plt.show()
```

## A box code

```
q1 = clencurt_2d(4)
q2 = clencurt_2d(7)
interp1 = cheblob(4)
interp2 = cheblob(7)
```

```
import matplotlib.patches as patches
from typing import List
from dataclasses import dataclass

@dataclass()
class TreeLevel:
    fhigh: np.ndarray
    centers: np.ndarray
    sizes: np.ndarray
    parents: np.ndarray
    is_leaf: np.ndarray

@dataclass()
class Tree:
    levels: List[TreeLevel]
    leaves: TreeLevel

def build_box_tree(f, start_centers, start_sizes, max_levels, tol):
    parents = np.zeros(start_centers.shape[0])
    centers = start_centers
    sizes = start_sizes
    levels = []
    for i in range(max_levels):
        box_low_pts = q1[0][None, :] * 0.5 * sizes[:, None, None] + centers[:, None, :]
        box_high_pts = q2[0][None, :] * 0.5 * sizes[:, None, None] + centers[:, None, :]
        box_quad_wts = q2[1][None, :] * 0.25 * sizes[:, None] ** 2

        f_high = f(
            box_high_pts[:, :, 0].ravel(), box_high_pts[:, :, 1].ravel()
        ).reshape((box_low_pts.shape[0], interp2[0].shape[0], interp2[0].shape[0]))

        # Because the Chebyshev Lobatto/Clenshaw Curtis points are nested, a 2N - 1 point
        # rule contains an N point rule inside it.
        f_low = f_high[:, ::2, ::2]

        f_high_flat = f_high.reshape((centers.shape[0], -1))
        f_low_flat = f_low.reshape((centers.shape[0], -1))

        # Interpolate to get an error estimate. Note that this error estimate will be
        # very conservative because it's estimating the error in the N low accuracy points
        # but we will end up using the 2N - 1 high accuracy points.
        f_high_interp = barycentric_tensor_product(
            q2[0][:, 0], q2[0][:, 1], interp1[0], interp1[1], f_low_flat
        )
        err = np.sqrt(np.sum((f_high_interp - f_high_flat) ** 2 * box_quad_wts, axis=1))

        # Don't refine if we're at the last level.
        if i == max_levels - 1:
            refine_boxes = np.array([], dtype=np.int64)
        else:
            refine_boxes = np.where(err > tol)[0]

        is_leaf = np.ones(centers.shape[0], dtype=bool)
        is_leaf[refine_boxes] = False

        levels.append(TreeLevel(f_high_flat, centers, sizes, parents, is_leaf))
        if refine_boxes.shape[0] == 0:
            break

        refine_centers = centers[refine_boxes]
        bump = sizes[refine_boxes] / 4

        parents = np.repeat(np.arange(centers.shape[0])[refine_boxes], 4)
        centers = np.concatenate(
            [
                refine_centers + np.array([bump, bump]).T,
                refine_centers + np.array([-bump, bump]).T,
                refine_centers + np.array([bump, -bump]).T,
                refine_centers + np.array([-bump, -bump]).T,
            ]
        )
        sizes = np.repeat(sizes[refine_boxes] / 2, 4, axis=0)

    return calculate_leaves(Tree(levels=levels, leaves=None))

def calculate_leaves(tree):
    leaves = []
    for i in range(len(tree.levels)):
        L = tree.levels[i]
        leaves.append((L.fhigh[L.is_leaf], L.centers[L.is_leaf], L.sizes[L.is_leaf], L.parents[L.is_leaf]))

    leaf_data = [
        np.concatenate([L[i] for L in leaves])
        for i in range(4)
    ]
    leaves = TreeLevel(*leaf_data, np.ones(leaf_data[0].shape[0], dtype=bool))
    return Tree(levels = tree.levels, leaves=leaves)
```

```
%%time
ethridge_tree = build_box_tree(ethridge_laplacian_fnc, np.array([[0, 0]]), np.array([1]), 10, 0.01)
```

```
ethridge_tree.leaves.centers.shape
```

```
def plot_level(L):
    for i in range(L.centers.shape[0]):
        c = L.centers[i]
        s = L.sizes[i]
        plt.gca().add_patch(
            patches.Rectangle(
                (c[0] - s / 2, c[1] - s / 2),
                s,
                s,
                edgecolor="k",
                facecolor="none",
            )
        )
plot_level(ethridge_tree.leaves)
plt.xlim([-0.55, 0.55])
plt.ylim([-0.55, 0.55])
plt.show()
```

```
box_quad_wts = (
    q2[1][None, :] * 0.25 * ethridge_tree.leaves.sizes[:, None] ** 2
)
np.sum(box_quad_wts.ravel())
```

```
test_tree = build_box_tree(lambda x, y: np.cos(10 * (x + y)), np.array([[0, 0]]), np.array([1]), 15, 0.000001)

box_quad_wts = (
    q2[1][None, :] * 0.25 * test_tree.leaves.sizes[:, None] ** 2
)

correct = 0.0367814305815291
est = np.sum(test_tree.leaves.fhigh.ravel() * box_quad_wts.ravel())
est, est - correct
```

## Volumetric Green's function integrals

```
def naive_eval(tree, obs_pts):
    box_high_pts = (
        q2[0][None, :] * 0.5 * tree.leaves.sizes[:, None, None] + tree.leaves.centers[:, None, :]
    )
    box_quad_wts = (
        q2[1][None, :] * 0.25 * tree.leaves.sizes[:, None] ** 2
    )

    G = fundamental_soln_matrix(obs_pts, box_high_pts.reshape((-1, 2)))[:, 0, :]
    out = (G * box_quad_wts.ravel()[None,:]).dot(tree.leaves.fhigh.ravel())
    return out
```

```
np.random.seed(2)
obs_test = (np.random.rand(1000, 2) - 0.5) * 0.2
est_rand = naive_eval(ethridge_tree, obs_test)
soln_rand = ethridge_soln_fnc(obs_test[:,0], obs_test[:,1])
err = np.max(np.abs(est_rand - soln_rand)) / np.max(np.abs(soln_rand))
print(err)
```

```
est_soln = naive_eval(ethridge_tree, obs2d).reshape(obsx.shape)
soln = ethridge_soln_fnc(obsx_flat, obsy_flat).reshape(obsx.shape)
```

```
plt.figure(figsize=(8, 4))
plt.subplot(1,2,1)
levels = np.linspace(np.min(soln), np.max(soln), 7)
cntf = plt.contourf(obsx, obsy, est_soln, levels=levels, extend="both")
plt.contour(
    obsx,
    obsy,
    est_soln,
    colors="k",
    linestyles="-",
    linewidths=0.5,
    levels=levels,
    extend="both",
)
plt.colorbar(cntf)
plt.xlim(zoomx)
plt.ylim(zoomy)

plt.subplot(1,2,2)
levels = np.linspace(np.min(soln), np.max(soln), 7)
cntf = plt.contourf(obsx, obsy, soln, levels=levels, extend="both")
plt.contour(
    obsx,
    obsy,
    soln,
    colors="k",
    linestyles="-",
    linewidths=0.5,
    levels=levels,
    extend="both",
)
plt.colorbar(cntf)
plt.xlim(zoomx)
plt.ylim(zoomy)
plt.tight_layout()
plt.show()

levels = np.linspace(-7, -2, 6)
err = np.log10(np.abs(est_soln - soln))
cntf = plt.contourf(obsx, obsy, err, levels=levels, extend="both")
plt.contour(
    obsx,
    obsy,
    err,
    colors="k",
    linestyles="-",
    linewidths=0.5,
    levels=levels,
    extend="both",
)
plt.colorbar(cntf)
plt.xlim(zoomx)
plt.ylim(zoomy)
plt.tight_layout()
plt.show()
```

## The 2:1 balance condition

```
def test_f(x, y):
    return (y > 0.15) * 1.0
discontinuity_tree = build_box_tree(test_f, np.array([[0, 0]]), np.array([1]), 6, 0.001)
plot_level(discontinuity_tree.leaves)
plt.xlim([-0.55, 0.55])
plt.ylim([-0.55, 0.55])
plt.axis('equal')
plt.show()
```

```
def balance_21(tree, f, plot_progress=False):
    i = 0
    while i <= len(tree.levels) - 3:
        # We can skip splitting the level above because those cells all satisfy the
        # 2:1 criteria already
        did_refine = False
        for j in range(i + 2, len(tree.levels)):
            big_L = tree.levels[i]
            small_L = tree.levels[j]
            dx = small_L.centers[:,None,0] - big_L.centers[None,:,0]
            dy = small_L.centers[:,None,1] - big_L.centers[None,:,1]

            closex = np.abs(dx) <= 0.5 * (small_L.sizes[:,None] + big_L.sizes[None,:])
            closey = np.abs(dy) <= 0.5 * (small_L.sizes[:,None] + big_L.sizes[None,:])

            refine_boxes = np.any(closex & closey, axis=0) & big_L.is_leaf
            refine_centers = big_L.centers[refine_boxes]
            if refine_centers.shape[0] == 0:
                continue

            bump = big_L.sizes[refine_boxes] / 4

            parents = np.repeat(np.arange(big_L.centers.shape[0])[refine_boxes], 4)
            centers = np.concatenate(
                [
                    refine_centers + np.array([bump, bump]).T,
                    refine_centers + np.array([-bump, bump]).T,
                    refine_centers + np.array([bump, -bump]).T,
                    refine_centers + np.array([-bump, -bump]).T,
                ]
            )
            sizes = np.repeat(big_L.sizes[refine_boxes] / 2, 4, axis=0)

            box_high_pts = q2[0][None, :] * 0.5 * sizes[:, None, None] + centers[:, None, :]
            box_quad_wts = q2[1][None, :] * 0.25 * sizes[:, None] ** 2

            # TODO: either interpolate here or re-use the original function.
            f_high = f(
                box_high_pts[:, :, 0].ravel(), box_high_pts[:, :, 1].ravel()
            ).reshape((centers.shape[0], -1))
            # TODO: at the end, re-calculate leaves.

            child_L = tree.levels[i+1]
            big_L.is_leaf[refine_boxes] = False
            tree.levels[i+1] = TreeLevel(
                np.concatenate([child_L.fhigh, f_high]),
                np.concatenate([child_L.centers, centers]),
                np.concatenate([child_L.sizes, sizes]),
                np.concatenate([child_L.parents, parents]),
                np.concatenate([child_L.is_leaf, np.ones(centers.shape[0], dtype=bool)])
            )
            did_refine = True
            break

        if did_refine:
            if plot_progress:
                print(f'Splitting {refine_centers.shape[0]} boxes in level {i}')
                tree = calculate_leaves(tree)
                plot_level(tree.leaves)
                plt.xlim([-0.55, 0.55])
                plt.ylim([-0.55, 0.55])
                plt.axis('equal')
                plt.show()
            # If cells were refined in this level, then there may be cells in
            # the parent's level that now need to be refined, so we back up a step to
            # check.
            i -= 1
        else:
            # If no cells were refined, then proceed down the tree to the next level
            i += 1
```

```
balance_21(discontinuity_tree, test_f, plot_progress=False)
```

```
discontinuity_tree = calculate_leaves(discontinuity_tree)

plot_level(discontinuity_tree.leaves)
plt.xlim([-0.55, 0.55])
plt.ylim([-0.55, 0.55])
plt.axis('equal')
plt.show()
```

```
import copy
balanced_tree = copy.deepcopy(ethridge_tree)
balance_21(balanced_tree, ethridge_laplacian_fnc)
balanced_tree = calculate_leaves(balanced_tree)
```

```
plt.figure(figsize=(8,4))
plt.subplot(1, 2, 1)
plot_level(ethridge_tree.leaves)
plt.xlim([-0.55, 0.55])
plt.ylim([-0.55, 0.55])
plt.axis('equal')

plt.subplot(1, 2, 2)
plot_level(balanced_tree.leaves)
for i in range(balanced_tree.leaves.centers.shape[0]):
    c = balanced_tree.leaves.centers[i]
    s = balanced_tree.leaves.sizes[i]
    if np.any(np.linalg.norm(c[None,:] - ethridge_tree.leaves.centers, axis=1) == 0):
        continue
    plt.gca().add_patch(
        patches.Rectangle(
            (c[0] - s / 2, c[1] - s / 2),
            s,
            s,
            linewidth=2,
            edgecolor="r",
            facecolor="none",
        )
    )
plt.xlim([-0.55, 0.55])
plt.ylim([-0.55, 0.55])
plt.axis('equal')
plt.tight_layout()
plt.show()
```

```
np.random.seed(2)
obs_test = (np.random.rand(1000, 2) - 0.5) * 0.2
est_rand = naive_eval(balanced_tree, obs_test)
soln_rand = ethridge_soln_fnc(obs_test[:,0], obs_test[:,1])
err = np.max(np.abs(est_rand - soln_rand)) / np.max(np.abs(soln_rand))
err
```

```
est_soln_balanced = naive_eval(balanced_tree, obs2d).reshape(obsx.shape)
```

```
np.max(np.abs(est_soln - est_soln_balanced))
```

## Using precomputed near-field integrals

```
boxes = {
    # Type 0 (coincident)
    (1, 0, 0): (0, 1, 1, 0),
    # Type 1
    (1, 2, 2): (1, 1, 1, 0),
    (1, -2, 2): (1, -1, 1, 0),
    (1, -2, -2): (1, -1, -1, 0),
    (1, 2, -2): (1, 1, -1, 0),
    # Type 2
    (1, 2, 0): (2, 1, 1, 0),
    (1, 0, 2): (2, 1, 1, 1),
    (1, -2, 0): (2, -1, 1, 0),
    (1, 0, -2): (2, 1, -1, 1),
    # Type 3
    (2, 3, 3): (3, 1, 1, 0),
    (2, -3, 3): (3, -1, 1, 0),
    (2, -3, -3): (3, -1, -1, 0),
    (2, 3, -3): (3, 1, -1, 0),
    # Type 4
    (2, 1, 3): (4, 1, 1, 1),
    (2, -1, 3): (4, -1, 1, 1),
    (2, -3, 1): (4, -1, 1, 0),
    (2, -3, -1): (4, -1, -1, 0),
    (2, -1, -3): (4, -1, -1, 1),
    (2, 1, -3): (4, 1, -1, 1),
    (2, 3, -1): (4, 1, -1, 0),
    (2, 3, 1): (4, 1, 1, 0),
    # Type 5
    (0.5, 1.5, 1.5): (5, 1, 1, 0),
    (0.5, -1.5, 1.5): (5, -1, 1, 0),
    (0.5, -1.5, -1.5): (5, -1, -1, 0),
    (0.5, 1.5, -1.5): (5, 1, -1, 0),
    # Type 6
    (0.5, 0.5, 1.5): (6, 1, 1, 1),
    (0.5, -0.5, 1.5): (6, -1, 1, 1),
    (0.5, -1.5, 0.5): (6, -1, 1, 0),
    (0.5, -1.5, -0.5): (6, -1, -1, 0),
    (0.5, -0.5, -1.5): (6, -1, -1, 1),
    (0.5, 0.5, -1.5): (6, 1, -1, 1),
    (0.5, 1.5, -0.5): (6, 1, -1, 0),
    (0.5, 1.5, 0.5): (6, 1, 1, 0),
}
```

```
def nearfield_box(I, Fv, flipx, flipy, rotxy):
    # NOTE: The transpose is necessary simply because the definition of the basis functions here is the transpose of the definition in precompute. I should fix this!
    N = int(np.sqrt(Fv.shape[0]))
    Fv = Fv.reshape((N,N)).T

    n_rot = {
        (1, 1): 0,
        (1, -1): 1,
        (-1, -1): 2,
        (-1, 1): 3
    }[(flipx, flipy)]
    n_transpose = ((n_rot % 2) == 1) + rotxy

    # Rotate from input coordinates into position
    Fv = np.rot90(Fv, n_rot)
    if n_transpose % 2 == 1:
        Fv = Fv.T

    est = I.dot(Fv.ravel())

    # Reverse the transformation back to the original input space
    if n_transpose % 2 == 1:
        est = est.T
    est = np.rot90(est, -n_rot)
    return est

def scale_integral(I, basis_dot_F, src_s):
    scale_T = src_s / 2.0
    C = scale_T ** 2
    log_factor = C * (1 / (2 * np.pi)) * np.log(scale_T)
    return C * I + log_factor * basis_dot_F
```

```
basis_integrals, nearfield_integrals = np.load("data/nearfield_integrals.npy", allow_pickle=True)
```

```
nearfield_integrals.shape
```

```
%%time
new_tree = build_box_tree(ethridge_laplacian_fnc, np.array([[0, 0]]), np.array([1]), 50, 10.0)
print(f'built tree with {new_tree.leaves.centers.shape[0] * q2[0].shape[0]} degrees of freedom, {new_tree.leaves.centers.shape[0]} leaves, and {len(new_tree.levels)} levels')

balance_21(new_tree, ethridge_laplacian_fnc)
new_tree = calculate_leaves(new_tree)
print(f'balanced tree has {new_tree.leaves.centers.shape[0]} leaves')
```

```
plot_level(new_tree.leaves)
plt.xlim([-0.55, 0.55])
plt.ylim([-0.55, 0.55])
plt.axis('equal')
plt.show()
```

```
%%time
box_high_pts = (
    q2[0][None, :] * 0.5 * new_tree.leaves.sizes[:, None, None] + new_tree.leaves.centers[:, None, :]
)
box_quad_wts = (
    q2[1][None, :] * 0.25 * new_tree.leaves.sizes[:, None] ** 2
)

obs_test = np.array([0.05,0.05])
sep = obs_test[None,:] - new_tree.leaves.centers
obs_i = np.where(np.all(np.abs(sep) <= new_tree.leaves.sizes[:, None] / 2, axis=1))[0][0]

obs_c = new_tree.leaves.centers[obs_i]
obs_s = new_tree.leaves.sizes[obs_i]
print(f'point contained in box {i}\n  with center: {obs_c}\n  and size: {obs_s}')
obs_pts = q2[0] * 0.5 * obs_s + obs_c
print(obs_c, obs_s)

src_cs = new_tree.leaves.centers
src_ss = new_tree.leaves.sizes
transformed_obs_center = np.round(2 * (obs_c[None,:] - src_cs) / src_ss[:,None], decimals=1)
transformed_obs_size = np.round(obs_s / src_ss, decimals=1)
```

```
%%time
G = (
    fundamental_soln_matrix(obs_pts, box_high_pts.reshape((-1, 2)))[:, 0, :]
    * box_quad_wts.ravel()[None,:]
).reshape((q2[0].shape[0], src_cs.shape[0], q2[0].shape[0]))
```

```
%%time
src_terms = np.sum(G * new_tree.leaves.fhigh[None,:], axis=2)
```

```
%%time
nearfield = 0
add = 0
for j in range(src_cs.shape[0]):
    nearfield_info = boxes.get((transformed_obs_size[j], *transformed_obs_center[j]), None)
    if nearfield_info is not None:
        F = new_tree.leaves.fhigh[j]
        integral_type, flipx, flipy, rotxy = nearfield_info
        I = nearfield_box(nearfield_integrals[integral_type], F, flipx, flipy, rotxy)
        basis_I = basis_integrals.ravel().dot(F.ravel())
        scaled_I = scale_integral(I, basis_I, src_ss[j])

        src_terms[:, j] = scaled_I.ravel()
        nearfield += 1
```

```
add, correct_add
```

```
%%time
result = np.sum(src_terms, axis=1) + add

print(f'there were {nearfield} nearfield cells')

correct_cell = ethridge_soln_fnc(obs_pts[:,0], obs_pts[:,1])
correct_pt = ethridge_soln_fnc(obs_test[0], obs_test[1])

obs_test_transformed = (obs_test - obs_c) / (obs_s * 0.5)
result_pt = barycentric_tensor_product(obs_test_transformed[:1], obs_test_transformed[1:], interp2[0], interp2[1], np.array([result]))[0,0]
result_naive = naive_eval(new_tree, np.array([obs_test]))[0]
print(correct_pt, result_pt)
print(f'precompute interpolate at {obs_test} error is: {correct_pt - result_pt:.3e}')
print(f'naive eval at {obs_test} error is: {correct_pt - result_naive:.3e}')
```

## Check that tree integrates a simple function well.

#### Scratch

There are two sources of error here:
1. The integration error for the values of the integrals at the interpolation points.
2. The interpolation error for the values of the integrals away from the interpolation points.

Based on the previous demonstration, the first type of error is almost zero. The red points in the error figure above show the location of the interpolation points. You can see that the error oscillates around these points crossing zero at the points themselves.

```
# obs_test_transformed = (obs_test - obs_c) / (obs_s * 0.5)

# Sprecompute = barycentric_tensor_product(obs_test_transformed[:1], obs_test_transformed[1:], interp2[0], interp2[1], np.array([result]))[0,0]

# correct = ethridge_soln_fnc(obs_test[0], obs_test[1])

# Snaive = naive_eval(balanced_tree, np.array([obs_test]))[0]

# Sprecompute, correct, np.linalg.norm(Sprecompute - correct) / np.linalg.norm(correct)

# Snaive, correct, np.linalg.norm(Snaive - correct) / np.linalg.norm(correct)
```

```
# box_high_pts = (
#     q2[0][None, :] * 0.5 * new_tree.leaves.sizes[:, None, None] + new_tree.leaves.centers[:, None, :]
# )
# box_quad_wts = (
#     q2[1][None, :] * 0.25 * new_tree.leaves.sizes[:, None] ** 2
# )

# obs_test = np.array([0.01,0.01])
# for i in range(new_tree.leaves.centers.shape[0]):
#     c = new_tree.leaves.centers[i]
#     s = new_tree.leaves.sizes[i]
#     sep = obs_test - c
#     if np.all(np.abs(sep) <= s / 2):
#         print(f'point contained in box {i}\n  with center: {c}\n  and size: {s}')
#         obs_i = i

# result = np.zeros((new_tree.leaves.fhigh.shape[1]))
# obs_c = new_tree.leaves.centers[obs_i]
# obs_s = new_tree.leaves.sizes[obs_i]
# obs_pts = q2[0] * 0.5 * obs_s + obs_c
# print(obs_c, obs_s)

# nearfield = 0
# for j in range(new_tree.leaves.centers.shape[0]):
#     if j % 10000 == 0:
#         print(f'starting cell {j}')
#     src_c = new_tree.leaves.centers[j]
#     src_s = new_tree.leaves.sizes[j]
#     F = new_tree.leaves.fhigh[j]
#     transformed_obs_center = np.round(2 * (obs_c - src_c) / src_s, decimals=1)
#     transformed_obs_size = np.round(obs_s / src_s, decimals=1)

#     nearfield_info = boxes.get((transformed_obs_size, *transformed_obs_center), None)

#     if nearfield_info is not None:
#         integral_type, flipx, flipy, rotxy = nearfield_info
#         I = nearfield_box(nearfield_integrals[integral_type], F, flipx, flipy, rotxy)
#         basis_I = basis_integrals.ravel().dot(F.ravel())
#         scaled_I = scale_integral(I, basis_I, src_s)

#         result += scaled_I.ravel()
#         nearfield += 1
#     else:
#         sep = obs_c - src_c
#         assert(not np.all(np.abs(sep) <= 0.5 * (obs_s + src_s)))
#         G = (
#             fundamental_soln_matrix(obs_pts, box_high_pts[j])[:, 0, :]
#             * box_quad_wts[j].ravel()
#         )
#         result += G.dot(F)

# print(f'there were {nearfield} nearfield cells')

# correct_cell = ethridge_soln_fnc(obs_pts[:,0], obs_pts[:,1])
# correct_pt = ethridge_soln_fnc(obs_test[0], obs_test[1])

# obs_test_transformed = (obs_test - obs_c) / (obs_s * 0.5)
# result_pt = barycentric_tensor_product(obs_test_transformed[:1], obs_test_transformed[1:], interp2[0], interp2[1], np.array([result]))[0,0]

# result_naive_pt = naive_eval(new_tree, obs_test[None,:])[0]
# print('cell errors: ', correct_cell - result)
# print(f'precompute interpolate at {obs_test} error is: {correct_pt - result_pt:.3e}')
# print(f'naive compute at {obs_test} error is {correct_pt - result_naive_pt:.3e}')
```


# Programación Evolutiva

La librería **Pyristic** incluye una clase llamada `EvolutionaryProgramming` inspirada en la metaheurística de *Programación Evolutiva* (PE) para resolver problemas de minimización. Para trabajar con esta clase se requiere hacer lo siguiente:

1. Definir:
    * La función objetivo $f$.
    * La lista de restricciones.
    * Lista de límites inferiores y superiores.
    * Configuración de operadores de la metaheurística (opcional).

2. Crear una clase que hereda de `EvolutionaryProgramming`.

3. Sobreescribir las siguientes funciones:

    * mutation_operator (opcional)
    * adaptive_mutation (opcional)
    * survivor_selection (opcional)
    * initialize_step_weights (opcional)
    * initialize_population (opcional)
    * fixer (opcional)

A continuación se muestran los elementos que se deben importar.

### Librerías externas

```python
from pprint import pprint
import math
import numpy as np
import copy
```

```python
from IPython.display import Image
from IPython.core.display import HTML
```

### Componentes de `pyristic`
La estructura que está organizada la librería es:
 * Las metaheurísticas están ubicadas en `heuristic`.
 * Las funciones de prueba están ubicadas en `utils.test_function`.
 * Las clases auxiliares para mantener la información de los operadores que serán empleados para alguna de las metaheurísticas basadas en los paradigmas del cómputo evolutivo están ubicadas en `utils.helpers`.
 * Las metaheurísticas basadas en los paradigmas del cómputo evolutivo dependen de un conjunto de operadores (selección, mutación y cruza). Estos operadores están ubicados en `utils.operators`.

Para demostrar el uso de nuestra metaheurística basada en *algoritmos geneticos* tenemos que importar la clase llamada `Genetic` que se encuentra en `heuristic.GeneticAlgorithm_search`.

```python
from pyristic.heuristic.EvolutiveProgramming_search import EvolutionaryProgramming
from pyristic.utils.helpers import EvolutionaryProgrammingConfig, get_stats, ContinuosFixer
from pyristic.utils.test_function import beale_, ackley_
from pyristic.utils.operators import mutation, selection
```

## Función de Beale
\begin{equation}
  \label{eq:BF}
  \begin{array}{rll}
  \text{minimizar:} & f(x_1, x_2) = (1.5 - x_1 + x_1x_2)^2 + (2.25 - x_1 + x_1x_2^2)^2 + (2.625 - x_1 + x_1x_2^3)^2
  &  \\
  \text{tal que: } & -4.5 \leq x_1,x_2 \leq 4.5 &
  \end{array}
\end{equation}

El mínimo global se encuentra en $x^* = (3, 0.5)$ y $f(x^*) = 0$.

```python
Image(filename="include/beale.png", width=500, height=300)
```

Para inicializar un objeto de la clase `EvolutionaryProgramming`, es necesario implementar los siguientes elementos.

#### Función objetivo

```python
def f(x : np.ndarray) -> float:
    a = (1.5 - x[0] + x[0]*x[1])**2
    b = (2.25 - x[0] + x[0]*x[1]**2)**2
    c = (2.625 - x[0] + x[0]*x[1]**3)**2
    return a+b+c
```

#### Restricciones
Las restricciones se definen como una lista de funciones que retornan valores booleanos, estos valores permiten revisar si una solución es factible o no. En el caso de la función de Beale, solo se tienen restricciones de caja.

```python
def is_feasible(x : np.ndarray) -> bool:
    for i in range(len(x)):
        if -4.5>x[i] or x[i] > 4.5:
            return False
    is_feasible.__doc__="x1: -4.5 <= {:.2f} <= 4.5 \n x2: -4.5 <= {:.2f} <= 4.5".format(x[0],x[1])

    return True

constraints_ = [is_feasible]
```

#### Límites de las variables del problema
Los límites de las variables de decisión son establecidos en una matriz (lista de listas) de tamaño (2 x $n$), donde $n$ es el número de variables. La primera fila contiene los límites inferiores de cada una de las variables de decisión y la segunda fila los límites superiores.

```python
lower_bound = [-4.5,-4.5]
upper_bound = [4.5, 4.5]
bounds = [lower_bound, upper_bound]
```

*Nota:* En el caso de que todas las variables de decisión se encuentren en el mismo rango de búsqueda, se puede utilizar una única lista con dos valores numéricos, donde, el primer valor representa el límite inferior y el segundo el límite superior.

Por ejemplo, la función de Beale es una función de dos variables ($x_{1}, x_{2}$). La dos variables están acotadas en el mismo espacio de búsqueda $ -4.5 < x_{i} < 4.5, i \in [1,2]$, entonces, en lugar de emplear la representación descrita antes, podemos sustituirla por:

``` python
bounds = [-4.5, 4.5]
```

En esta representación, la primera componente se refiere al límite inferior, mientras, la segunda componente es el límite superior. Este arreglo será interpretado como el espacio de búsqueda para todas las variables de decisión.

La librería **Pyristic** tiene implementados algunos problemas de prueba en `utils.helpers.test_function`, entre ellos la función de Beale. Los problemas de prueba están definidos como diccionarios con las siguientes llaves:

 * `function.` Función objetivo.
 * `contraints.` Restricciones del problema, lista con al menos una función que regresa un valor booleano.
 * `bounds.` Límites para cada una de las variables del problema. En el caso de que todas las variables del problema se encuentren en el mismo intervalo de búsqueda, se puede emplear una lista con dos valores numéricos.
 * `decision_variables.` Número de variables de decisión.

```python
beale_
```

    {'function': CPUDispatcher(<function beale_function at 0x7fb8ab775d40>),
     'constraints': [<function pyristic.utils.test_function.constraint1_beale(X: numpy.ndarray) -> bool>],
     'bounds': [-4.5, 4.5],
     'decision_variables': 2}

### Declaración de `EvolutionaryProgramming`

La metaheurística de PE implementada en la librería **Pyristic** se puede utilizar de las siguientes maneras:

* Crear una clase que herede de la clase `EvolutionaryProgramming` y sobreescribir las funciones antes mencionadas.
* Declarar un objeto del tipo `EvolutionaryProgrammingConfig` y ajustar los operadores.
* Realizar una combinación de las dos anteriores.

Es importante resaltar que se puede hacer uso de `EvolutionaryProgramming` sin modificar los operadores que tiene por defecto.

### Ejecución de la metaheurística
Como mencionamos antes, una forma de utilizar la metaheurística es hacer una instancia de la clase `EvolutionProgramming` dejando su configuración por defecto.

Los argumentos que se deben indicar al momento de inicializar son:
 * función objetivo
 * restricciones del problema
 * límites inferior y superior (por cada variable de decisión)
 * número de variables que tiene el problema.

```python
Beale_optimizer = EvolutionaryProgramming(**beale_)
```

Recordemos que `beale_` es un diccionario con la información requerida por el constructor de la clase `EvolutionaryProgramming`.

Finalmente, se llama a la función `optimize`. Recibe los siguientes parámetros:

* **generations**. Número de generaciones (iteraciones de la metaheurística).
* **size_population**. Tamaño de la población (número de individuos).
* **verbose**. Muestra en qué iteración se encuentra nuestra búsqueda, por defecto está en True.
* **\**kwargs**. Argumentos externos a la búsqueda.

Para resolver la función de Beale utilizaremos los siguientes parámetros:
* **generations** = $200$
* **size_population** = $100$
* **verbose** = True.

```python
Beale_optimizer.optimize(200,100)
```

    100%|██████████| 200/200 [00:00<00:00, 237.43it/s]

```python
print(Beale_optimizer)
```

    Evolutive Programming search:
     f(X) = 3.232480818659537e-30
     X = [3.  0.5]
     Constraints:
     x1: -4.5 <= 3.00 <= 4.5
     x2: -4.5 <= 0.50 <= 4.5

Para revisar el comportamiento de la metaheurística en determinado problema, la librería **Pyristic** cuenta con una función llamada `get_stats`. Esta función se encuentra en `utils.helpers` y recibe como parámetros:

* Objeto que realiza la búsqueda de soluciones.
* El número de veces que se quiere ejecutar la metaheurística.
* Los argumentos que recibe la función `optimize` (debe ser una tupla).
* Argumentos adicionales a la búsqueda, estos argumentos deben estar contenidos en un diccionario (opcional).

La función `get_stats` retorna un diccionario con algunas estadísticas de las ejecuciones.

```python
args = (700, 100, False)
statistics = get_stats(Beale_optimizer, 21, args)
```

```python
pprint(statistics)
```

    {'Best solution': {'f': 0.0, 'x': array([3. , 0.5])},
     'Mean': 0.0,
     'Median': 0.0,
     'Standard deviation': 0.0,
     'Worst solution': {'f': 0.0, 'x': array([3. , 0.5])}}

## Función de Ackley

\begin{equation}
  \min f(\vec{x}) = -20\exp \left( -0.2 \sqrt{\frac{1}{n} \sum_{i=1}^n x_i^2} \right)
  - exp \left( \frac{1}{n} \sum_{i=1}^n \cos (2\pi x_i) \right)
  + 20 + e
\end{equation}

El mínimo global está en $x^* = 0 $ y $f(\vec{x}) = 0$ y su dominio es $|x_{i}| < 30$.

```python
Image(filename="include/ackley.jpg", width=500, height=300)
```

```python
ackley_
```

    {'function': CPUDispatcher(<function ackley_function at 0x7fb8aa53fa70>),
     'constraints': [<function pyristic.utils.test_function.constraint1_ackley(X: numpy.ndarray) -> bool>],
     'bounds': [-30.0, 30.0],
     'decision_variables': 10}

En el caso de la función de Ackley, al no estár con restricción de variables de decisión, podemos modificar el número de variables de decisión que tiene por defecto de la siguiente manera:

```python
ackley_['decision_variable'] = 5
```

Para resolver la función de Ackley con 10 variables de decisión utilizaremos los siguientes parámetros:
* **generations** = $500$
* **size_population** = $100$

```python
Optimizer_by_default = EvolutionaryProgramming(**ackley_)
```

```python
Optimizer_by_default.optimize(500,100)
```

    100%|██████████| 500/500 [00:00<00:00, 580.97it/s]

```python
print(Optimizer_by_default)
```

    Evolutive Programming search:
     f(X) = 11.4904039400463
     X = [-0.99474072 -9.9342271  -7.21989415 -0.99477073 -0.2458009   0.03781271
     -0.09230842 -0.06651247 -0.99476553 -1.98318824]
     Constraints:
     x1: -30 <= -0.99 <= 30
     x2: -30 <= -9.93 <= 30
     x3: -30 <= -7.22 <= 30
     x4: -30 <= -0.99 <= 30
     x5: -30 <= -0.25 <= 30
     x6: -30 <= 0.04 <= 30
     x7: -30 <= -0.09 <= 30
     x8: -30 <= -0.07 <= 30
     x9: -30 <= -0.99 <= 30
     x10: -30 <= -1.98 <= 30

La solución encontrada por PE no es la solución óptima. A continuación mostraremos la ejecución de la metaheurística modificando la configuración por defecto.

### Declaración de `EvolutionaryProgramming` por configuración

 A continuación vamos a mostrar la forma en que se declara un objeto del tipo `EvolutionaryProgrammingConfig` que es una clase auxiliar, donde, contendrá los operadores que se emplea en la ejecución de la metaheurística.

```python
configuration = (EvolutionaryProgrammingConfig()
                 .adaptive_mutation(
                     mutation.sigma_ep_adaptive_mutator(ackley_['decision_variables'], 2.0)
                     )
                 )
```

En este ejemplo mostraremos el impacto de inicializar el operador de mutación en los tamaños de paso con $\alpha = 2$, el constructor en caso de no incluir este parámetro lo inicializa con $\alpha = 0.5$.

```python
print(configuration)
```

    --------------------------------
    	Configuration
    --------------------------------
    Adaptive mutation: Sigma EP.
    		-Alpha: 2.0

    --------------------------------

```python
Optimizer_by_configuration = EvolutionaryProgramming(**ackley_, config=configuration)
```

A diferencia del ejemplo de la función de Beale, incluimos la configuración de los operadores que deseamos utilizar en la variable llamada `config` al crear un objeto de la clase `EvolutionaryProgramming`.

```python
Optimizer_by_configuration.optimize(500,100)
```

    100%|██████████| 500/500 [00:01<00:00, 480.36it/s]

```python
print(Optimizer_by_configuration)
```

    Evolutive Programming search:
     f(X) = 2.7741147055422393
     X = [-0.11764831  0.03903111 -1.51869087 -0.02020615 -0.22483818 -0.06830735
      0.02759398  0.0366477  -0.01927905  0.20827637]
     Constraints:
     x1: -30 <= -0.12 <= 30
     x2: -30 <= 0.04 <= 30
     x3: -30 <= -1.52 <= 30
     x4: -30 <= -0.02 <= 30
     x5: -30 <= -0.22 <= 30
     x6: -30 <= -0.07 <= 30
     x7: -30 <= 0.03 <= 30
     x8: -30 <= 0.04 <= 30
     x9: -30 <= -0.02 <= 30
     x10: -30 <= 0.21 <= 30

### Herencia desde *EvolutionaryProgramming*

Otra forma de utilizar la metaheurística de nuestra librería es definiendo una clase que herede de `EvolutionaryProgramming`, donde, vamos a sobreescribir el método `adaptive_mutation` y así permitir incluir distintos valores para $\alpha$.

```python
class EPAckley(EvolutionaryProgramming):
    def __init__(self, function, decision_variables, constraints, bounds):
        super().__init__(function, decision_variables, constraints, bounds)

    def adaptive_mutation(self, **kwargs):
        alpha_ = kwargs['alpha']
        return mutation.sigma_ep_adaptive(self.logger['parent_population_sigma'], alpha_)
```

```python
additional_arguments = {'alpha':2.0}
```

El diccionario que hemos definido tiene el parámetro que se utiliza en la función `adaptive_mutation`.

```python
Optimizer_by_class = EPAckley(**ackley_)
```

```python
Optimizer_by_class.optimize(500,100,**additional_arguments)
```

    100%|██████████| 500/500 [00:01<00:00, 452.98it/s]

```python
print(Optimizer_by_class)
```

    Evolutive Programming search:
     f(X) = 0.8964946432807577
     X = [ 0.04024736  0.01625345  0.05809872  0.16245586 -0.04917014  0.00387434
      0.21038493 -0.00032807 -0.1783945   0.00565523]
     Constraints:
     x1: -30 <= 0.04 <= 30
     x2: -30 <= 0.02 <= 30
     x3: -30 <= 0.06 <= 30
     x4: -30 <= 0.16 <= 30
     x5: -30 <= -0.05 <= 30
     x6: -30 <= 0.00 <= 30
     x7: -30 <= 0.21 <= 30
     x8: -30 <= -0.00 <= 30
     x9: -30 <= -0.18 <= 30
     x10: -30 <= 0.01 <= 30

### Resultados

#### Usando la configuración por defecto

```python
args = (500,100,False)
statistics = get_stats(Optimizer_by_default, 21, args)
```

```python
pprint(statistics)
```

    {'Best solution': {'f': 9.388284989726808,
                       'x': array([ 5.94366960e+00,  9.60375969e-01,  6.94754057e+00,  9.91308130e-01,
            9.07685858e-04,  9.88853141e-01, -3.54640086e-06, -1.98611046e+00,
           -1.98653365e+00,  1.15761714e+00])},
     'Mean': 15.542553098123888,
     'Median': 16.69157579384026,
     'Standard deviation': 2.473604541882901,
     'Worst solution': {'f': 18.2012233794908,
                        'x': array([ 1.29830057e+01,  7.99785858e+00,  2.99916046e+00,  8.99759287e+00,
            7.99308554e+00, -1.69951205e+01,  2.50162071e+01,  4.99860044e+00,
            1.10011888e+01,  1.72500711e-02])}}

#### Indicando la configuración que va a utilizar PE

```python
args = (500,100,False)
statistics = get_stats(Optimizer_by_configuration, 21, args)
```

```python
pprint(statistics)
```

    {'Best solution': {'f': 0.1400350206585057,
                       'x': array([-0.01219466,  0.01051069, -0.0676162 ,  0.00101741, -0.01840164,
            0.01325927, -0.03612233,  0.00881036,  0.00302312, -0.00857504])},
     'Mean': 3.6470199502783367,
     'Median': 3.338762668285544,
     'Standard deviation': 3.027445585886844,
     'Worst solution': {'f': 12.320844663835127,
                        'x': array([14.03494345, -0.99162685,  1.97668216,  0.02211279, -0.14195507,
           -2.96436191, -2.05546111, -1.03261323, -1.99745198,  0.0451257 ])}}

#### Creando una clase que hereda de *EvolutionaryProgramming*

```python
args = (500,100,False)
statistics = get_stats(Optimizer_by_class, 21, args,**additional_arguments)
```

```python
pprint(statistics)
```

    {'Best solution': {'f': 0.02572722171335018,
                       'x': array([ 0.00793316,  0.0027408 ,  0.00025037, -0.00718162,  0.00691242,
           -0.00903555, -0.00064304, -0.00212231,  0.00047024,  0.00992375])},
     'Mean': 2.830611040111844,
     'Median': 2.27070115940251,
     'Standard deviation': 2.9317848044857997,
     'Worst solution': {'f': 10.470219171345585,
                        'x': array([ 8.79145601e-01, -1.13234002e+00,  1.00392228e+01, -2.94073892e+00,
            5.27578857e-03, -1.59042164e-01,  1.98697217e+00,  8.37889607e-01,
            1.00029248e+00, -1.90451620e+00])}}

Las últimas dos estrategias utilizan la misma configuración, la única diferencia es la forma en que se han creado y los resultados obtenidos pueden variar por los números aleatorios generados.


# Tutorial 3: Synapses and Networks

## Example: Random Balanced Network

We will now simulate a network of **integrate-and-fire** (IAF), **excitatory** and **inhibitory spiking neurons** connected **randomly with fixed weights**. This network was proposed and studied in:

> [1] Brunel N (2000). Dynamics of sparsely connected networks of excitatory and inhibitory spiking neurons. Journal of Computational Neuroscience 8, 183-208.

and hence it is also called the **Brunel Network**.

The network is composed of excitatory neurons (80%) and inhibitory neurons (20%) with connection probability $\epsilon = 10\%$ (`epsilon`). All neuron types can be connected (E$\rightarrow$E, E$\rightarrow$I, I$\rightarrow$E and I$\rightarrow$I). Neurons also receive external input from oustide the network modeled by a **Poisson spike train generator** [2] with rate `nu_ex`, $\nu_{ex} = \eta * \nu_{th}$, where the **threshold rate** $\nu_{th}$ (`nu_th`) is calculated using

\begin{equation}
    \nu_{th} = \frac{\theta}{J * \text{CE} * \tau_{m}}
\end{equation}

In general terms, the threshold rate is the frequency needed to bring cells to the firing threshold, and the interspike intervals drawn are exponentially distributed. The synaptic input current on each neuron is calculated as

\begin{equation}
    I_i(t) = \tau \sum_j J_j \sum_k \delta(t-\delta-t_j^k)
\end{equation}

where the sum over $j$ covers all synapses (internal and external to the network) and the sum over $k$ covers all spikes arriving at times $t^k_j$ with delay $\delta$ (`delta`). This simple postsynaptic current model is called **delta synapse**, in which stimulation is applied in a single point in time for simplicity using the Dirac delta function. In biological systems, synaptic models are diverse and can vary within the same neuron depending on the function of the synapse, for example.

In NEST, we will create a network of `iaf_psc_delta` neurons connected using `static_synapse` with weights $J_E$ and $J_I = -g*J_E$. We will study how this network changes its behavior depending on its parametrization.

### Sources:

>[2] [Poisson distribution](https://en.wikipedia.org/wiki/Poisson_distribution)

First, we will initialize the simulation and define a function that runs the Brunel Network.

```python
%matplotlib inline
import matplotlib
import matplotlib.pyplot as plt
import nest
import numpy as np
import time
from numpy import exp
```

```python
def brunel_network(simtime = 300., g = 5., eta = 2., delay = 1.5, epsilon = 0.1, N_rec = 50):

    """Random balanced network (delta synapses)
    ----------------------------------------------

    This script simulates an excitatory and an inhibitory population on
    the basis of the network used in [1].

    When connecting the network customary synapse models are used, which
    allow for querying the number of created synapses. Using spike
    detectors the average firing rates of the neurons in the populations
    are established. The building, as well as the simulation time of the
    network, are recorded.

    Original code obtained from NEST examples.
    Modified for Skill Pills + Workshop.

    ---- Arguments ----

    g: ratio of inhibitory / excitatory weight
    eta: external noise rate relative to the threshold rate
    delay: synaptic delay in ms
    epsilon: connection probability
    simtime: simulation time in ms
    N_rec: number of neurons to record activity from for each population

    References
    ~~~~~~~~~~~~~~

    .. [1] Brunel N (2000). Dynamics of sparsely connected networks of excitatory and
           inhibitory spiking neurons. Journal of Computational Neuroscience 8,
           183-208.

    """

    nest.ResetKernel()

    ###############################################################################
    # Assigning the current time to a variable in order to determine the build
    # time of the network.

    startbuild = time.time()

    ###############################################################################
    # Assigning the simulation parameters to variables.

    dt = 0.1  # the resolution in ms

    ###############################################################################
    # Definition of the number of neurons in the network and the number of neuron
    # recorded from

    order = 2500
    NE = 4 * order  # number of excitatory neurons
    NI = 1 * order  # number of inhibitory neurons
    N_neurons = NE + NI  # number of neurons in total
    N_rec_volt = 3  # record voltage from 3 neurons of population

    ###############################################################################
    # Definition of connectivity parameter

    CE = int(epsilon * NE)  # number of excitatory synapses per neuron
    CI = int(epsilon * NI)  # number of inhibitory synapses per neuron
    C_tot = int(CI + CE)  # total number of synapses per neuron

    ###############################################################################
    # Initialization of the parameters of the integrate and fire neuron and the
    # synapses. The parameter of the neuron are stored in a dictionary.

    tauMem = 20.0  # time constant of membrane potential in ms
    theta = 20.0  # membrane threshold potential in mV
    neuron_params = {"C_m": 1.0,
                     "tau_m": tauMem,
                     "t_ref": 2.0,
                     "E_L": 0.0,
                     "V_reset": 0.0,
                     "V_m": 0.0,
                     "V_th": theta}
    J = 0.1  # postsynaptic amplitude in mV
    J_ex = J  # amplitude of excitatory postsynaptic potential
    J_in = -g * J_ex  # amplitude of inhibitory postsynaptic potential

    ###############################################################################
    # Definition of threshold rate, which is the external rate needed to fix the
    # membrane potential around its threshold, the external firing rate and the
    # rate of the poisson generator which is multiplied by the in-degree CE and
    # converted to Hz by multiplication by 1000.

    nu_th = theta / (J * CE * tauMem)
    nu_ex = eta * nu_th
    p_rate = 1000.0 * nu_ex * CE

    ###############################################################################
    # Configuration of the simulation kernel by the previously defined time
    # resolution used in the simulation. Setting ``print_time`` to `True` prints the
    # already processed simulation time as well as its percentage of the total
    # simulation time.

    nest.SetKernelStatus({"resolution": dt, "print_time": True,
                          "overwrite_files": True})

    print("Building network")

    ###############################################################################
    # Configuration of the model ``iaf_psc_delta`` and ``poisson_generator`` using
    # ``SetDefaults``. This function expects the model to be the inserted as a
    # string and the parameter to be specified in a dictionary. All instances of
    # theses models created after this point will have the properties specified
    # in the dictionary by default.

    nest.SetDefaults("iaf_psc_delta", neuron_params)
    nest.SetDefaults("poisson_generator", {"rate": p_rate})

    ###############################################################################
    # Creation of the nodes using ``Create``. We store the returned handles in
    # variables for later reference. Here the excitatory and inhibitory, as well
    # as the poisson generator and two spike detectors. The spike detectors will
    # later be used to record excitatory and inhibitory spikes.

    nodes_ex = nest.Create("iaf_psc_delta", NE)
    nodes_in = nest.Create("iaf_psc_delta", NI)
    noise = nest.Create("poisson_generator")
    espikes = nest.Create("spike_detector")
    ispikes = nest.Create("spike_detector")

    ###############################################################################
    # Configuration of the spike detectors recording excitatory and inhibitory
    # spikes using ``SetStatus``, which expects a list of node handles and a list
    # of parameter dictionaries. Setting the variable ``to_file`` to `True` ensures
    # that the spikes will be recorded in a .gdf file starting with the string
    # assigned to label. Setting ``withtime`` and ``withgid`` to `True` ensures that
    # each spike is saved to file by stating the gid of the spiking neuron and
    # the spike time in one line.

    nest.SetStatus(espikes, [{"label": "brunel-py-ex",
                              "withtime": True,
                              "withgid": True,
                              "to_file": False}])

    nest.SetStatus(ispikes, [{"label": "brunel-py-in",
                              "withtime": True,
                              "withgid": True,
                              "to_file": False}])

    voltmeter_ex = nest.Create("voltmeter", N_rec_volt)
    voltmeter_in = nest.Create("voltmeter", N_rec_volt)

    print("Connecting devices")

    ###############################################################################
    # Definition of a synapse using ``CopyModel``, which expects the model name of
    # a pre-defined synapse, the name of the customary synapse and an optional
    # parameter dictionary. The parameters defined in the dictionary will be the
    # default parameter for the customary synapse. Here we define one synapse for
    # the excitatory and one for the inhibitory connections giving the
    # previously defined weights and equal delays.

    nest.CopyModel("static_synapse", "excitatory",
                   {"weight": J_ex, "delay": delay})
    nest.CopyModel("static_synapse", "inhibitory",
                   {"weight": J_in, "delay": delay})

    ###############################################################################
    # Connecting the previously defined poisson generator to the excitatory and
    # inhibitory neurons using the excitatory synapse. Since the poisson
    # generator is connected to all neurons in the population the default rule
    # (# ``all_to_all``) of ``Connect`` is used. The synaptic properties are inserted
    # via ``syn_spec`` which expects a dictionary when defining multiple variables
    # or a string when simply using a pre-defined synapse.

    nest.Connect(noise, nodes_ex, syn_spec="excitatory")
    nest.Connect(noise, nodes_in, syn_spec="excitatory")

    ###############################################################################
    # Connecting the first ``N_rec`` nodes of the excitatory and inhibitory
    # population to the associated spike detectors using excitatory synapses.
    # Here the same shortcut for the specification of the synapse as defined
    # above is used.

    nest.Connect(nodes_ex[:N_rec], espikes, syn_spec="excitatory")
    nest.Connect(nodes_in[:N_rec], ispikes, syn_spec="excitatory")

    for i in range(N_rec_volt):
        nest.Connect([voltmeter_ex[i]], [nodes_ex[i]])

    for i in range(N_rec_volt):
        nest.Connect([voltmeter_in[i]], [nodes_in[i]])

    print("Connecting network")

    print("Excitatory connections")

    ###############################################################################
    # Connecting the excitatory population to all neurons using the pre-defined
    # excitatory synapse. Beforehand, the connection parameter are defined in a
    # dictionary. Here we use the connection rule ``fixed_indegree``,
    # which requires the definition of the indegree. Since the synapse
    # specification is reduced to assigning the pre-defined excitatory synapse it
    # suffices to insert a string.

    conn_params_ex = {'rule': 'fixed_indegree', 'indegree': CE}
    nest.Connect(nodes_ex, nodes_ex + nodes_in, conn_params_ex, "excitatory")

    print("Inhibitory connections")

    ###############################################################################
    # Connecting the inhibitory population to all neurons using the pre-defined
    # inhibitory synapse. The connection parameter as well as the synapse
    # paramtere are defined analogously to the connection from the excitatory
    # population defined above.

    conn_params_in = {'rule': 'fixed_indegree', 'indegree': CI}
    nest.Connect(nodes_in, nodes_ex + nodes_in, conn_params_in, "inhibitory")

    ###############################################################################
    # Storage of the time point after the buildup of the network in a variable.

    endbuild = time.time()

    ###############################################################################
    # Simulation of the network.

    print("Simulating")

    nest.Simulate(simtime)

    ###############################################################################
    # Storage of the time point after the simulation of the network in a variable.

    endsimulate = time.time()

    ###############################################################################
    # Reading out the total number of spikes received from the spike detector
    # connected to the excitatory population and the inhibitory population.

    events_ex = nest.GetStatus(espikes, "n_events")[0]
    events_in = nest.GetStatus(ispikes, "n_events")[0]

    ###############################################################################
    # Calculation of the average firing rate of the excitatory and the inhibitory
    # neurons by dividing the total number of recorded spikes by the number of
    # neurons recorded from and the simulation time. The multiplication by 1000.0
    # converts the unit 1/ms to 1/s=Hz.

    rate_ex = events_ex / simtime * 1000.0 / N_rec
    rate_in = events_in / simtime * 1000.0 / N_rec

    ###############################################################################
    # Reading out the number of connections established using the excitatory and
    # inhibitory synapse model. The numbers are summed up resulting in the total
    # number of synapses.

    num_synapses = (nest.GetDefaults("excitatory")["num_connections"] +
                    nest.GetDefaults("inhibitory")["num_connections"])

    ###############################################################################
    # Establishing the time it took to build and simulate the network by taking
    # the difference of the pre-defined time variables.

    build_time = endbuild - startbuild
    sim_time = endsimulate - endbuild

    ###############################################################################
    # Printing the network properties, firing rates and building times.

    print("Brunel network simulation (Python)")
    print("Number of neurons : {0}".format(N_neurons))
    print("Number of synapses: {0}".format(num_synapses))
    print("       Exitatory  : {0}".format(int(CE * N_neurons) + N_neurons))
    print("       Inhibitory : {0}".format(int(CI * N_neurons)))
    print("Excitatory rate   : %.2f Hz" % rate_ex)
    print("Inhibitory rate   : %.2f Hz" % rate_in)
    print("Building time     : %.2f s" % build_time)
    print("Simulation time   : %.2f s" % sim_time)

    ###############################################################################
    # Obtain data to plot voltage and raster activity.

    allevents_E = nest.GetStatus(espikes, "events")[0]
    allevents_I = nest.GetStatus(ispikes, "events")[0]

    membrane_E = []
    membrane_t_E = []
    membrane_I = []
    membrane_t_I = []

    for i in range(N_rec_volt):
        voltmeter_data = nest.GetStatus([voltmeter_ex[i]])[0]
        membrane_E.append(voltmeter_data["events"]["V_m"])
        membrane_t_E.append(voltmeter_data["events"]["times"])

    for i in range(N_rec_volt):
        voltmeter_data = nest.GetStatus([voltmeter_in[i]])[0]
        membrane_I.append(voltmeter_data["events"]["V_m"])
        membrane_t_I.append(voltmeter_data["events"]["times"])

    return allevents_E, allevents_I, membrane_E, membrane_t_E, membrane_I, membrane_t_I
```

We can run the network by calling `brunel_network`. Arguments can be changed during function call, and variables `stime` and `nrec` were defined separately to facilitate the plots below.

```python
stime = 900.
nrec = 50

events_E, events_I, v_E, t_E, v_I, t_I = \
        brunel_network(stime, g = 5., eta = 2., delay = 1.5, epsilon = 0.1, N_rec = nrec)
```

    Building network
    Connecting devices
    Connecting network
    Excitatory connections
    Inhibitory connections
    Simulating
    Brunel network simulation (Python)
    Number of neurons : 12500
    Number of synapses: 15637600
           Exitatory  : 12512500
           Inhibitory : 3125000
    Excitatory rate   : 31.93 Hz
    Inhibitory rate   : 32.13 Hz
    Building time     : 3.29 s
    Simulation time   : 28.04 s

Below we plot the voltage, raster, and activity rate of the excitatory and inhibitory populations.

```python
# Membrane potential plots: sample excitatory neurons

plt.rcParams['figure.dpi'] = 300

ax1 = plt.subplot(311)
plt.axhline(0, color='tab:grey', linestyle='--')
plt.axhline(20, color='tab:grey', linestyle='--')
plt.plot(t_E[0], v_E[0], color='tab:blue')
plt.xlim(0, 300)
plt.ylim(-5, 25)
plt.text(305, -2, "V$_{reset}$", color='tab:grey', fontsize=12)
plt.text(305, 18, "V$_{th}$", color='tab:grey', fontsize=12)
plt.ylabel('V$_{E,0}$ (mV)')
plt.setp(ax1.get_xticklabels(), visible=False)

ax2 = plt.subplot(312, sharex=ax1, sharey=ax1)
plt.axhline(0, color='tab:grey', linestyle='--')
plt.axhline(20, color='tab:grey', linestyle='--')
plt.plot(t_E[1], v_E[1], color='tab:orange')
plt.text(305, -2, "V$_{reset}$", color='tab:grey', fontsize=12)
plt.text(305, 18, "V$_{th}$", color='tab:grey', fontsize=12)
plt.ylabel('V$_{E,1}$ (mV)')
plt.setp(ax2.get_xticklabels(), visible=False)

ax3 = plt.subplot(313, sharex=ax1, sharey=ax1)
plt.axhline(0, color='tab:grey', linestyle='--',)
plt.axhline(20, color='tab:grey', linestyle='--')
plt.plot(t_E[1], v_E[1], color='tab:green')
plt.text(305, -2, "V$_{reset}$", color='tab:grey', fontsize=12)
plt.text(305, 18, "V$_{th}$", color='tab:grey', fontsize=12)
plt.ylabel('V$_{E,2}$ (mV)')
plt.xlabel('time (ms)')
plt.show()
```

```python
# Membrane potential plots: sample excitatory and inhibitory neurons

ax1 = plt.subplot(211)
plt.axhline(0, color='tab:grey', linestyle='--')
plt.axhline(20, color='tab:grey', linestyle='--')
plt.plot(t_E[0], v_E[0], color='tab:blue')
plt.xlim(0, stime)
plt.ylim(-5, 25)
plt.text(stime + 10, -2, "V$_{reset}$", color='tab:grey', fontsize=12)
plt.text(stime + 10, 18, "V$_{th}$", color='tab:grey', fontsize=12)
plt.ylabel('V$_{E,0}$ (mV)')
plt.setp(ax1.get_xticklabels(), visible=False)

ax2 = plt.subplot(212, sharex=ax1, sharey=ax1)
plt.axhline(0, color='tab:grey', linestyle='--')
plt.axhline(20, color='tab:grey', linestyle='--')
plt.plot(t_I[0], v_I[0], color='tab:red')
plt.xlim(0, stime)
plt.ylim(-5, 25)
plt.text(stime + 10, -2, "V$_{reset}$", color='tab:grey', fontsize=12)
plt.text(stime + 10, 18, "V$_{th}$", color='tab:grey', fontsize=12)
plt.ylabel('V$_{I,0}$ (mV)')
plt.xlabel('time (ms)')
plt.show()
```

```python
# Rasterplots and population frequency plots: nrec neurons recorded for each population

fig, ax = plt.subplots(2, 2, sharex=True, sharey=False)

ax[0,0].set_title("Excitatory Population")
ax[0,0].set_xlim(0, stime)
ax[0,0].set_ylabel('Neuron ID')
ax[0,0].plot(events_E['times'], events_E['senders'], '.', color='tab:blue', markersize=2)

ax[1,0].set_xlim(0, stime)
ax[1,0].set_xlabel('time (ms)')
ax[1,0].set_ylabel('Frequency (Hz)')
count, bins = np.histogram(events_E['times'], bins=100)
binwidth = bins[1]-bins[0]
ax[1,0].plot(bins[:-1], count*1000./nrec/binwidth, color='tab:blue')

ax[0,1].set_title("Inhibitory Population")
ax[0,1].yaxis.set_label_position("right")
ax[0,1].set_ylabel('Neuron ID')
ax[0,1].yaxis.tick_right()
ax[0,1].set_xlim(0, stime)
ax[0,1].plot(events_I['times'], events_I['senders']-10000, '.', color='tab:red', markersize=2)

ax[1,1].yaxis.set_label_position("right")
ax[1,1].set_ylabel('Frequency (Hz)')
ax[1,1].yaxis.tick_right()
ax[1,1].set_xlabel('time (ms)')
ax[1,1].set_xlim(0, stime)
count, bins = np.histogram(events_I['times'], bins=100)
binwidth = bins[1]-bins[0]
ax[1,1].plot(bins[:-1], count*1000./nrec/binwidth, color='tab:red')

fig.tight_layout()
plt.show()
```

---

One of the main findings in Brunel's paper is the different activity states that can be observed in this network depending on $g$, the **ratio between inhibitory and excitatory synaptic weights**, $\eta$, the **external excitation rate relative to the threshold rate**, $\delta$, the **synaptic delay**, and $\epsilon$, the **connection probability**.

By altering these arguments, he observed different collective behaviors identified as **asynchronous irregular** (AI), **asynchronous regular** (AR), **synchronous irregular** (SI) and **synchronous regular** (SR). These behaviors are exemplified in the images below.

<center></center>

<center></center>

<center>from [1]</center>

### Rerun the `brunel_network` function and try to identify the four states cited above.

Use the graphs as a starting point. Try to elaborate on how each of the four parameters affects the state of this network. Use `Kernel > Restart & Run All` as necessary.

```python

```


```python
%matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
import mpl_toolkits.mplot3d.axes3d as axes3d
from matplotlib import rc,rcParams
rc('text', usetex=True)
rcParams['figure.figsize'] = (8, 6.5)
rcParams['ytick.labelsize'],rcParams['xtick.labelsize'] = 17.,17.
rcParams['axes.labelsize']=19.
rcParams['legend.fontsize']=17.
rcParams['axes.titlesize']=20.
rcParams['text.latex.preamble'] = ['\\usepackage{siunitx}']
#import seaborn
import sympy as sm
from scipy.integrate import odeint
sm.init_printing()
```

Κυλινδρικό ρολό χαρτί μήκους $h$ που αποτελείται από έναν πυρήνα ακτίνας $R_0$ και μάζας $M_0$ ξετυλίγειτε καθώς εκτοξέυεται απο ύψος $y_0$. Αρχικά το πάχος του ρολού είναι $R_E$ και το πάχος ενός φύλλου χαρτιού είναι $\delta$
Υπολογίστε τη τρόχια του.

```python
R0,M0,Re,d,t,h,w0,g,u0,y0,rhox,a,theta=sm.symbols('R_0,M_0,R_E,\\delta,t,h,\\omega_0,g,u_0,y_0,\\rho_x,a,\\theta',real=True,positive=True)
```

```python
Vx=sm.Function('V_x',real=True)(t)
Vy=sm.Function('V_y',real=True)(t)
#M=sm.Function('V_x')(t)
```

Αριθμός περιστροφών δεδομένης γωνιακής ταχύτητας $\omega (t)$
$$
k(t) =\frac{\int _0 ^t \omega (t') dt'}{2\pi}
$$
άρα το πάχος του εξωτερικού στρώματος είναι
$$
R_{x}(t)=R_E - k(t) \delta
$$
και η συνολική μάζα του κυλίνδρου
$$
M(t)=M_0 +\rho _x h \pi R_{x}^2(t)
$$

```python
w=w0#estw statheri gwniaki taxyttita
```

```python
k= sm.integrate(w,t)/(2*sm.pi)
Rex=Re-d*k
R=R0+Rex
M=M0+rhox*h*sm.pi*Rex**2
M
```

```python
R
```

Το σύστημα των διαφορικών εξισώσεων είναι:
\begin{align}
&\frac{d}{dt}\big(M(t) V_y \big)=-M(t)g \\
&\frac{d}{dt}\big(M(t) V_x \big)=0
\end{align}

```python
eqy=sm.diff(Vy*M,t)+M*g#-Vy*sm.diff(M,t)/M
eqx=sm.diff(Vx*M,t)#+Vx*sm.diff(M,t)/M
```

```python
eqy
```

```python
eqx
```

```python
sub={R0:0.02,h:0.03,g:9.86,M0:0.050,w0:0.5*2.*np.pi,d:0.05,u0:5.0,rhox:1.5,Re:0.1,y0:100.,a:0.01}
```

```python
sub[a]=1
```

```python
eqy.subs(sub)
```

```python
sm.solve(eqx,sm.diff(Vx))[0]
```

```python
dVy=sm.solve(eqy,sm.diff(Vy))[0]
dVx=sm.solve(eqx,sm.diff(Vx))[0]
```

```python
dVyn=sm.lambdify((Vy,t),dVy.subs(sub))
dVxn=sm.lambdify((Vx,t),dVx.subs(sub))
Rn=sm.lambdify(t,R.subs(sub))
Mn=sm.lambdify(t,M.subs(sub))
```

```python
def DX(y,t):
    x,v=y
    dydt=[v,dVxn(v,t)]
    return dydt
def DY(y,t):
    y,v=y
    dydt=[v,dVyn(v,t)]
    return dydt
```

```python
tt=np.linspace(0,5)
solx=odeint(DX,y0=[0.,5.],t=tt)
soly=odeint(DY,y0=[100.,0.],t=tt)
```

```python
plt.plot(tt,soly[:,1])
```

```python
def r(theta0=45.,tend=4.,friction=0.01,angular=0.5*2*np.pi):
    w=w0*t
    k= sm.integrate(w,t)/(2*sm.pi)
    Rex=Re-d*k
    R=R0+Rex
    M=M0+rhox*h*sm.pi*Rex**2
    eqy=sm.diff(Vy*M,t)+M*g+a*R*Vy
    eqx=sm.diff(Vx*M,t)+a*R*Vx
    dVy=sm.solve(eqy,sm.diff(Vy))[0]
    dVx=sm.solve(eqx,sm.diff(Vx))[0]
    subk={R0:0.02,h:0.1,g:9.86,M0:0.050,w0:angular,d:0.0001,u0:5.0,rhox:500.,Re:0.05,y0:10.,a:friction}
    dVyn=sm.lambdify((Vy,t),dVy.subs(subk))
    dVxn=sm.lambdify((Vx,t),dVx.subs(subk))
    def DX(y,t):
        x,v=y
        dydt=[v,dVxn(v,t)]
        return dydt
    def DY(y,t):
        y,v=y
        dydt=[v,dVyn(v,t)]
        return dydt
    #Rn=sm.lambdify(t,R.subs(subk))
    Mn=sm.lambdify(t,M.subs(subk))
    V0y=sub[u0]*np.sin(theta0)
    V0x=sub[u0]*np.cos(theta0)
    tt=np.linspace(0,tend)
    solx=odeint(DX,y0=[0.,V0x],t=tt)
    soly=odeint(DY,y0=[subk[y0],V0y],t=tt)
    return tt,solx[:,0],soly[:,0],Mn(tt)
```

```python
#for theta0 in np.linspace(0,0.75,5)*0.5*np.pi:
#for fr in np.linspace(0,1.,5):
for wr in np.linspace(0,600.,5)*2*np.pi:
    #tt,xx,yy,MM=r(tend=5,friction=0.0001,theta0=theta0)
    #plt.plot(xx,yy,label=r'Variable Mass $\theta _0={:.2}\pi$'.format(theta0/np.pi))
#     tt,xx,yy,MM=r(tend=2,friction=fr,theta0=0.,angular=2*np.pi)
#     plt.plot(xx,yy,label=r'Variable Mass a={:.2}'.format(fr))
    tt,xx,yy,MM=r(tend=2,friction=0.,theta0=0.,angular=wr)
    plt.plot(xx,yy,label=r'Variable Mass w0={:.2f}'.format(wr))
plt.plot(5.0*tt,10.-(9.86/2.)*tt**2,'--',label='Steady Mass') #apli boli
plt.legend()
plt.ylim(0,None)
```

```python
plt.plot(MM)
```

```python
plt.plot(tt,Rn(tt))
```

```python
plt.plot(tt,Mn(tt))
```

```python
plt.plot(solx[:,0],soly[:,0],label='Variable Mass')
plt.plot(5.0*tt,100.-(9.86/2.)*tt**2,label='Steady Mass') #apli boli
plt.legend()
```

```python
plt.plot(solx[:,1],soly[:,1],label='Variable Mass')
plt.plot(5.0*np.ones(tt.shape),-9.86*tt,label='Steady Mass') #apli boli
plt.legend()
```

```python
plt.plot(tt,solx[:,0]-5.0*tt)
```

```python
plt.plot(soly[:,0]-(100.-(9.86/2.)*tt**2))
#plt.plot(100.-(9.86/2.)*tt**2)
```

```python
Vys=sm.dsolve(eqy,ics={Vy.subs(t,0):sm.Float(0)})#,ics={eqy.subs({t:0}):0})
Vys=Vys.subs('C1',0)
Vys
```

```python
Vxs=sm.dsolve(eqx)
Vxs=Vxs.subs('C1',4*sm.pi*M0*u0)
Vxs
```

```python
Vxs=Vxs.rhs
Vys=Vys.rhs
```

```python
Vxn=sm.lambdify(t,Vxs.subs(sub),"numpy")
Vyn=sm.lambdify(t,Vys.subs(sub),"numpy")
```

```python
tt=np.linspace(0,5)
```

```python
from scipy.integrate import odeint
```

```python
def Vxsn(y,t): return Vxn(t)
def Vysn(y,t): return Vyn(t)
```

```python
xn=odeint(Vxsn,y0=0.,t=tt)
yn=odeint(Vysn,y0=100.,t=tt)
```

```python
plt.plot(xn,yn)
plt.plot(5.0*tt,100.-(9.86/2.)*tt**2) #apli boli
```

```python
plt.plot(tt,np.abs(xn[:,0]-5.0*tt))
```

```python
plt.plot(tt,np.abs(yn[:,0]-(100.-(9.86/2.)*tt**2)))
```

```python
sub
```

## Τριβή
Το σύστημα των διαφορικών εξισώσεων είναι:
\begin{align}
&\frac{d}{dt}\big(M(t) V_y \big)=-M(t)g -a V_y\\
&\frac{d}{dt}\big(M(t) V_x \big)=0 -a V_x
\end{align}

$$
\dot{M}\dot{y}+M\dot{V}_y+Mg+a\dot{y}=0
$$

\begin{align}
\dot{y}&=V_y \\
\dot{V}_y%= -\dot{M}\dot{y}+M\dot{V}_y+Mg+a\dot{y}=0
\end{align}

```python
eqy=sm.diff(Vy*M,t)+M*g+a*Vy
eqx=sm.diff(Vx*M,t)+a*Vx
```

```python
dVy=sm.solve(eqy,sm.diff(Vy))[0]
dVx=sm.solve(eqx,sm.diff(Vx))[0]
```

```python
dVx
```

```python
sub={R0:0.02,h:0.03,g:9.86,M0:0.020,w0:0.5*2.*np.pi,d:0.001,u0:5.0,rhox:1.5,Re:0.003,y0:100.,a:0.01}
```

```python
dVyn=sm.lambdify((Vy,t),dVy.subs(sub))
dVxn=sm.lambdify((Vx,t),dVx.subs(sub))
```

```python
sub
```

```python
def DX(y,t):
    x,v=y
    dydt=[v,dVxn(v,t)]
    return dydt
def DY(y,t):
    y,v=y
    dydt=[v,dVyn(v,t)]
    return dydt
```

```python
solx=odeint(DX,y0=[0.,5.],t=tt)
soly=odeint(DY,y0=[100.,0.],t=tt)
```

```python
plt.plot(solx[:,0],soly[:,0])
plt.plot(5.0*tt,100.-(9.86/2.)*tt**2) #apli boli
```

```python
Vxn=odeint(dVxn,y0=5.,t=tt)
Vyn=odeint(dVyn,y0=0.,t=tt)
```

```python
plt.plot(tt,Vxn)
```

```python
xn=
```

```python
Vys=sm.dsolve(eqy,ics={Vy.subs(t,0):sm.Float(0)})#,ics={eqy.subs({t:0}):0})
Vys=Vys.subs('C1',0)
Vys
```

```python

```