Cherenkov light¶
A charge moving faster than light does in water leaves a cone of blue light. The film measures the cone's angle against the formula.

examples/cherenkov_cone.py
"""Outrunning light: a charge in water leaves a cone of blue light behind it.
Light in water travels at c/n (n = 1.33). Every point a charge passes sends out a wavelet growing
at c/n: below β = 1/n they nest; above it they pile up on a cone, the optical sonic boom, and
light leaves along its normal at the Cherenkov angle cos θ = 1/(nβ) (Cherenkov 1934; Frank & Tamm
1937), 41° in water as β → 1. On screen θ is fitted to the drawn wavefronts at three speeds;
photons leave the track at θ, as bright as Frank–Tamm says (∝ sin²θ). When the charge slows below
c/n the light goes out, and the shell already emitted lands on a wall of photomultipliers as a
ring: how Super-Kamiokande sees a muon.
"""
from collections.abc import Callable
import numpy as np
import manimgx as m
N_WATER = 1.33
WAVE = 1.15 # c/n: how fast every wavelet grows (scene units per second)
LIGHT = N_WATER * WAVE # c
LIFE = 2.8 # seconds a wavelet is drawn behind the charge
# photons are absorbed at this age (the water's attenuation, shortened)
PHOTON_LIFE = 3.5
TICK = 1 / 60
RING_EVERY = 6 # simulation ticks between drawn wavelets (0.1 s)
# photons leave every tick at these azimuths: each azimuth traces one line of the cone
AZIMUTHS = 96
PHOTON_CAP = AZIMUTHS * 220
APEX_X = 2.0 # where the charge sits on screen while the camera follows it
FIT_AGES = (0.15, 0.8) # the envelope is fitted to the wavelets just behind the charge
PMT_PITCH = 0.15
PMT_RADIUS = 3.4
BRAKE = 2.4 # the charge starts to slow down this far before the wall
WATER = "#020a17"
RING_BLUE = "#8fd3ff"
CHERENKOV = ["#ffffff", "#9ddcff", "#3fa9ff", "#1f5fd6"]
HIT_TIME = ["#fff3b0", "#ffb347", "#ff5e62", "#c84bff", "#4b6bff"]
def colormap(values: np.ndarray, stops: list[str]) -> np.ndarray:
"""(n, 4) RGBA rows for values in [0, 1], interpolated through color stops."""
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
def cloud(count: int, size: float) -> m.PMobject:
mob = m.PMobject(stroke_width=size)
mob.add_points(np.zeros((count, 3)), rgbas=np.zeros((count, 4)))
return mob
class Tank:
"""The charge, its wavelets and its photons, in the lab frame; the camera follows the charge
(`shift` is the lab x drawn at the screen's x = 0) until `follow` is animated to 0.
"""
def __init__(self, beta: m.ValueTracker, follow: m.ValueTracker) -> None:
self.beta = beta
self.follow = follow
self.time = 0.0
self.ticks = 0
self.x = 0.0
self.speed = beta.get_value() # measured: distance per tick over c
self.shift = -APEX_X
self.wall = np.inf
self.brake_time = np.inf # when the charge began to slow down
rings = int(LIFE / (RING_EVERY * TICK)) + 2
self.ring_x = np.zeros(rings)
self.ring_t = np.full(rings, -np.inf)
self.photon_x = np.zeros(PHOTON_CAP) # emission point on the track
self.photon_dir = np.zeros((PHOTON_CAP, 3))
self.photon_t = np.full(PHOTON_CAP, -np.inf)
# brightness per photon: Frank–Tamm, ∝ sin²θ per unit length
self.photon_w = np.zeros(PHOTON_CAP)
self.alive = np.zeros(PHOTON_CAP, bool)
self.next_photon = 0
# the wall's photomultipliers: a square grid of tubes inside a disc, facing the track
g = np.arange(-PMT_RADIUS, PMT_RADIUS + 1e-9, PMT_PITCH)
yy, zz = np.meshgrid(g, g, indexing="ij")
self.pmt_side = len(g)
self.pmt_inside = (np.hypot(yy, zz) <= PMT_RADIUS).ravel()
self.pmt_yz = np.stack([yy.ravel(), zz.ravel()], axis=1)
self.pmt_flash = np.zeros(len(self.pmt_yz))
self.pmt_charge = np.zeros(len(self.pmt_yz))
self.pmt_first = np.full(len(self.pmt_yz), np.inf)
# ── the simulation: one 60 Hz tick ──────────────────────────────────────────────────────
def step(self, dt: float) -> None:
if dt <= 0:
return
beta = self.beta.get_value()
if beta < 0.985 and self.x > 0 and np.isfinite(self.wall):
self.brake_time = min(self.brake_time, self.time)
before = self.x
self.x += beta * LIGHT * dt
self.speed = (self.x - before) / dt / LIGHT
self.shift += self.follow.get_value() * (self.x - before)
self.time += dt
self.ticks += 1
if beta > 0.3 and self.ticks % RING_EVERY == 0:
k = int(np.argmin(self.ring_t)) # reuse the oldest slot
self.ring_x[k], self.ring_t[k] = self.x, self.time
if N_WATER * beta > 1: # faster than light in water: it shines
self.emit(beta)
self.alive &= self.time - self.photon_t < PHOTON_LIFE
self.absorb()
self.pmt_flash *= np.exp(-dt / 0.35)
def emit(self, beta: float) -> None:
"""Photons leave the track at the Cherenkov angle, all around it."""
cos_t = 1 / (N_WATER * beta)
sin2 = 1 - cos_t**2
count = AZIMUTHS
idx = (self.next_photon + np.arange(count)) % PHOTON_CAP
self.next_photon = (self.next_photon + count) % PHOTON_CAP
az = np.linspace(0, 2 * np.pi, count, endpoint=False)
sin_t = np.sqrt(sin2)
self.photon_w[idx] = beta * sin2 / (1 - 1 / N_WATER**2)
self.photon_dir[idx] = np.stack(
[np.full(count, cos_t), sin_t * np.cos(az), sin_t * np.sin(az)], axis=1
)
self.photon_x[idx] = self.x
self.photon_t[idx] = self.time
self.alive[idx] = True
def photons(self) -> np.ndarray:
"""Lab positions of all photon slots."""
age = self.time - self.photon_t
pos = WAVE * np.where(np.isfinite(age), age, 0)[:, None] * self.photon_dir
pos[:, 0] += self.photon_x
return pos
def absorb(self) -> None:
"""Photons crossing the wall plane this tick are caught by the nearest tube."""
if not np.isfinite(self.wall):
return
pos = self.photons()
crossed = self.alive & (pos[:, 0] >= self.wall)
if not crossed.any():
return
idx = np.nonzero(crossed)[0]
# where each photon met the plane
age = (self.wall - self.photon_x[idx]) / (WAVE * self.photon_dir[idx, 0])
yz = WAVE * age[:, None] * self.photon_dir[idx, 1:]
cell = np.round((yz + PMT_RADIUS) / PMT_PITCH).astype(int)
cell = np.clip(cell, 0, self.pmt_side - 1)
tube = cell[:, 0] * self.pmt_side + cell[:, 1]
np.add.at(self.pmt_flash, tube, self.photon_w[idx])
np.add.at(self.pmt_charge, tube, self.photon_w[idx])
self.pmt_first[tube] = np.minimum(self.pmt_first[tube], self.time)
self.alive[idx] = False
# ── the measurement ─────────────────────────────────────────────────────────────────────
def fitted_angle(self) -> float | None:
"""θ fitted to the drawn wavefronts: the least-squares line through the charge touching
the wavelets emitted 0.15–0.8 s ago; the light leaves along its normal. None while the
charge is still inside its own wavelets: then there is no cone."""
age = self.time - self.ring_t
chosen = (age > FIT_AGES[0]) & (age < FIT_AGES[1])
if chosen.sum() < 3:
return None
behind = self.x - self.ring_x[chosen]
radius = WAVE * age[chosen]
s = radius / behind
if np.any(s >= 1):
return None
# the point where the line from the charge touches each wavefront (charge at origin)
tx = -behind + radius * s
ty = radius * np.sqrt(1 - s * s)
alpha = np.arctan(-(tx * ty).sum() / (tx * tx).sum())
return float(np.pi / 2 - alpha)
class CherenkovCone(m.ThreeDScene):
def construct(self) -> None:
self.camera.background_color = WATER
self.set_camera_orientation(phi=0, theta=-90 * m.DEGREES, zoom=0.92)
beta = m.ValueTracker(0.60)
follow = m.ValueTracker(1.0) # 1: the camera rides with the charge
glow = m.ValueTracker(0.0) # the photons' visibility
rings_on = m.ValueTracker(1.0)
fit_on = m.ValueTracker(0.0)
track_on = m.ValueTracker(1.0)
wall_on = m.ValueTracker(0.0)
charge_on = m.ValueTracker(1.0)
tank = Tank(beta, follow)
for _ in range(int(LIFE / TICK)): # start with a full train of wavelets
tank.step(TICK)
def here(lab_x: float) -> np.ndarray:
return np.array([lab_x - tank.shift, 0.0, 0.0])
# ── wavelets: one circle per slot of the ring buffer ────────────────────────────────
template = m.Circle(radius=1).points.copy()
circles = [
m.Circle(radius=1, stroke_width=1.7, color=RING_BLUE) for _ in tank.ring_t
]
rings = m.VGroup(*circles)
def draw_rings(_: m.Mobject) -> None:
ages = tank.time - tank.ring_t
for circle, age, x in zip(circles, ages, tank.ring_x, strict=True):
if not 0 <= age < LIFE:
circle.set_stroke(opacity=0)
continue
circle.points = template * max(WAVE * age, 0.01) + here(x)
fade = (1 - age / LIFE) ** 1.4
circle.set_stroke(opacity=0.9 * fade * rings_on.get_value())
rings.add_updater(draw_rings)
# ── photons on the cone ──────────────────────────────────────────────────────────────
photons = cloud(PHOTON_CAP, 2.6)
photon_halo = cloud(PHOTON_CAP, 8.0) # a soft bloom around every photon
def draw_photons(mob: m.Mobject) -> None:
pos = tank.photons()
pos[:, 0] -= tank.shift
age = np.nan_to_num((tank.time - tank.photon_t) / PHOTON_LIFE, posinf=1)
rows = colormap(age, CHERENKOV)
# brighter bands (photons emitted together) slide down the cone: each is where one
# wavelet touches it
pulse = 0.65 + 0.35 * np.cos(2 * np.pi * 2.5 * tank.photon_t)
fade = np.clip((1 - age) / 0.3, 0, 1) * tank.photon_w * pulse
shown = np.where(tank.alive, fade, 0) * glow.get_value()
for layer, alpha in ((mob, 1.0), (photon_halo, 0.07)):
rows[:, 3] = alpha * shown
layer.points = pos
layer.paint = layer.paint.but(fill=rows.copy())
photons.add_updater(draw_photons)
# ── water specks: they show the motion ──────────────────────────────────────────────
rng = np.random.default_rng(3)
speck_lab = rng.uniform([-12, -4.5, -4.5], [12, 4.5, 4.5], (420, 3))
specks = cloud(len(speck_lab), 1.8)
def draw_specks(mob: m.Mobject) -> None:
pos = speck_lab.copy()
pos[:, 0] = (pos[:, 0] - tank.shift + 12) % 24 - 12
rows = np.tile([0.55, 0.7, 0.85, 0.0], (len(pos), 1))
rows[:, 3] = 0.3 * np.clip((11.5 - np.abs(pos[:, 0])) / 2, 0, 1)
mob.points = pos
mob.paint = mob.paint.but(fill=rows)
specks.add_updater(draw_specks)
# ── the charge, its halo and its track ──────────────────────────────────────────────
charge = m.Dot3D(radius=0.07, color=m.WHITE)
halos = [cloud(1, w) for w in (12, 24, 44)]
track = m.VMobject(stroke_color=m.WHITE, stroke_width=1.5)
def draw_charge(mob: m.Mobject) -> None:
p = here(tank.x)
shown = charge_on.get_value()
mob.move_to(p)
mob.set_opacity(shown)
for halo, a in zip(halos, (0.5, 0.18, 0.07), strict=True):
halo.points = p[None, :]
halo.paint = halo.paint.but(
fill=np.array([[0.62, 0.86, 1.0, a * shown]])
)
track.set_points_as_corners([p - np.array([7.5, 0, 0]), p])
track.set_stroke(opacity=0.3 * track_on.get_value())
charge.add_updater(draw_charge)
# ── the fitted envelope, a light ray and its angle ──────────────────────────────────
def fit_marks() -> m.VGroup:
theta = tank.fitted_angle()
show = fit_on.get_value()
if theta is None or show < 0.01:
return m.VGroup()
alpha = np.pi / 2 - theta
apex = here(tank.x)
lines = m.VGroup(
*[
m.DashedLine(
apex,
apex
+ 3.4 * np.array([-np.cos(alpha), sign * np.sin(alpha), 0]),
dash_length=0.12,
stroke_width=2.4,
color=m.GOLD,
)
for sign in (1, -1)
]
)
# a ray from 2.6 behind the charge to the envelope, normal to it
back = 2.6
source = apex - np.array([back, 0, 0])
foot = apex + back * np.cos(alpha) * np.array(
[-np.cos(alpha), np.sin(alpha), 0]
)
ray = m.Arrow(
source,
foot,
buff=0,
stroke_width=3,
color=m.GOLD,
max_tip_length_to_length_ratio=0.12,
)
arc = m.Arc(radius=0.55, start_angle=0, angle=theta, arc_center=source)
arc.set_stroke(m.GOLD, 2)
label = m.MathTex(r"\theta", font_size=30, color=m.GOLD).move_to(
source + 0.82 * np.array([np.cos(theta / 2), np.sin(theta / 2), 0])
)
marks = m.VGroup(lines, ray, arc, label)
marks.set_opacity(show)
return marks
fit = m.always_redraw(fit_marks)
# ── the wall of photomultipliers ─────────────────────────────────────────────────────
inside = tank.pmt_inside
pmts = cloud(int(inside.sum()), 8.0)
def draw_wall(mob: m.Mobject) -> None:
x = tank.wall - tank.shift if np.isfinite(tank.wall) else 40.0
yz = tank.pmt_yz[inside]
mob.points = np.column_stack([np.full(len(yz), x), yz])
on = wall_on.get_value()
first = tank.pmt_first[inside]
hit = np.isfinite(first)
# color by arrival time, across the half second in which the ring lands
start = tank.brake_time + 2.55 if np.isfinite(tank.brake_time) else 0.0
timed = colormap(np.where(hit, (first - start) / 0.55, 0), HIT_TIME)
caught = tank.pmt_charge[inside] # photons caught, Frank–Tamm weighted
light = np.clip(caught / 4.0, 0, 1)[:, None]
flash = np.clip(tank.pmt_flash[inside] / 2.0, 0, 1)[:, None]
rows = np.tile([0.3, 0.36, 0.45, 0.0], (len(yz), 1))
rows[:, :3] = rows[:, :3] * (1 - light) + timed[:, :3] * light
rows[:, :3] = rows[:, :3] * (1 - flash) + flash
lit = np.maximum(0.3 + 0.7 * light, flash)[:, 0]
rows[:, 3] = on * np.where(hit, lit, 0.2)
mob.paint = mob.paint.but(fill=rows)
pmts.add_updater(draw_wall)
driver = m.ValueTracker(0)
driver.add_updater(lambda _, dt: tank.step(dt))
# ── heads-up display ─────────────────────────────────────────────────────────────────
title = m.Text("Outrunning light", font_size=38).to_corner(m.UL)
subtitle = m.Text(
"a charge moving faster than light does in water", font_size=22
).set_color(m.GREY_B)
subtitle.next_to(title, m.DOWN, aligned_edge=m.LEFT, buff=0.15)
law = m.MathTex(r"\cos\theta = \frac{1}{n\beta}", font_size=40)
medium = m.MathTex(r"n = 1.33\ \text{(water)}", font_size=26).set_color(
m.GREY_B
)
law_block = m.VGroup(law, medium).arrange(m.DOWN, buff=0.15)
law_block.to_corner(m.UR).shift(0.1 * m.LEFT)
def readout(tex: str, value: m.DecimalNumber) -> m.VGroup:
return m.VGroup(m.MathTex(tex, font_size=30), value).arrange(
m.RIGHT, buff=0.15
)
beta_value = m.DecimalNumber(0.6, num_decimal_places=3, font_size=30)
beta_value.add_updater(lambda d: d.set_value(tank.speed))
fitted_value = m.DecimalNumber(
0, num_decimal_places=1, font_size=30, unit=r"^\circ", color=m.GOLD
)
formula_value = m.DecimalNumber(
0, num_decimal_places=1, font_size=30, unit=r"^\circ"
)
def fitted(d: m.DecimalNumber) -> None:
theta = tank.fitted_angle()
d.set_value(0 if theta is None else np.degrees(theta))
d.set_opacity(0 if theta is None else 1)
def formula(d: m.DecimalNumber) -> None:
ok = N_WATER * tank.speed > 1
d.set_value(np.degrees(np.arccos(1 / (N_WATER * tank.speed))) if ok else 0)
d.set_opacity(1 if ok else 0)
fitted_value.add_updater(fitted)
formula_value.add_updater(formula)
rows = m.VGroup(
readout(r"\beta =", beta_value),
readout(r"\theta_{\text{fit}} =", fitted_value),
readout(r"\arccos\tfrac{1}{n\beta} =", formula_value),
).arrange(m.DOWN, aligned_edge=m.LEFT, buff=0.18)
rows.next_to(law_block, m.DOWN, buff=0.4).align_to(law_block, m.LEFT)
no_cone = m.Text("no cone", font_size=24).set_color(m.GREY_B)
no_cone.next_to(rows[1][0], m.RIGHT, buff=0.15)
no_cone.add_updater(
lambda t: t.set_opacity(0 if tank.fitted_angle() is not None else 1)
)
no_angle = m.Text("none", font_size=24).set_color(m.GREY_B)
no_angle.next_to(rows[2][0], m.RIGHT, buff=0.15)
no_angle.add_updater(
lambda t: t.set_opacity(0 if N_WATER * tank.speed > 1 else 1)
)
# θ(β): the law, the threshold, the live fit and the measured points
axes = m.Axes(
x_range=[0.6, 1.0, 0.1],
y_range=[0, 45, 15],
x_length=3.4,
y_length=2.0,
tips=False,
axis_config={"stroke_width": 1.5, "color": m.GREY_B},
)
axes.to_corner(m.DR).shift(0.25 * m.UP + 0.35 * m.LEFT)
threshold = 1 / N_WATER
curve = axes.plot(
lambda b: np.degrees(np.arccos(min(1.0, 1 / (N_WATER * b)))),
x_range=[threshold, 1.0, 0.002],
color=CHERENKOV[2],
stroke_width=2.5,
)
cut = m.DashedLine(
axes.c2p(threshold, 0), axes.c2p(threshold, 45), stroke_width=1.5
).set_color(m.GREY_B)
labels = m.VGroup(
m.MathTex(r"\beta", font_size=28).next_to(
axes.c2p(1.0, 0), m.RIGHT, buff=0.15
),
m.MathTex(r"1", font_size=24).next_to(axes.c2p(1.0, 0), m.DOWN, buff=0.12),
m.MathTex(r"\tfrac{1}{n}", font_size=26).next_to(
axes.c2p(threshold, 0), m.DOWN, buff=0.12
),
m.MathTex(r"\theta", font_size=28).next_to(
axes.c2p(0.6, 45), m.UP, buff=0.12
),
m.MathTex(r"45^\circ", font_size=22).next_to(
axes.c2p(0.6, 45), m.LEFT, buff=0.12
),
)
live = m.Dot(radius=0.06, color=m.GOLD)
def track_live(d: m.Mobject) -> None:
theta = tank.fitted_angle()
b = float(np.clip(tank.speed, 0.6, 1.0))
d.move_to(axes.c2p(b, 0 if theta is None else np.degrees(theta)))
live.add_updater(track_live)
plot = m.VGroup(axes, curve, cut, labels)
# translucent points hide what is drawn after them behind them: add those last
self.add(driver, specks, track, rings, pmts, charge, fit)
self.add(photons, photon_halo, *halos)
readouts = m.VGroup(rows, no_cone, no_angle, live)
self.add_fixed_in_frame_mobjects(title, subtitle, law_block, readouts, plot)
self.remove(title, subtitle, law_block, readouts, plot)
def stamp() -> None:
"""Put the fitted angle at this speed on the plot."""
theta = tank.fitted_angle()
if theta is None:
return
dot = m.Dot(
axes.c2p(tank.speed, np.degrees(theta)), radius=0.07, color=m.WHITE
)
ring = m.Circle(radius=0.13, stroke_width=2, color=m.WHITE).move_to(dot)
self.add_fixed_in_frame_mobjects(dot, ring)
self.remove(dot, ring)
self.play(m.FadeIn(dot, scale=2), m.FadeIn(ring, scale=0.4), run_time=0.4)
# 0–3 s: below the threshold the wavelets nest inside each other
self.play(m.Write(title), m.FadeIn(subtitle), run_time=1.5)
self.play(m.FadeIn(law_block), m.FadeIn(readouts), m.FadeIn(plot), run_time=1.2)
self.wait(0.3)
# 3–12 s: faster, through the threshold; the cone is fitted at three speeds
self.play(beta.animate.set_value(0.80), run_time=1.8)
self.play(fit_on.animate.set_value(1), run_time=0.8)
self.wait(0.6)
stamp()
self.play(beta.animate.set_value(0.90), run_time=1.2)
self.wait(0.9)
stamp()
self.play(beta.animate.set_value(0.99), run_time=1.2)
self.wait(0.9)
stamp()
# 12–18 s: the cone in 3D, lit by its photons
self.move_camera(
phi=64 * m.DEGREES,
theta=-118 * m.DEGREES,
zoom=1.0,
frame_center=np.array([0.7, -0.15, 0.3]),
run_time=3.5,
added_anims=[
glow.animate.set_value(1),
rings_on.animate.set_value(0.3),
track_on.animate.set_value(0.5),
fit_on.animate.set_value(0),
],
)
orbit = 4.5
tank.wall = tank.x + BRAKE + beta.get_value() * LIGHT * orbit
self.play(
self.camera.theta_tracker.animate.set_value(-72 * m.DEGREES),
self.camera.phi_tracker.animate.set_value(70 * m.DEGREES),
run_time=orbit,
)
# 20–23 s: the charge slows below c/n: the cone closes and goes dark; the camera lets
# it go and turns to the wall, where the light already emitted lands as a ring
swing = 2.6
wall_x = tank.wall - tank.shift - 0.15 # where it stops once the camera does
def within(
seconds: float, rate: Callable[[float], float] = m.smooth
) -> Callable[[float], float]:
"""A rate function that completes in the first `seconds` of the swing."""
return lambda t: rate(min(1.0, t * swing / seconds))
self.move_camera(
phi=84 * m.DEGREES,
theta=-10 * m.DEGREES,
zoom=0.9,
frame_center=np.array([wall_x, 1.33, 0.33]),
run_time=swing,
added_anims=[
beta.animate(rate_func=within(1.6, m.rush_from)).set_value(0.0),
follow.animate(rate_func=within(0.8, m.rush_from)).set_value(0.0),
wall_on.animate(rate_func=within(1.2)).set_value(1),
rings_on.animate.set_value(0),
track_on.animate.set_value(0),
charge_on.animate.set_value(0),
],
)
self.play(
m.FadeOut(readouts),
self.camera.theta_tracker.animate.set_value(-18 * m.DEGREES),
run_time=3.0,
rate_func=m.linear,
)
closing = m.Text(
"Super-Kamiokande sees a stopping muon as a ring like this", font_size=26
)
closing.to_edge(m.DOWN, buff=0.3).to_edge(m.LEFT, buff=0.4)
self.add_fixed_in_frame_mobjects(closing)
self.remove(closing)
self.play(
m.FadeIn(closing, shift=0.2 * m.UP),
self.camera.theta_tracker.animate.set_value(-22 * m.DEGREES),
run_time=1.4,
rate_func=m.linear,
)
self.play(
self.camera.theta_tracker.animate.set_value(-30 * m.DEGREES),
run_time=3.2,
rate_func=m.linear,
)
if __name__ == "__main__":
CherenkovCone().render("cherenkov_cone.mp4")