Skip to content

Why a Taylor series stops

The Taylor series of 1/(1 + x²) stops converging at |x| = 1. Two poles at ±i are the reason, and only the complex plane shows them.

examples/taylor_poles.py
"""Why the Taylor series of 1/(1 + x²) gives up at |x| = 1.

On the real line f(x) = 1/(1 + x²) is as smooth as a function can be, yet its Taylor series
1 − x² + x⁴ − x⁶ + ⋯ explodes as soon as |x| > 1. The reason is off the line: in the complex
plane f(z) = 1/(1 + z²) has poles at z = ±i, and a power series converges exactly in the largest
disk around its center that avoids every singularity. Seen from the side, the bell curve is a
slice of the landscape |f(z)|, colored by arg f(z); the disk |z| < 1 runs into the two towers. Move
the center to a and the radius becomes the distance to ±i, √(1 + a²).
"""

import numpy as np

import manimgx as m

UNIT = 1.45  # screen units per unit of x, y and |f|
X_RANGE = (-2.4, 2.4)
Y_RANGE = (-1.9, 1.9)
CLIP = 2.3  # |f| is cut off here (the poles go to infinity)


def f(z: np.ndarray) -> np.ndarray:
    return 1 / (1 + z * z)


def domain_colors(z: np.ndarray) -> np.ndarray:
    """RGBA rows: hue from arg f(z), a little brighter where |f| is large."""
    w = f(z)
    hue = (np.angle(w) / m.TAU) % 1.0
    k = np.arange(3)[None, :]
    rgb = 0.5 + 0.5 * np.cos(m.TAU * (hue[:, None] - k / 3))  # a smooth hue wheel
    light = np.clip(0.55 + 0.25 * np.tanh(np.log(np.abs(w) + 1e-9)), 0, 1)
    rows = np.ones((len(z), 4))
    rows[:, :3] = 0.25 + 0.75 * rgb * light[:, None]
    return rows


def partial_sum(x: np.ndarray, terms: int) -> np.ndarray:
    return sum((-1) ** k * x ** (2 * k) for k in range(terms))


def grid_mesh(nu: int, nv: int, fill_color: str = "#ffffff") -> m.MeshMobject:
    """A smooth lit mesh over an (nu + 1) × (nv + 1) grid of vertices (vertex i·(nv + 1) + j),
    each cell two triangles; its points are set by whoever shapes it."""
    idx = np.arange((nu + 1) * (nv + 1)).reshape(nu + 1, nv + 1)
    a, b, c, d = idx[:-1, :-1], idx[1:, :-1], idx[1:, 1:], idx[:-1, 1:]
    cells = np.stack([np.stack([a, b, c], -1), np.stack([a, c, d], -1)], 2)
    return m.MeshMobject(
        np.zeros(((nu + 1) * (nv + 1), 3)),
        cells.reshape(-1, 3),
        shade_in_3d=True,
        fill_color=fill_color,
    )


def camera_axes(phi: float, theta: float, gamma: float) -> np.ndarray:
    """The camera's axes for its orbit angles, as rows: right, up, and toward the viewer."""

    def spin(a: float) -> np.ndarray:  # a turn about z
        return np.array(
            [[np.cos(a), -np.sin(a), 0.0], [np.sin(a), np.cos(a), 0.0], [0.0, 0.0, 1.0]]
        )

    c, s = np.cos(phi), np.sin(phi)
    tilt = np.array(
        [[1.0, 0.0, 0.0], [0.0, c, s], [0.0, -s, c]]
    )  # a turn by −φ about x
    return spin(gamma) @ tilt @ spin(-theta - np.pi / 2)


def billboard(label: m.Mobject, camera: m.Camera, shown: m.ValueTracker) -> m.Mobject:
    """Keep `label` where it is, turned every frame to face the camera, at opacity `shown` (fade it
    by animating `shown`: a FadeIn would fight the turning)."""
    anchor = label.get_center()
    parts = [
        (part, part.points - anchor) for part in label.family_members_with_points()
    ]

    def face(_: m.Mobject) -> None:
        axes = camera_axes(camera.get_phi(), camera.get_theta(), camera.get_gamma())
        for part, flat in parts:
            part.points = anchor + flat[:, :1] * axes[0] + flat[:, 1:2] * axes[1]
        label.set_opacity(shown.get_value())

    label.add_updater(face)
    face(label)
    return label


class TaylorPoles(m.ThreeDScene):
    def construct(self) -> None:
        # the side view: the x–z plane seen face on, nearly orthographic (a 2D plot)
        self.set_camera_orientation(
            phi=90 * m.DEGREES,
            theta=-90 * m.DEGREES,
            focal_distance=60,
            zoom=1.0,
            frame_center=np.array([0.0, 0.0, 1.0]),
        )
        real_axis = m.Line(
            UNIT * X_RANGE[0] * m.RIGHT, UNIT * X_RANGE[1] * m.RIGHT, stroke_width=2
        )
        height_axis = m.Line(m.ORIGIN, UNIT * CLIP * m.OUT, stroke_width=2)
        ticks = m.VGroup(
            *[
                m.Line(
                    UNIT * x * m.RIGHT + 0.08 * m.IN,
                    UNIT * x * m.RIGHT + 0.08 * m.OUT,
                    stroke_width=2,
                )
                for x in (-2, -1, 1, 2)
            ]
        )
        tick_labels = m.VGroup(
            *[
                m.MathTex(str(x), font_size=28)
                .move_to(UNIT * x * m.RIGHT + 0.35 * m.IN)
                .rotate(m.PI / 2, m.RIGHT)
                for x in (-2, -1, 1, 2)
            ]
        )

        def graph(
            values: np.ndarray, xs: np.ndarray, color: str, width: float = 4
        ) -> m.VMobject:
            keep = np.abs(values) < CLIP + 0.4
            mob = m.VMobject(stroke_color=color, stroke_width=width)
            points = np.stack(
                [UNIT * xs, 0 * xs, UNIT * np.clip(values, -1.0, CLIP + 0.4)], 1
            )
            mob.set_points_smoothly(points[keep])
            return mob

        xs = np.linspace(*X_RANGE, 400)
        bell = graph(f(xs.astype(complex)).real, xs, m.WHITE, 5)
        edges = m.VGroup(
            *[
                m.DashedLine(
                    UNIT * x * m.RIGHT,
                    UNIT * x * m.RIGHT + UNIT * CLIP * m.OUT,
                    stroke_width=2,
                    color=m.GREY_B,
                )
                for x in (-1, 1)
            ]
        )

        title = m.Text(
            "Why does this Taylor series stop at 1?", font_size=34
        ).to_corner(m.UL)
        series = m.MathTex(
            r"\frac{1}{1+x^2} = 1 - x^2 + x^4 - x^6 + \cdots", font_size=34
        )
        series.next_to(title, m.DOWN, aligned_edge=m.LEFT, buff=0.25)
        terms_label = m.MathTex(r"n =", font_size=32)
        terms_value = m.Integer(1, font_size=32)
        terms_row = (
            m.VGroup(terms_label, terms_value)
            .arrange(m.RIGHT, buff=0.15)
            .to_corner(m.UR)
        )
        self.add_fixed_in_frame_mobjects(title, series, terms_row)
        self.remove(series, terms_row)

        self.add(real_axis, height_axis, ticks, tick_labels)
        self.play(m.Create(bell), m.FadeIn(series), run_time=2)
        # 2–9 s: partial sums hug the curve inside (−1, 1) and fly off outside
        partial = graph(partial_sum(xs, 1), xs, m.YELLOW, 3.5)
        self.play(m.FadeIn(partial), m.FadeIn(terms_row), m.Create(edges), run_time=1)
        for terms in range(2, 13):
            terms_value.set_value(terms)
            self.play(
                m.Transform(partial, graph(partial_sum(xs, terms), xs, m.YELLOW, 3.5)),
                run_time=0.5,
            )
        # 9–13 s: tilt the camera: the real line lies in the complex plane
        plane = m.NumberPlane(
            x_range=(*X_RANGE, 0.5),
            y_range=(*Y_RANGE, 0.5),
            x_length=UNIT * 4.8,
            y_length=UNIT * 3.8,
            background_line_style={
                "stroke_color": m.BLUE_E,
                "stroke_width": 1,
                "stroke_opacity": 0.6,
            },
            axis_config={"stroke_opacity": 0},
        )
        self.play(
            m.FadeOut(partial), m.FadeOut(edges), m.FadeOut(terms_row), run_time=1
        )
        self.add(plane)
        self.move_camera(
            phi=62 * m.DEGREES,
            theta=-62 * m.DEGREES,
            focal_distance=20,
            zoom=0.95,
            frame_center=np.array([-1.1, 0.4, 0.9]),
            added_anims=[m.FadeIn(plane)],
            run_time=3,
        )

        # 13–19 s: the landscape |f(z)| rises, colored by arg f
        nu, nv = 241, 191  # odd: no grid point lands exactly on a pole
        xg = np.linspace(*X_RANGE, nu + 1)
        yg = np.linspace(*Y_RANGE, nv + 1)
        zz = xg[:, None] + 1j * yg[None, :]
        height = np.minimum(np.abs(f(zz)), CLIP)
        base_colors = domain_colors(zz.ravel())
        rise = m.ValueTracker(0.0)
        center = m.ValueTracker(0.0)  # the center of the expansion
        spotlight = m.ValueTracker(
            0.0
        )  # 0: all lit; 1: only the disk of convergence lit
        land = grid_mesh(nu, nv)

        def lift(mob: m.Mobject) -> None:
            xx, yy = np.meshgrid(xg, yg, indexing="ij")
            mob.points = UNIT * np.stack(
                [xx, yy, rise.get_value() * height], -1
            ).reshape(-1, 3)
            a = center.get_value()
            inside = np.abs(zz.ravel() - a) < np.hypot(a, 1.0)
            shade = 1 - spotlight.get_value() * 0.6 * ~inside
            rows = base_colors.copy()
            rows[:, :3] *= shade[:, None]
            rows[:, 3] = min(1.0, 4 * rise.get_value())
            mob.paint = mob.paint.but(fill=rows)

        lift(land)
        land.add_updater(lift)
        poles = m.VGroup(
            *[
                m.MathTex(t, font_size=36).move_to(
                    UNIT * np.array([0.35, y, CLIP + 0.25])
                )
                for t, y in ((r"i", 1), (r"-i", -1))
            ]
        )
        poles_shown = m.ValueTracker(0.0)
        for pole in poles:
            billboard(pole, self.camera, poles_shown)
        complex_series = m.MathTex(
            r"f(z) = \frac{1}{1+z^2}\ \text{has poles at } z = \pm i", font_size=32
        )
        complex_series.next_to(title, m.DOWN, aligned_edge=m.LEFT, buff=0.25)
        self.add_fixed_in_frame_mobjects(complex_series)
        self.remove(complex_series)
        self.add(land)
        self.play(
            rise.animate.set_value(1.0),
            m.FadeOut(series),
            m.FadeIn(complex_series),
            run_time=3.5,
            rate_func=m.smooth,
        )
        self.add(poles)
        self.play(
            poles_shown.animate.set_value(1.0),
            self.camera.theta_tracker.animate.set_value(-40 * m.DEGREES),
            run_time=2,
        )

        # 19–30 s: the disk of convergence, drawn on the landscape, and moving its center
        def on_surface(z: np.ndarray, lift_by: float = 0.03) -> np.ndarray:
            h = np.minimum(np.abs(f(z)), CLIP) + lift_by
            return UNIT * np.stack([z.real, z.imag, h], 1)

        def ring_points() -> np.ndarray:
            a = center.get_value()
            t = np.linspace(0, m.TAU, 361)
            return on_surface(a + np.hypot(a, 1.0) * np.exp(1j * t) + 1e-4j)

        ring = m.VMobject(stroke_color=m.YELLOW, stroke_width=6, shade_in_3d=True)
        ring.set_points_as_corners(ring_points())
        ring.add_updater(lambda mob: mob.set_points_as_corners(ring_points()))

        def interval_points() -> np.ndarray:
            a = center.get_value()
            r = np.hypot(a, 1.0)
            x = np.linspace(max(a - r, X_RANGE[0]), min(a + r, X_RANGE[1]), 200)
            return on_surface(x + 0j, 0.04)

        interval = m.VMobject(stroke_color=m.YELLOW, stroke_width=9, shade_in_3d=True)
        interval.set_points_smoothly(interval_points())
        interval.add_updater(lambda mob: mob.set_points_smoothly(interval_points()))

        radius_value = m.DecimalNumber(1.0, num_decimal_places=2, font_size=32)
        center_value = m.DecimalNumber(0.0, num_decimal_places=2, font_size=32)
        readout = m.VGroup(
            m.VGroup(m.MathTex(r"a =", font_size=32), center_value).arrange(
                m.RIGHT, buff=0.15
            ),
            m.VGroup(
                m.MathTex(r"\text{radius} =", font_size=32),
                radius_value,
                m.MathTex(r"= |a \mp i| = \sqrt{1+a^2}", font_size=32),
            ).arrange(m.RIGHT, buff=0.15),
        ).arrange(m.DOWN, aligned_edge=m.LEFT)
        readout.next_to(complex_series, m.DOWN, aligned_edge=m.LEFT, buff=0.35)
        center_value.add_updater(lambda d: d.set_value(center.get_value()))
        # measured: the distance from the center to the nearest pole
        radius_value.add_updater(
            lambda d: d.set_value(
                float(min(abs(center.get_value() - p) for p in (1j, -1j)))
            )
        )
        closing = m.Text(
            "A power series converges out to the nearest singularity.", font_size=28
        )
        closing.to_edge(m.DOWN, buff=0.3)
        self.add_fixed_in_frame_mobjects(readout, closing)
        self.remove(readout, closing)
        self.play(
            m.Create(ring),
            m.FadeIn(interval),
            m.FadeIn(readout),
            spotlight.animate.set_value(1.0),
            run_time=2.5,
        )
        self.wait(0.5)
        self.play(center.animate.set_value(1.5), run_time=4)
        self.play(center.animate.set_value(0.0), m.FadeIn(closing), run_time=3.5)
        self.wait(1.5)


if __name__ == "__main__":
    TaylorPoles().render("taylor_poles.mp4")