同步的萤火虫¶
超过临界耦合后,2000 只萤火虫 步调一致。其同步程度与精确曲线吻合。
examples/kuramoto_fireflies.py
"""萤火虫步入同步:超过临界耦合后的自发同步(Kuramoto 1975)。
2000 只萤火虫在相位 θᵢ 过 0 时闪烁。每只有自己的节律 ωᵢ,取自
宽度为 γ 的洛伦兹分布,每只被群体的平均节律所牵引:
θ̇ᵢ = ωᵢ + K r sin(ψ − θᵢ),其中 r e^{iψ} = (1/N) Σ e^{iθⱼ}(画为 HUD 中的箭头)。
在 K_c = 2γ 以下什么也不发生:r 仅为有限尺寸噪声,~1/√N。超过它,
锁相簇形成并增长,r 遵循洛伦兹分布的精确结果 r = √(1 − K_c/K)(Kuramoto
1984)。萤火虫按节律排列(慢的在左边,快的在右边),因此森林
锁相的中间部分作为一个波闪烁,而两端保持各自的节律。
"""
import numpy as np
import manimgx as m
N = 2000
OMEGA0 = 2 * np.pi * 0.75 # 群体的中位节律:每 1.33 秒闪一次
GAMMA = OMEGA0 / 1.5 # 洛伦兹节律分布的半宽
KC = 2 * GAMMA # 临界耦合
STOPS = ["#ff8a3d", "#ffc94d", "#e2ff6a", "#8dff8a", "#46f0d2"] # 慢 → 快节律
GOLD = "#ffd166"
BACKGROUND = "#03060d"
def colormap(values: np.ndarray, stops: list[str]) -> np.ndarray:
"""[0, 1] 区间值的 RGBA 行,通过色标插值。"""
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 ease(x: float) -> float:
x = min(max(x, 0.0), 1.0)
return x * x * (3 - 2 * x)
def coupling(t: float) -> float:
"""K / K_c 随影片时间的变化:缓慢通过转变区,然后增强。"""
return (
0.9 * ease((t - 1.5) / 4.5) + 0.7 * ease((t - 6) / 9) + 1.4 * ease((t - 15) / 7)
)
def order(theta: np.ndarray) -> complex:
"""序参量 r e^{iψ} = e^{iθ} 的均值。"""
return complex(np.exp(1j * theta).mean())
def tube(
points: np.ndarray, radius: float, sides: int = 12
) -> tuple[np.ndarray, np.ndarray]:
"""沿折线的光照管道的顶点和三角形(平行传输标架)。"""
tangent = np.gradient(points, axis=0)
tangent /= np.linalg.norm(tangent, axis=1, keepdims=True)
seed = np.cross(tangent[0], [0.3, 0.5, 0.8])
normals = [seed / np.linalg.norm(seed)]
for t in tangent[1:]:
n = normals[-1] - np.dot(normals[-1], t) * t
normals.append(n / np.linalg.norm(n))
n_ = np.array(normals)
b_ = np.cross(tangent, n_)
a = np.linspace(0, m.TAU, sides, endpoint=False)
ring = (
np.cos(a)[None, :, None] * n_[:, None] + np.sin(a)[None, :, None] * b_[:, None]
)
verts = (points[:, None] + radius * ring).reshape(-1, 3)
i = np.arange(len(points) - 1)[:, None]
j = np.arange(sides)[None, :]
k = (j + 1) % sides
tris = 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)
return verts, tris
def cloud(points: np.ndarray, rows: np.ndarray, size: float) -> m.PMobject:
mob = m.PMobject(stroke_width=size)
mob.add_points(points, rgbas=rows)
return mob
class KuramotoFireflies(m.ThreeDScene):
def construct(self) -> None:
self.camera.background_color = BACKGROUND
self.set_camera_orientation(phi=78 * m.DEGREES, theta=-100 * m.DEGREES)
self.camera.light_source.move_to([-6.0, 30.0, 12.0]) # 来自后方的月光
rng = np.random.default_rng(7)
# 节律:洛伦兹分布的分位数,使样本尽可能服从洛伦兹分布
rank = (np.arange(N) + 0.5) / N
omega = OMEGA0 + GAMMA * np.tan(np.pi * (rank - 0.5))
theta = rng.uniform(0, m.TAU, N)
paint = colormap(rank, STOPS)
# 森林:萤火虫按节律分布在其中,在深度和高度上散布
home = np.stack(
[
13.0 * (rank - 0.5) + 0.7 * rng.standard_normal(N),
rng.uniform(-2.5, 6.0, N),
rng.uniform(-3.3, 1.2, N),
],
axis=1,
)
drift_f = rng.uniform(0.25, 0.7, (N, 3))
drift_p = rng.uniform(0, m.TAU, (N, 3))
trunks = []
for k in range(16):
x0, y0 = -8.0 + k + rng.uniform(-0.4, 0.4), rng.uniform(-1.0, 9.0)
line = np.array([[x0, y0, -4.0], [x0 + 0.1, y0, 1.0], [x0, y0, 7.0]])
verts, tris = tube(line, rng.uniform(0.07, 0.16))
trunks.append(
m.MeshMobject(verts, tris, shade_in_3d=True, fill_color="#0c1118")
)
state = {"theta": theta}
clock = [0.0]
def rhs(th: np.ndarray, k: float) -> np.ndarray:
z = order(th)
return omega + k * abs(z) * np.sin(np.angle(z) - th)
def advance(mob: m.Mobject, dt: float) -> None:
k = coupling(clock[0]) * KC
th = state["theta"]
h = dt / 4
for _ in range(4): # RK4
k1 = rhs(th, k)
k2 = rhs(th + h / 2 * k1, k)
k3 = rhs(th + h / 2 * k2, k)
k4 = rhs(th + h * k3, k)
th = th + h / 6 * (k1 + 2 * k2 + 2 * k3 + k4)
state["theta"] = np.mod(th, m.TAU)
clock[0] += dt
driver = m.Mobject().add_updater(advance)
def flash() -> np.ndarray:
"""亮度:相位过 0 时的短暂闪烁。"""
return np.exp(5.0 * (np.cos(state["theta"]) - 1))
cores = cloud(home, paint, 5)
glows = cloud(home, paint, 26)
halos = cloud(home, paint, 64)
def shine(mob: m.Mobject) -> None:
t = clock[0]
where = home + 0.16 * np.sin(drift_f * t + drift_p)
b = flash()
lit = paint.copy()
lit[:, :3] *= (0.22 + 0.78 * b)[:, None]
cores.points = where
cores.paint = cores.paint.but(fill=lit)
for layer, alpha in ((glows, 0.32), (halos, 0.05)):
rows = paint.copy()
rows[:, 3] = alpha * b
layer.points = where
layer.paint = layer.paint.but(fill=rows)
shine(driver)
cores.add_updater(shine)
self.add(driver, *trunks, cores, glows, halos)
# HUD,左侧:圆上的相位(顶部闪烁)和箭头 r e^{iψ}
at = np.array([-5.35, -2.35, 0.0])
radius = 1.05
panel_l = m.RoundedRectangle(
corner_radius=0.15, width=3.1, height=3.05, stroke_width=1
)
panel_l.set_fill(BACKGROUND, opacity=0.8).set_stroke(m.GREY_D)
panel_l.move_to(at + [0, 0.12, 0])
ring = m.Circle(radius=radius, stroke_color=m.GREY_D, stroke_width=1.5)
ring.move_to(at)
spot = m.Line(
at + [0, radius - 0.12, 0], at + [0, radius + 0.12, 0], stroke_width=3
).set_stroke(m.WHITE)
lane = radius * (0.82 + 0.18 * rank) # 慢节律在内,快节律在外
def on_circle(th: np.ndarray, r: np.ndarray) -> np.ndarray:
angle = np.pi / 2 - th # 相位 0 在顶部,顺时针推进
flat = np.stack([r * np.cos(angle), r * np.sin(angle), 0 * r], axis=1)
return flat + at
dots = cloud(on_circle(theta, lane), paint, 4)
arrow_line = m.Line(at, at + [0.01, 0, 0], stroke_width=4, stroke_color=m.WHITE)
arrow_tip = m.Dot(at, radius=0.05, color=m.WHITE)
# 中心处的 ×10 放大器,使箭头在 r 仍仅为噪声时可见
noise_ring = m.DashedVMobject(
m.Circle(radius=10 * radius / np.sqrt(N), stroke_width=1.5), num_dashes=20
).move_to(at)
noise_ring.set_stroke(m.GREY_B)
zoom_arrow = m.Line(at, at + [0.01, 0, 0], stroke_width=2.5)
zoom_label = m.MathTex(r"\times 10", font_size=20).set_color(m.GREY_B)
zoom_label.next_to(noise_ring, m.DOWN, buff=0.05)
def phases(mob: m.Mobject) -> None:
b = flash()
rows = paint.copy()
rows[:, :3] *= (0.45 + 0.55 * b)[:, None]
dots.points = on_circle(state["theta"], lane)
dots.paint = dots.paint.but(fill=rows)
z = order(state["theta"])
tip = on_circle(np.array([np.angle(z)]), np.array([radius * abs(z)]))[0]
arrow_line.put_start_and_end_on(at, tip + [1e-4, 0, 0])
arrow_tip.move_to(tip)
near = min(1.0, max(0.0, (0.1 - abs(z)) / 0.04)) # r 变为真实值后消失
reach = min(10 * abs(z), 0.8)
zoomed = on_circle(np.array([np.angle(z)]), np.array([radius * reach]))[0]
zoom_arrow.put_start_and_end_on(at, zoomed + [1e-4, 0, 0])
zoom_arrow.set_stroke(m.WHITE, opacity=0.85 * near)
noise_ring.set_stroke(opacity=0.7 * near)
zoom_label.set_opacity(near)
dots.add_updater(phases)
z_label = m.MathTex(r"r e^{i\psi} = \langle e^{i\theta} \rangle", font_size=26)
z_label.next_to(ring, m.UP, buff=0.22)
# HUD,右侧:r 对 K,实测值和精确的洛伦兹结果
plot = m.Axes(
x_range=[0, 3.2, 1],
y_range=[0, 1, 0.5],
x_length=4.2,
y_length=2.1,
tips=False,
axis_config={"stroke_width": 2, "color": m.GREY_B, "include_ticks": False},
).move_to([4.55, -2.45, 0])
panel_r = m.RoundedRectangle(
corner_radius=0.15, width=5.2, height=3.05, stroke_width=1
)
panel_r.set_fill(BACKGROUND, opacity=0.8).set_stroke(m.GREY_D)
panel_r.move_to(plot.get_center() + [0.05, 0.12, 0])
theory = m.VGroup(
m.Line(plot.c2p(0, 0), plot.c2p(1, 0)),
plot.plot(lambda k: float(np.sqrt(1 - 1 / k)), x_range=[1, 3.2, 0.005]),
).set_stroke(GOLD, 3)
theory_label = m.MathTex(r"r = \sqrt{1 - K_c/K}", font_size=26).set_color(GOLD)
theory_label.move_to(plot.c2p(2.35, 0.45))
noise = m.DashedLine(
plot.c2p(0, 1 / np.sqrt(N)), plot.c2p(1, 1 / np.sqrt(N)), dash_length=0.05
).set_stroke(m.GREY_B, 1.5)
noise_label = m.MathTex(r"1/\sqrt{N}", font_size=22).set_color(m.GREY_B)
noise_label.next_to(plot.c2p(0.5, 1 / np.sqrt(N)), m.UP, buff=0.08)
kc_tick = m.MathTex(r"K_c = 2\gamma", font_size=24).next_to(
plot.c2p(1, 0), m.DOWN, buff=0.12
)
k_axis = m.MathTex(r"K", font_size=26).next_to(plot.c2p(3.2, 0), m.RIGHT, 0.1)
r_axis = m.MathTex(r"r", font_size=26).next_to(plot.c2p(0, 1), m.LEFT, 0.12)
trace: list[np.ndarray] = []
measured = m.VMobject(stroke_color=m.WHITE, stroke_width=2)
now = m.Dot(plot.c2p(0, 0), radius=0.055, color=m.WHITE)
def record(mob: m.Mobject) -> None:
k, r = coupling(clock[0]), abs(order(state["theta"]))
trace.append(plot.c2p(k, r))
if len(trace) > 1:
measured.set_points_as_corners(np.array(trace))
now.move_to(trace[-1])
measured.add_updater(record)
# 图例:每只萤火虫的自身节律,也决定其位置
legend_bar = m.Rectangle(width=3.0, height=0.1, stroke_width=0, fill_opacity=1)
legend_bar.set_fill(color=STOPS, opacity=1).set_sheen_direction(m.RIGHT)
legend_bar.move_to([-0.55, -3.62, 0])
slow = m.Text("slower", font_size=20).set_color(m.GREY_B)
slow.next_to(legend_bar, m.LEFT, buff=0.15)
fast = m.Text("faster", font_size=20).set_color(m.GREY_B)
fast.next_to(legend_bar, m.RIGHT, buff=0.15)
legend_title = m.MathTex(
r"\text{own rhythm } \omega_i \text{ (Lorentzian, width } \gamma)",
font_size=24,
).set_color(m.GREY_B)
legend_title.next_to(legend_bar, m.UP, buff=0.12)
legend = m.VGroup(legend_bar, slow, fast, legend_title)
backing = m.RoundedRectangle(
corner_radius=0.12,
width=legend.width + 0.35,
height=legend.height + 0.25,
stroke_width=1,
)
backing.set_fill(BACKGROUND, opacity=0.8).set_stroke(m.GREY_D)
legend = m.VGroup(backing.move_to(legend), *legend)
# 读数和标题
title = m.Text("Fireflies fall into step", font_size=36).to_corner(m.UL)
subtitle = m.Text(
f"{N} flashing oscillators, each nudged toward the swarm's rhythm",
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"\dot\theta_i = \omega_i + K r \sin(\psi - \theta_i)", font_size=32
).to_corner(m.UR)
k_value = m.DecimalNumber(0, num_decimal_places=2, font_size=28)
r_value = m.DecimalNumber(0, num_decimal_places=2, font_size=28)
k_text = m.MathTex(r"K/K_c =", font_size=28)
r_text = m.MathTex(r"r =", font_size=28)
readout = m.VGroup(
m.VGroup(k_text, k_value).arrange(m.RIGHT, buff=0.12),
m.VGroup(r_text, r_value).arrange(m.RIGHT, buff=0.12),
).arrange(m.RIGHT, buff=0.5)
readout.next_to(law, m.DOWN, buff=0.2).align_to(law, m.RIGHT)
readout.set_opacity(0.0)
shown = m.ValueTracker(0.0) # 读数的不透明度(实时数字无法 FadeIn)
def read(mob: m.Mobject) -> None:
k_value.set_value(coupling(clock[0]))
k_value.next_to(k_text, m.RIGHT, buff=0.12)
r_value.set_value(abs(order(state["theta"])))
r_value.next_to(r_text, m.RIGHT, buff=0.12)
readout.set_opacity(shown.get_value())
k_value.add_updater(read)
hud = [
panel_l,
ring,
spot,
dots,
noise_ring,
zoom_label,
zoom_arrow,
arrow_line,
arrow_tip,
z_label,
panel_r,
plot,
theory,
k_axis,
r_axis,
measured,
now,
readout,
]
later = [law, noise, noise_label, kc_tick, theory_label, legend]
self.add_fixed_in_frame_mobjects(title, subtitle, *hud, *later)
self.remove(*later)
# 0–7 秒:耦合太弱:每只萤火虫保持各自节律,r 为噪声
self.begin_ambient_camera_rotation(rate=0.012)
self.wait(1.0)
self.play(
m.Write(law), shown.animate.set_value(1.0), m.FadeIn(legend), run_time=1.5
)
self.wait(1.5)
self.play(m.Create(noise), m.FadeIn(noise_label), run_time=1)
self.wait(1.5)
self.play(m.FadeIn(kc_tick), run_time=1)
# 7–16 秒:超过 K_c 后簇锁定并沿精确曲线增长
self.wait(5)
self.play(m.FadeIn(theory_label), run_time=1)
self.wait(2.5)
# 16–25 秒:强耦合:进入森林,群体作为一个波闪烁
self.wait(5)
# 保持,并在群体闪烁时结束:当平均相位 ψ 回到 0 时
self.wait(2.8)
for _ in range(90): # 最多一个闪烁周期
if -0.1 < np.angle(order(state["theta"])) <= 0:
break
self.wait(1 / 60)
if __name__ == "__main__":
KuramotoFireflies().render("kuramoto_fireflies.mp4")