A traffic jam from nowhere¶
With time as a third dimension, a traffic jam is a knot of paths. It moves backward while every car moves forward.
examples/traffic_helix.py
"""A traffic jam from nowhere, seen in spacetime.
Twenty-two cars drive around a ring road with no bottleneck, no merge, no accident — and within
a minute a jam appears out of nothing, just as it did in Sugiyama and colleagues' experiment on
a real track (New J. Phys., 2008). Each driver speeds toward the speed their gap allows and reacts
with a lag: the optimal-velocity model ẍₙ = a [V(xₙ₊₁ − xₙ) − ẋₙ], V(h) = tanh(h − 2) + tanh 2
(Bando et al., 1995). With the gap at 2 and a = 1, smooth flow is unstable: a tiny wobble grows
into a stop-and-go wave. Lift time into the third dimension (the last 70 time units, the present
on top) and each car's worldline winds up a cylinder; the jam is where worldlines flatten and
crowd — and that knot leans back, moving against the traffic while every car keeps going forward.
"""
import numpy as np
import manimgx as m
N = 22
LENGTH = 44.0 # the ring's length (gap 2 per car)
SENSITIVITY = 1.0
RADIUS = 2.6 # the ring on screen
TIME_SCALE = 12.5 # model time per second of video
RISE = 0.05 # height per unit of model time
WINDOW = 70.0 # how much of the past the cylinder shows (model time)
SPEED_STOPS = ["#e63946", "#f4a261", "#e9c46a", "#8ac926", "#2ec4b6"] # stopped … fast
V_MAX = 1.0 + np.tanh(2.0)
def optimal_velocity(gap: np.ndarray) -> np.ndarray:
return np.tanh(gap - 2) + np.tanh(2)
def colormap(values: np.ndarray, stops: list[str]) -> np.ndarray:
rgb = np.array([m.ManimColor(s).to_rgb() for s in stops])
x = np.clip(values, 0, 1) * (len(stops) - 1)
i = np.minimum(x.astype(int), len(stops) - 2)
f = (x - i)[:, None]
out = np.ones((len(values), 4))
out[:, :3] = rgb[i] * (1 - f) + rgb[i + 1] * f
return out
class Road:
"""The cars' positions and speeds, integrated with RK4; their histories for the worldlines."""
def __init__(self) -> None:
self.x = np.arange(N) * LENGTH / N
self.x[0] += 0.1 # one driver a little out of place
self.v = np.full(N, optimal_velocity(np.array(2.0)))
self.t = 0.0
self.times: list[float] = [0.0]
self.xs: list[np.ndarray] = [self.x.copy()]
self.vs: list[np.ndarray] = [self.v.copy()]
def rates(self, x: np.ndarray, v: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
gap = (np.roll(x, -1) - x) % LENGTH
return v, SENSITIVITY * (optimal_velocity(gap) - v)
def advance(self, duration: float, dt: float = 0.05) -> None:
for _ in range(max(1, round(duration / dt))):
k1 = self.rates(self.x, self.v)
k2 = self.rates(self.x + dt / 2 * k1[0], self.v + dt / 2 * k1[1])
k3 = self.rates(self.x + dt / 2 * k2[0], self.v + dt / 2 * k2[1])
k4 = self.rates(self.x + dt * k3[0], self.v + dt * k3[1])
self.x = self.x + dt / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0])
self.v = self.v + dt / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1])
self.t += dt
self.times.append(self.t)
self.xs.append(self.x.copy())
self.vs.append(self.v.copy())
def on_ring(x: np.ndarray, height: np.ndarray, radius: float = RADIUS) -> np.ndarray:
angle = m.TAU * x / LENGTH
return np.stack([radius * np.cos(angle), radius * np.sin(angle), height], -1)
def worldlines(
road: Road, lift: float, sides: int = 6
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Every car's worldline over the last WINDOW of model time, as a thin tube on the cylinder
(the present on top), colored by the car's speed: vertices, triangles, colors. A curve on a
cylinder has a natural frame — the outward radial direction and its cross with the tangent —
so no transport along the curve is needed."""
times = np.array(road.times)
keep = times >= road.t - WINDOW
xs, vs = np.array(road.xs)[keep], np.array(road.vs)[keep] # (samples, cars)
z = lift * RISE * (times[keep] - (road.t - WINDOW))[:, None] * np.ones((1, N))
centers = on_ring(xs, z).transpose(1, 0, 2) # (cars, samples, 3)
angle = m.TAU * xs.T / LENGTH
radial = np.stack([np.cos(angle), np.sin(angle), np.zeros_like(angle)], -1)
tangent = np.gradient(centers, axis=1)
tangent /= np.linalg.norm(tangent, axis=-1, keepdims=True) + 1e-12
across = np.cross(tangent, radial)
across /= np.linalg.norm(across, axis=-1, keepdims=True) + 1e-12
out = np.cross(across, tangent)
a = np.linspace(0, m.TAU, sides, endpoint=False)
ring = (
np.cos(a)[:, None] * out[:, :, None, :]
+ np.sin(a)[:, None] * across[:, :, None, :]
)
verts = (centers[:, :, None, :] + 0.028 * ring).reshape(-1, 3)
cars, samples = centers.shape[:2]
i = np.arange(samples - 1)[:, None]
j = np.arange(sides)[None, :]
k = (j + 1) % sides
one = np.stack(
[
np.stack([i * sides + j, (i + 1) * sides + j, (i + 1) * sides + k], -1),
np.stack([i * sides + j, (i + 1) * sides + k, i * sides + k], -1),
],
2,
).reshape(-1, 3)
tris = (one[None] + (np.arange(cars) * samples * sides)[:, None, None]).reshape(
-1, 3
)
speed = np.repeat(vs.T.reshape(-1), sides)
return verts, tris, colormap(speed / V_MAX, SPEED_STOPS)
class TrafficHelix(m.ThreeDScene):
def construct(self) -> None:
road = Road()
lift = m.ValueTracker(0.0) # 0: the present only (a flat ring); 1: time rises
def step(_: m.Mobject, dt: float) -> None:
road.advance(TIME_SCALE * dt)
clock = m.Mobject()
clock.add_updater(step)
lines = m.MeshMobject(
np.zeros((3, 3)), np.array([[0, 1, 2]]), shade_in_3d=True
) # drawn once time rises
def redraw(mob: m.Mobject) -> None:
assert isinstance(mob, m.MeshMobject)
if lift.get_value() > 0 and len(road.times) > 2:
verts, tris, rows = worldlines(road, lift.get_value())
mob.points, mob.triangles = verts, tris
mob.paint = mob.paint.but(fill=rows)
lines.add_updater(redraw)
cars = m.Group(*[m.Dot3D(radius=0.12, color=m.WHITE) for _ in range(N)])
def drive(group: m.Mobject) -> None:
height = lift.get_value() * RISE * WINDOW
colors = colormap(road.v / V_MAX, SPEED_STOPS)
for k, car in enumerate(group.submobjects):
car.move_to(on_ring(road.x[k : k + 1], np.array([height]))[0])
car.set_color(m.ManimColor(colors[k, :3]))
cars.add_updater(drive)
track = m.Circle(radius=RADIUS, color=m.GREY_D, stroke_width=10)
title = m.Text("A traffic jam from nowhere", font_size=38).to_corner(m.UL)
subtitle = m.Text(
"22 cars on a ring road; no bottleneck (Sugiyama et al., 2008)",
font_size=22,
).set_color(m.GREY_B)
subtitle.next_to(title, m.DOWN, aligned_edge=m.LEFT, buff=0.12)
model = m.MathTex(
r"\ddot x_n = a\,\bigl[V(x_{n+1} - x_n) - \dot x_n\bigr]", font_size=32
).to_corner(m.UR)
bar = m.Rectangle(
width=2.4, height=0.18, stroke_width=0, fill_opacity=1
).set_fill(color=SPEED_STOPS, opacity=1)
bar.set_sheen_direction(m.RIGHT)
legend = (
m.VGroup(m.Text("stopped", font_size=20), bar, m.Text("fast", font_size=20))
.arrange(m.RIGHT, buff=0.15)
.next_to(model, m.DOWN, aligned_edge=m.RIGHT, buff=0.3)
)
self.add_fixed_in_frame_mobjects(title, subtitle, model, legend)
# 0–8 s: from above: smooth flow, then a wobble grows into a jam
self.set_camera_orientation(
phi=0, theta=-90 * m.DEGREES, focal_distance=40, zoom=1.0
)
self.add(track, clock, lines, cars)
self.wait(8)
# 8–14 s: lift time into the third dimension: worldlines wind up a cylinder
up = (
m.Text("height = time", font_size=26)
.set_color(m.YELLOW)
.next_to(legend, m.DOWN, aligned_edge=m.RIGHT, buff=0.25)
)
self.add_fixed_in_frame_mobjects(up)
self.remove(up)
self.move_camera(
phi=66 * m.DEGREES,
theta=-70 * m.DEGREES,
focal_distance=20,
zoom=0.9,
frame_center=np.array([0.0, 0.0, 1.75]),
added_anims=[lift.animate.set_value(1.0), m.FadeIn(up)],
run_time=6,
)
# 14–30 s: the jam is the band of flat, crowded, red worldlines — and it leans backward
self.begin_ambient_camera_rotation(rate=0.08)
self.wait(10)
closing = m.Text(
"The jam moves backward while every car moves forward.", font_size=28
).to_edge(m.DOWN, buff=0.35)
self.add_fixed_in_frame_mobjects(closing)
self.remove(closing)
self.play(m.FadeIn(closing), run_time=1)
self.wait(5)
if __name__ == "__main__":
TrafficHelix().render("traffic_helix.mp4")