Skip to content

The Lorenz attractor

Eight nearly identical starting points trace a butterfly and then drift apart. Both paths follow the Lorenz equations, showing how a deterministic system can be chaotic.

examples/lorenz_attractor.py
"""Chaos: the Lorenz attractor.

In 1963 Edward Lorenz cut a model of convection down to three equations, ẋ = σ(y − x),
ẏ = x(ρ − z) − y, ż = xy − βz, and found that it never settles and never repeats: a point winds
around one lobe, then the other, in an order no one can predict. Here eight points start a
ten-thousandth apart. For a while they travel as one curve; then the difference doubles about
every three quarters of a unit of time, they split, and each weaves the same butterfly its own
way. The paths are integrated as they are drawn (fourth-order Runge–Kutta, σ = 10, ρ = 28,
β = 8/3).
"""

import numpy as np

import manimgx as m

WALL = 7.5  # the README wall's 5 seconds start here
SIGMA, RHO, BETA = 10.0, 28.0, 8.0 / 3.0
DT, STEPS = 0.004, 6500  # 26 units of time
SETTLE = 3000  # steps run before the paths start, so that they start on the attractor
PATHS = 8
SCALE = 0.115  # screen units per unit of the system
GROW = 22.0  # seconds the paths take to grow


def lorenz(p: np.ndarray) -> np.ndarray:
    x, y, z = p[..., 0], p[..., 1], p[..., 2]
    return np.stack([SIGMA * (y - x), x * (RHO - z) - y, x * y - BETA * z], axis=-1)


def step(p: np.ndarray) -> np.ndarray:
    """One step of fourth-order Runge–Kutta."""
    a = lorenz(p)
    b = lorenz(p + DT / 2 * a)
    c = lorenz(p + DT / 2 * b)
    d = lorenz(p + DT * c)
    return p + DT / 6 * (a + 2 * b + 2 * c + d)


def integrate(starts: np.ndarray) -> np.ndarray:
    """Each start's path, (paths, STEPS + 1, 3)."""
    out = np.empty((len(starts), STEPS + 1, 3))
    p = starts.astype(float)
    out[:, 0] = p
    for k in range(STEPS):
        p = step(p)
        out[:, k + 1] = p
    return out


class LorenzAttractor(m.ThreeDScene):
    def construct(self) -> None:
        settled = np.array([0.0, 1.0, 1.05])
        for _ in range(SETTLE):
            settled = step(settled)
        starts = settled + np.outer(np.arange(PATHS), [1e-4, 0, 0])
        paths = integrate(starts)
        center = np.array([0.0, 0.0, 25.0])
        colors = m.color_gradient(
            [m.BLUE, m.TEAL, m.GREEN, m.YELLOW, m.GOLD, m.RED, m.MAROON, m.PURPLE],
            PATHS,
        )

        def to_screen(points: np.ndarray) -> np.ndarray:
            return (points - center) * SCALE

        clock = m.ValueTracker(0.0)  # how much of the paths is drawn, 0 to 1
        fulls, curves, heads = [], m.VGroup(), m.VGroup()
        for path, color in zip(paths, colors, strict=True):
            full = m.VMobject(stroke_color=color, stroke_width=2.2, stroke_opacity=0.85)
            full.set_points_as_corners(to_screen(path))
            fulls.append(full)
            curve = full.copy()
            curve.pointwise_become_partial(full, 0, 0)
            curves.add(curve)
            heads.add(m.Dot3D(to_screen(path[0]), radius=0.07, color=color))

        def grow(group: m.VGroup) -> None:
            t = clock.get_value()
            k = min(int(t * STEPS), STEPS)
            for curve, full, head, path in zip(
                curves, fulls, heads, paths, strict=True
            ):
                assert isinstance(curve, m.VMobject)
                curve.pointwise_become_partial(full, 0, max(t, 1e-6))
                head.move_to(to_screen(path[k]))

        everything = m.VGroup(curves, heads)
        everything.add_updater(grow)

        equations = m.MathTex(
            r"\dot x &= \sigma (y - x) \\ \dot y &= x(\rho - z) - y \\ \dot z &= xy - \beta z",
            font_size=44,
        ).to_corner(m.UL, buff=0.5)
        self.add_fixed_in_frame_mobjects(equations)
        self.remove(equations)

        # the lobes lie along x = y: the camera swings slowly through the view that shows
        # them side by side (θ = −45°)
        self.set_camera_orientation(
            phi=68 * m.DEGREES, theta=-88 * m.DEGREES, zoom=1.05
        )
        self.begin_ambient_camera_rotation(rate=0.05)
        self.play(m.FadeIn(heads), m.Write(equations), run_time=1.5)
        self.add(everything)
        self.play(clock.animate.set_value(1.0), run_time=GROW, rate_func=m.linear)
        everything.clear_updaters()
        self.play(m.FadeOut(heads), run_time=1)
        self.wait(2.5)


if __name__ == "__main__":
    LorenzAttractor().render("lorenz_attractor.mp4")