{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "801ff228",
   "metadata": {},
   "source": [
    "[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/MeiraDavidson/math_module2/blob/main/notebooks/ch12_series.ipynb)\n\n",
    "# Chapter 12 — Companion Notebook\n",
    "## Adding Up Forever\n",
    "*when does an infinite sum settle to a number — and where do Taylor's series really live?*\n",
    "\n",
    "Four live experiments on the last idea in the book — that adding infinitely many numbers is just taking a limit of *partial sums*. You will watch a series settle onto its limit or wander off to infinity, squeeze a sum against an area with the integral test, shade the interval where a power series is allowed to live, and add up a bouncing ball's whole path as a geometric series that lands on a finite total.\n",
    "\n",
    "> **How to use this notebook.** Run every cell from the top (Shift+Enter). Each widget below is live — drag the sliders and watch the picture respond. Requires `ipywidgets` and `matplotlib`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d327ed87",
   "metadata": {},
   "outputs": [],
   "source": [
    "%matplotlib inline\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "from matplotlib import animation\n",
    "from ipywidgets import (interact, interactive, fixed, Dropdown, Checkbox,\n",
    "                        Button, Output, VBox, HBox,\n",
    "                        FloatSlider, IntSlider, FloatLogSlider)\n",
    "from IPython.display import HTML, display, Markdown\n",
    "\n",
    "# Book 2 palette — matches the printed figures in figures/png/\n",
    "BLUE=\"#1f4e79\"; RED=\"#c0392b\"; GREEN=\"#2e8b57\"; ORANGE=\"#e08e0b\"\n",
    "PURPLE=\"#6c3483\"; TEAL=\"#1b9e9e\"; GRAY=\"#7f8c8d\"; DARK=\"#2c3e50\"\n",
    "SHADE=\"#9fc5e8\"; SHADE2=\"#f4b6ad\"; SHADEG=\"#a9dfbf\"; LIGHT=\"#d6dbdf\"\n",
    "\n",
    "plt.rcParams.update({\n",
    "    \"figure.figsize\": (7.2, 4.5), \"figure.dpi\": 96,\n",
    "    \"font.family\": \"serif\", \"mathtext.fontset\": \"cm\", \"font.size\": 12,\n",
    "    \"axes.spines.top\": False, \"axes.spines.right\": False,\n",
    "    \"axes.grid\": True, \"grid.color\": LIGHT, \"grid.linewidth\": 0.7,\n",
    "    \"lines.linewidth\": 2.2, \"lines.solid_capstyle\": \"round\",\n",
    "})\n",
    "\n",
    "def axes(ax=None, title=None):\n",
    "    '''A clean axes with a light grid; returns the axes.'''\n",
    "    if ax is None:\n",
    "        _, ax = plt.subplots()\n",
    "    if title:\n",
    "        ax.set_title(title, color=DARK)\n",
    "    return ax\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c0574fd3",
   "metadata": {},
   "source": [
    "## 1. Does it settle, or does it run away? — the partial sums\n",
    "\n",
    "An infinite series is nothing more exotic than the **limit of its partial sums** $S_N=\\sum_{n=1}^N a_n$ — the sequences of Chapter 1, now built from a running total. So the whole question of convergence is visible to the eye: plot $S_N$ against $N$ and *look*. A convergent series' dots crowd down onto a single height; a divergent one's climb or wander off without ever settling.\n",
    "\n",
    "Pick a series. The blue dots are the partial sums $S_N$. When the series converges, the red line marks the true limit and you watch the dots press onto it. The two cautionary tales sit side by side: $\\sum\\frac1{n^2}$ converges to $\\frac{\\pi^2}{6}$, but the harmonic series $\\sum\\frac1n$ — whose terms *also* shrink to zero — climbs forever. Terms dying is necessary, never sufficient."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0bcd21c8",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Each series: term rule a_n, its limit (or None if it diverges),\n",
    "# and a label for the limit line.\n",
    "_SERIES = {\n",
    "    'geometric  \\u03a3 2^(-n)  \\u2192  1':\n",
    "        (lambda n: 2.0 ** (-n),            1.0,            'limit = 1'),\n",
    "    'p=2  \\u03a3 1/n\\u00b2  \\u2192  \\u03c0\\u00b2/6':\n",
    "        (lambda n: 1.0 / n ** 2,           np.pi ** 2 / 6, 'limit = \\u03c0\\u00b2/6 \\u2248 1.6449'),\n",
    "    'alternating  \\u03a3 (-1)^(n+1)/n  \\u2192  ln 2':\n",
    "        (lambda n: (-1.0) ** (n + 1) / n,  np.log(2.0),    'limit = ln 2 \\u2248 0.6931'),\n",
    "    'harmonic  \\u03a3 1/n  \\u2192  \\u221e  (diverges)':\n",
    "        (lambda n: 1.0 / n,                None,           None),\n",
    "}\n",
    "\n",
    "def _partial_sums(series='geometric  \\u03a3 2^(-n)  \\u2192  1', N=25):\n",
    "    rule, limit, lab = _SERIES[series]\n",
    "    n = np.arange(1, N + 1)\n",
    "    S = np.cumsum(rule(n))            # the partial sums S_1, S_2, ...\n",
    "\n",
    "    fig, ax = plt.subplots()\n",
    "    axes(ax, series)\n",
    "    if limit is not None:\n",
    "        ax.axhline(limit, color=RED, lw=2.0, ls='--', zorder=2, label=lab)\n",
    "    ax.scatter(n, S, s=30, color=BLUE, zorder=3, label='partial sums $S_N$')\n",
    "    ax.plot(n, S, color=BLUE, lw=0.9, alpha=0.4, zorder=1)\n",
    "    ax.set_xlabel('N  (number of terms)'); ax.set_ylabel('$S_N$')\n",
    "    ax.set_xlim(0, N + 1)\n",
    "    if limit is None:\n",
    "        # harmonic: let it climb, annotate the slow march to infinity\n",
    "        ax.text(0.5, -0.22, 'terms \\u2192 0, yet the sum \\u2192 \\u221e: '\n",
    "                'the dots never stop climbing.',\n",
    "                transform=ax.transAxes, ha='center', color=DARK, fontsize=10)\n",
    "    else:\n",
    "        span = max(abs(S).max(), abs(limit)) * 0.12 + 0.05\n",
    "        lo = min(S.min(), limit) - span\n",
    "        hi = max(S.max(), limit) + span\n",
    "        ax.set_ylim(lo, hi)\n",
    "        ax.text(0.5, -0.22, f'with N = {N} terms,  S_N = {S[-1]:.5f}  '\n",
    "                f'(gap to limit: {abs(S[-1]-limit):.2e})',\n",
    "                transform=ax.transAxes, ha='center', color=DARK, fontsize=10)\n",
    "    ax.legend(loc='best', framealpha=0.9, fontsize=9)\n",
    "    plt.show()\n",
    "\n",
    "interact(_partial_sums,\n",
    "         series=Dropdown(options=list(_SERIES),\n",
    "                         value='geometric  \\u03a3 2^(-n)  \\u2192  1',\n",
    "                         description='series'),\n",
    "         N=IntSlider(value=25, min=3, max=120, step=1, description='terms N'));"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "11ff4948",
   "metadata": {},
   "source": [
    "## 2. Weigh the sum against an area — the integral test\n",
    "\n",
    "Here is the principle that earns this chapter its place at the *end* of a calculus book: a sum of positive, decreasing terms can be trapped against an **area**, using the very rectangle-and-curve picture you built for the integral. Draw each term $f(n)$ as a rectangle of width $1$ and height $f(n)$, and lay the rectangles against the curve $y=f(x)$. Because $f$ is decreasing, the sum and the integral $\\int_1^\\infty f$ differ by at most the first term — so they are **finite together or infinite together**.\n",
    "\n",
    "Pick $f$ and slide the number of terms. The blue curve is $y=f(x)$; the shaded rectangles are the series' terms. The read-out shows the running sum $\\sum f(n)$ and the running area $\\int_1^N f$ side by side. With $f=1/x^2$ the area converges (to $1$), so the sum is squeezed finite — that's why $\\sum 1/n^2$ converges. With $f=1/x$ the area is $\\ln N\\to\\infty$, dragging the harmonic sum up with it."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b3e10a26",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Positive decreasing f, its exact antiderivative integral_1^N f, and\n",
    "# whether the improper integral (and hence the series) converges.\n",
    "_FUNCS = {\n",
    "    'f(x) = 1/x\\u00b2   (series \\u03a3 1/n\\u00b2 converges)':\n",
    "        (lambda x: 1.0 / x ** 2, lambda N: 1.0 - 1.0 / N, True,  '\\u222b\\u2081^\\u221e = 1'),\n",
    "    'f(x) = 1/x    (harmonic \\u03a3 1/n diverges)':\n",
    "        (lambda x: 1.0 / x,      lambda N: np.log(N),     False, '\\u222b\\u2081^\\u221e = \\u221e'),\n",
    "    'f(x) = 1/x^(3/2)  (\\u03a3 1/n^(3/2) converges)':\n",
    "        (lambda x: x ** -1.5,    lambda N: 2.0 - 2.0 / np.sqrt(N), True, '\\u222b\\u2081^\\u221e = 2'),\n",
    "}\n",
    "\n",
    "def _integral_test(func='f(x) = 1/x\\u00b2   (series \\u03a3 1/n\\u00b2 converges)', N=12):\n",
    "    f, Iexact, converges, tag = _FUNCS[func]\n",
    "    n = np.arange(1, N + 1)\n",
    "    partial_sum = float(np.sum(f(n)))\n",
    "    running_area = float(Iexact(N))           # area from 1 to N under f\n",
    "\n",
    "    fig, ax = plt.subplots()\n",
    "    axes(ax, func)\n",
    "    # rectangles: term n drawn on [n, n+1] at height f(n) (right of the curve)\n",
    "    for k in n:\n",
    "        ax.bar(k, f(k), width=1.0, align='edge', color=SHADE,\n",
    "               edgecolor=BLUE, linewidth=0.8, alpha=0.8, zorder=2)\n",
    "    xs = np.linspace(1, N + 1, 600)\n",
    "    ax.plot(xs, f(xs), color=BLUE, lw=2.4, zorder=3, label='y = f(x)')\n",
    "    ax.set_xlabel('x'); ax.set_ylabel('f(x)')\n",
    "    ax.set_xlim(1, N + 1); ax.set_ylim(0, f(1) * 1.08)\n",
    "    ax.legend(loc='upper right', framealpha=0.9, fontsize=10)\n",
    "    verdict = ('converges' if converges else 'diverges')\n",
    "    ax.text(0.5, -0.24,\n",
    "            f'running sum  \\u03a3\\u2099\\u208c\\u2081^{N} f(n) = {partial_sum:.4f}      '\n",
    "            f'running area  \\u222b\\u2081^{N} f = {running_area:.4f}      '\n",
    "            f'[{tag}  \\u2192  series {verdict}]',\n",
    "            transform=ax.transAxes, ha='center', color=DARK, fontsize=9.5)\n",
    "    plt.show()\n",
    "\n",
    "interact(_integral_test,\n",
    "         func=Dropdown(options=list(_FUNCS),\n",
    "                       value='f(x) = 1/x\\u00b2   (series \\u03a3 1/n\\u00b2 converges)',\n",
    "                       description='f(x)'),\n",
    "         N=IntSlider(value=12, min=2, max=40, step=1, description='terms N'));"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "49b44eea",
   "metadata": {},
   "source": [
    "## 3. Where a power series is allowed to live — the radius $R$\n",
    "\n",
    "A power series $\\sum c_n x^n$ is a Taylor series viewed as a sum that converges only for some $x$. The fundamental fact: there is a **radius of convergence** $R$ — the series converges for all $|x|<R$ and diverges for all $|x|>R$. The tool that finds it is the **ratio test**: form $\\left|c_n/c_{n+1}\\right|$, and its limit *is* $R$, because the series behaves geometrically with ratio $\\approx |x|/R$.\n",
    "\n",
    "Pick a power series. The plot shows $\\left|c_n/c_{n+1}\\right|$ settling onto $R$ (red line), and the green band on the $x$-axis is the interval $(-R,R)$ where the series genuinely equals its function. Watch $e^x$ fill the *whole line* ($R=\\infty$, the ratio marching off the top) while $\\frac1{1-x}$ stops dead at $\\pm 1$ — *that* is the wall at $\\pm1$ from Chapter 7, now derived rather than drawn."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e2ab8f34",
   "metadata": {},
   "outputs": [],
   "source": [
    "import math\n",
    "\n",
    "# Each power series: a scalar coefficient rule c(k) for k >= 0, and true R.\n",
    "_POWER = {\n",
    "    'e^x = \\u03a3 x\\u207f/n!   (R = \\u221e)':\n",
    "        (lambda k: 1.0 / math.factorial(k),  np.inf),\n",
    "    '1/(1-x) = \\u03a3 x\\u207f   (R = 1)':\n",
    "        (lambda k: 1.0,                      1.0),\n",
    "    '\\u03a3 x\\u207f/n   (R = 1)':\n",
    "        (lambda k: 1.0 / k if k >= 1 else 1.0, 1.0),\n",
    "}\n",
    "\n",
    "def _radius(series='e^x = \\u03a3 x\\u207f/n!   (R = \\u221e)', terms=12):\n",
    "    crule, R = _POWER[series]\n",
    "    n = np.arange(0, terms)\n",
    "    c = np.array([crule(int(k)) for k in n], dtype=float)\n",
    "    # ratio |c_n / c_{n+1}| -> R\n",
    "    ratios = np.abs(c[:-1] / c[1:])\n",
    "    ks = n[:-1]\n",
    "\n",
    "    fig, (axR, axX) = plt.subplots(2, 1, figsize=(7.2, 5.6),\n",
    "                                   gridspec_kw={'height_ratios': [3, 1]})\n",
    "    # --- top: the ratio settling onto R ---\n",
    "    axes(axR, series)\n",
    "    axR.scatter(ks, ratios, s=34, color=BLUE, zorder=3,\n",
    "                label='$|c_n / c_{n+1}|$')\n",
    "    axR.plot(ks, ratios, color=BLUE, lw=0.9, alpha=0.4, zorder=1)\n",
    "    if np.isfinite(R):\n",
    "        axR.axhline(R, color=RED, lw=2.0, ls='--', zorder=2, label=f'R = {R:g}')\n",
    "        axR.set_ylim(0, max(R * 1.6, ratios.max() * 1.1))\n",
    "    else:\n",
    "        axR.set_ylim(0, ratios.max() * 1.1)\n",
    "        axR.text(0.5, 0.9, 'ratio marches off \\u2192  R = \\u221e',\n",
    "                 transform=axR.transAxes, ha='center', color=RED, fontsize=11)\n",
    "    axR.set_xlabel('n'); axR.set_ylabel('ratio')\n",
    "    axR.legend(loc='best', framealpha=0.9, fontsize=9)\n",
    "\n",
    "    # --- bottom: the interval (-R, R) shaded on the x-axis ---\n",
    "    axX.set_title('interval of convergence  (\\u2212R, R)', color=DARK, fontsize=11)\n",
    "    span = 3.5 if not np.isfinite(R) else max(R * 1.8, 1.5)\n",
    "    axX.set_xlim(-span, span); axX.set_ylim(-1, 1)\n",
    "    axX.axhline(0, color=DARK, lw=1.2, zorder=1)\n",
    "    if np.isfinite(R):\n",
    "        axX.axvspan(-R, R, color=SHADEG, alpha=0.8, zorder=0,\n",
    "                    label='converges')\n",
    "        for s in (-R, R):\n",
    "            axX.plot(s, 0, 'o', color=RED, ms=8, zorder=3)\n",
    "        axX.text(0, 0.45, f'(\\u2212{R:g}, {R:g})', ha='center',\n",
    "                 color=DARK, fontsize=11)\n",
    "    else:\n",
    "        axX.axvspan(-span, span, color=SHADEG, alpha=0.8, zorder=0)\n",
    "        axX.text(0, 0.45, 'the whole real line', ha='center',\n",
    "                 color=DARK, fontsize=11)\n",
    "    axX.set_yticks([]); axX.set_xlabel('x'); axX.grid(False)\n",
    "    fig.tight_layout()\n",
    "    plt.show()\n",
    "\n",
    "interact(_radius,\n",
    "         series=Dropdown(options=list(_POWER),\n",
    "                         value='e^x = \\u03a3 x\\u207f/n!   (R = \\u221e)',\n",
    "                         description='series'),\n",
    "         terms=IntSlider(value=12, min=4, max=20, step=1, description='terms'));"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cacab496",
   "metadata": {},
   "source": [
    "## 4. The bouncing ball — a geometric series with a finite total\n",
    "\n",
    "Here is the geometric series made physical. Drop a ball; each bounce returns it to a fixed fraction $r$ of its previous height. It bounces *infinitely many times* — yet the total distance it travels is **finite**, because the heights form a geometric series. Starting from height $h_0$, the ball falls $h_0$, then rises and falls $h_0 r$, then $h_0 r^2$, and so on:\n",
    "\n",
    "$$D = h_0 + 2h_0 r + 2h_0 r^2 + \\cdots = h_0\\,\\frac{1+r}{1-r}.$$\n",
    "\n",
    "Watch the bounces shrink. The read-out tracks the **running total** climbing toward that closed-form sum — infinitely many bounces, but the distance lands on a single finite number. (Here $h_0=1$ and $r=0.6$, so $D = \\frac{1.6}{0.4} = 4$.)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a4df5014",
   "metadata": {},
   "outputs": [],
   "source": [
    "_h0 = 1.0          # initial drop height\n",
    "_r = 0.6           # each bounce returns to fraction r of prior height\n",
    "_n_bounces = 7     # bounces to animate (the tail is negligible)\n",
    "_D_total = _h0 * (1.0 + _r) / (1.0 - _r)   # closed-form total distance\n",
    "\n",
    "# Peak height of bounce k (k = 0 is the initial drop height).\n",
    "_peaks = _h0 * _r ** np.arange(_n_bounces + 1)\n",
    "# x-position of the apex of each arc, spaced for a readable trajectory.\n",
    "_apex_x = np.cumsum(np.r_[0.0, np.sqrt(_peaks[:-1]) + np.sqrt(_peaks[1:])])\n",
    "\n",
    "def _trajectory(samples_per_arc=22):\n",
    "    '''Stitch the initial fall + parabolic bounce arcs into one (x, y)\n",
    "    path, with the cumulative *path length* (vertical distance) alongside.'''\n",
    "    xs, ys = [], []\n",
    "    s = np.linspace(0, 1, samples_per_arc)\n",
    "    # initial fall: straight down at x = _apex_x[0], height h0 -> 0\n",
    "    xs += [_apex_x[0]] * samples_per_arc\n",
    "    ys += list(_h0 * (1 - s))\n",
    "    # each bounce: a parabolic arc rising to its peak and back to the floor\n",
    "    for k in range(_n_bounces):\n",
    "        x0, x1 = _apex_x[k], _apex_x[k + 1]\n",
    "        peak = _peaks[k + 1]\n",
    "        xs += list(x0 + (x1 - x0) * s)\n",
    "        ys += list(4 * peak * s * (1 - s))     # 0 -> peak -> 0\n",
    "    xs, ys = np.array(xs), np.array(ys)\n",
    "    # cumulative vertical distance travelled (this is the geometric total)\n",
    "    dist = np.concatenate([[0.0], np.cumsum(np.abs(np.diff(ys)))])\n",
    "    return xs, ys, dist\n",
    "\n",
    "_X, _Y, _DIST = _trajectory()\n",
    "_FRAMES = 56\n",
    "_idx = np.linspace(1, len(_X) - 1, _FRAMES).astype(int)\n",
    "\n",
    "fig, (axB, axS) = plt.subplots(1, 2, figsize=(10.6, 4.4),\n",
    "                               gridspec_kw={'width_ratios': [3, 2]})\n",
    "\n",
    "def _frame(i):\n",
    "    j = _idx[i]\n",
    "    axB.clear(); axS.clear()\n",
    "    # --- left: the bouncing ball ---\n",
    "    axes(axB, f'bounces (r = {_r}):  ball returns to {_r:.0%} of prior height')\n",
    "    axB.plot(_X[:j], _Y[:j], color=SHADE, lw=1.6, alpha=0.7, zorder=1)\n",
    "    axB.plot(_X[j], _Y[j], 'o', color=RED, ms=13, zorder=3)\n",
    "    axB.axhline(0, color=DARK, lw=1.4, zorder=2)\n",
    "    axB.set_xlim(_apex_x[0] - 0.3, _apex_x[-1] + 0.3)\n",
    "    axB.set_ylim(0, _h0 * 1.15)\n",
    "    axB.set_xlabel('horizontal position'); axB.set_ylabel('height')\n",
    "    axB.grid(True, color=LIGHT, linewidth=0.7)\n",
    "    # --- right: running total distance climbing to D ---\n",
    "    axes(axS, 'total distance travelled')\n",
    "    axS.axhline(_D_total, color=RED, lw=2.0, ls='--', zorder=2,\n",
    "                label=f'closed form  D = {_D_total:g}')\n",
    "    axS.plot(np.arange(j), _DIST[:j], color=BLUE, lw=2.4, zorder=3,\n",
    "             label='running total')\n",
    "    axS.set_xlim(0, len(_X)); axS.set_ylim(0, _D_total * 1.12)\n",
    "    axS.set_xlabel('motion step'); axS.set_ylabel('distance')\n",
    "    axS.legend(loc='lower right', framealpha=0.9, fontsize=9)\n",
    "    axS.text(0.5, 0.06, f'so far: {_DIST[j]:.4f}  of  {_D_total:g}',\n",
    "             transform=axS.transAxes, ha='center', color=DARK, fontsize=10)\n",
    "    return []\n",
    "\n",
    "ani = animation.FuncAnimation(fig, _frame, frames=_FRAMES, interval=90)\n",
    "plt.close(fig)\n",
    "HTML(ani.to_jshtml())"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "pygments_lexer": "ipython3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}