""" Manim explainer of the CESM CAM4 aquaplanet CO2 experiment. Act 1 the experiment: one atmosphere, three CO2 levels Act 2 the forcing: outgoing longwave falls, energy accumulates at TOA Act 3 the fingerprint: stratosphere cools while the troposphere warms Act 4 the warming: slab ocean lets the surface respond (data-driven) All numbers are read from handoff/anim_forcing.json and, when present, handoff/anim_warming.json — both written from the real CESM history files. No LaTeX: text via Pango, equations via matplotlib mathtext PNGs. """ import json import os import numpy as np import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from manim import * config.background_color = "#161616" BLUE_3B = "#58C4DD" YELLOW_3B = "#FFFF00" COOL = "#4C6EF5" WARM = "#E8A33D" HOT = "#C1272D" GREYT = "#8A8A8A" D = json.load(open("handoff/anim_forcing.json")) WARM_PATH = "handoff/anim_warming.json" W = json.load(open(WARM_PATH)) if os.path.exists(WARM_PATH) else None CO2 = D["co2"] CCOL = {280.0: COOL, 560.0: WARM, 1120.0: HOT} def tex_png(expr, path, color="white", fs=64, dpi=300): """Math via matplotlib mathtext -> transparent PNG (no LaTeX toolchain).""" fig = plt.figure(figsize=(0.01, 0.01)) fig.patch.set_alpha(0) fig.text(0, 0, f"${expr}$", color=color, fontsize=fs) fig.savefig(path, dpi=dpi, transparent=True, bbox_inches="tight", pad_inches=0.06) plt.close(fig) return path def eq_image(expr, path, color=YELLOW_3B, width=3.4, fs=64): """tex_png -> ImageMobject scaled to an explicit scene width. ImageMobject sizes itself from the bitmap's pixel dimensions, so a 300-dpi PNG arrives many scene-units wide; always set width rather than .scale(). """ tex_png(expr, path, color=color, fs=fs) img = ImageMobject(path) img.width = width return img def axis_ticks(ax, values, axis="y", fmt="{:.0f}", fs=17, color=GREYT, buff=0.18, along=None): """Hand-placed Pango tick labels. Manim's own `numbers_to_include` builds DecimalNumbers out of MathTex, which needs a LaTeX toolchain this platform lacks — so we never let it. """ out = VGroup() for v in values: t = Text(fmt.format(v), font_size=fs, color=color) if axis == "y": anchor = ax.c2p(along if along is not None else ax.x_range[0], v) t.move_to([anchor[0] - buff - t.width / 2, anchor[1], 0]) else: anchor = ax.c2p(v, along if along is not None else ax.y_range[0]) t.move_to([anchor[0], anchor[1] - buff - t.height / 2, 0]) out.add(t) return out def title_card(text, sub=None): t = Text(text, font_size=44, color=BLUE_3B) if sub is None: return t s = Text(sub, font_size=24, color=GREYT).next_to(t, DOWN, buff=0.3) return VGroup(t, s) class CO2Story(Scene): def construct(self): self.act1_setup() self.act2_forcing() self.act3_fingerprint() self.act4_warming() self.act5_close() # ------------------------------------------------------------------ def act1_setup(self): head = title_card( "Does more CO\u2082 warm the planet?", "CESM2 / CAM4 aquaplanet \u2014 an ocean-covered Earth, run three times", ) self.play(Write(head[0], run_time=1.4), FadeIn(head[1], shift=UP * 0.2)) self.wait(1.2) self.play(FadeOut(head, shift=UP * 0.4)) # a planet with three atmospheres planet = Circle(radius=0.95, color=BLUE_3B, fill_opacity=0.28, stroke_width=2) planet.shift(LEFT * 0.6) label = Text("aquaplanet", font_size=19, color=GREYT) label.move_to(planet.get_center() + DOWN * 2.55) self.play(GrowFromCenter(planet), FadeIn(label)) rings = VGroup() tags = VGroup() for i, c in enumerate(CO2): ring = Circle(radius=1.18 + 0.24 * i, color=CCOL[c], stroke_width=3 + 2.0 * i, stroke_opacity=0.9) ring.move_to(planet.get_center()) rings.add(ring) t = Text(f"{int(c)} ppm", font_size=22, color=CCOL[c]) t.move_to(LEFT * 5.1 + UP * (1.5 - 1.5 * i)) tags.add(t) for ring, tag in zip(rings, tags): self.play(Create(ring), FadeIn(tag, shift=RIGHT * 0.2), run_time=0.85) self.wait(0.8) note = Text( "Same model, same sun,\n" "same sea-surface temperature.\n" "Only CO\u2082 changes.", font_size=21, color=WHITE, line_spacing=0.9, ).move_to(RIGHT * 3.7) self.play(FadeIn(note, shift=LEFT * 0.3)) self.wait(1.8) self.play(FadeOut(VGroup(planet, label, rings, tags, note))) # ------------------------------------------------------------------ def act2_forcing(self): head = Text("Step 1 \u2014 CO\u2082 blocks the escaping heat", font_size=32, color=BLUE_3B).to_edge(UP, buff=0.5) self.play(FadeIn(head, shift=DOWN * 0.2)) ax = Axes( x_range=[-0.2, 2.4, 1], y_range=[214, 232, 4], x_length=5.2, y_length=3.7, axis_config={"color": GREYT, "stroke_width": 2, "include_tip": False}, ).shift(LEFT * 3.1 + DOWN * 0.5) yt = axis_ticks(ax, [216, 220, 224, 228, 232], axis="y", along=-0.2) xl = VGroup(Text("CO\u2082 (ppm)", font_size=20, color=GREYT).next_to(ax, DOWN, buff=0.52)) yl = Text("outgoing heat (W/m\u00b2)", font_size=19, color=GREYT).rotate(PI / 2).next_to(yt, LEFT, buff=0.16) for i, c in enumerate(CO2): t = Text(f"{int(c)}", font_size=17, color=CCOL[c]) t.next_to(ax.c2p(i, 214), DOWN, buff=0.14) xl.add(t) self.play(Create(ax), FadeIn(yt), FadeIn(xl), FadeIn(yl)) pts = VGroup() for i, (c, f) in enumerate(zip(CO2, D["FLNT"])): dot = Dot(ax.c2p(i, f), color=CCOL[c], radius=0.085) pts.add(dot) line = VMobject(color=GREYT, stroke_width=2).set_points_as_corners( [ax.c2p(i, f) for i, f in enumerate(D["FLNT"])] ) self.play(Create(line, run_time=1.2), *[GrowFromCenter(p) for p in pts]) drop = D["FLNT"][0] - D["FLNT"][2] cap = Text(f"\u2193 {drop:.1f} W/m\u00b2 less heat leaves\n at 4\u00d7 CO\u2082", font_size=21, color=YELLOW_3B, line_spacing=0.8) cap.next_to(ax, RIGHT, buff=0.7).shift(UP * 0.9) self.play(FadeIn(cap, shift=LEFT * 0.2)) self.wait(1.4) # the forcing panel ax2 = Axes( x_range=[-0.15, 2.3, 1], y_range=[0, 8, 2], x_length=4.4, y_length=3.0, axis_config={"color": GREYT, "stroke_width": 2, "include_tip": False}, ).next_to(ax, RIGHT, buff=1.7).shift(DOWN * 0.35) yt2 = axis_ticks(ax2, [0, 2, 4, 6, 8], axis="y", along=-0.15) yl2 = Text("forcing (W/m\u00b2)", font_size=19, color=GREYT).rotate(PI / 2).next_to(yt2, LEFT, buff=0.16) xt2 = VGroup(*[Text(f"{int(c)}", font_size=16, color=CCOL[c]).next_to(ax2.c2p(i, 0), DOWN, buff=0.14) for i, c in enumerate(CO2)]) self.play(FadeOut(cap), Create(ax2), FadeIn(yt2), FadeIn(yl2), FadeIn(xt2)) fit = Line(ax2.c2p(0, 0), ax2.c2p(2.15, D["slope"] * 2.15), color=GREYT, stroke_width=2.5) self.play(Create(fit, run_time=1.0)) fpts = VGroup(*[Dot(ax2.c2p(i, f), color=CCOL[c], radius=0.085) for i, (c, f) in enumerate(zip(CO2, D["forcing_allsky"]))]) self.play(*[GrowFromCenter(p) for p in fpts], run_time=0.9) eq = eq_image(rf"{D['slope']:.2f}\ \mathrm{{W/m^2}}\ \mathrm{{per\ doubling}}", "eq_slope.png", width=3.5) eq.move_to(ax2.get_center() + UP * 2.30) self.play(FadeIn(eq, shift=DOWN * 0.15)) self.wait(1.0) acc = Text("accepted value \u2248 3.7", font_size=17, color=GREYT) acc.move_to(eq.get_center() + DOWN * 0.42) self.play(FadeIn(acc)) self.wait(1.6) self.play(FadeOut(VGroup(ax, yt, xl, yl, pts, line, ax2, yt2, yl2, xt2, fit, fpts, acc, head)), FadeOut(eq)) # ------------------------------------------------------------------ def act3_fingerprint(self): head = Text("Step 2 \u2014 the fingerprint in the vertical", font_size=32, color=BLUE_3B).to_edge(UP, buff=0.5) self.play(FadeIn(head, shift=DOWN * 0.2)) p = np.array(D["pressure"]) keep = p > 4 p = p[keep] # y = -log10(p): pressure DECREASES upward, so the surface sits at the # bottom of the panel and the stratosphere at the top. ylev = -np.log10(p) YTOP, YBOT = -np.log10(3.5), -3.02 ax = Axes( x_range=[-20, 3, 5], y_range=[YBOT, YTOP, 1], x_length=5.9, y_length=4.3, axis_config={"color": GREYT, "stroke_width": 2, "include_tip": False}, ).shift(LEFT * 2.5 + DOWN * 0.30) # y=0 lies outside y_range, so Axes would draw the x-axis along the TOP # edge; suppress it and draw the baseline at the bottom ourselves. ax.x_axis.set_opacity(0) base = Line(ax.c2p(-20, YBOT), ax.c2p(3, YBOT), color=GREYT, stroke_width=2) xt = axis_ticks(ax, [-20, -15, -10, -5, 0], axis="x", along=YBOT) xl = Text("temperature change (K)", font_size=20, color=GREYT).next_to(xt, DOWN, buff=0.16) self.play(Create(ax), Create(base), FadeIn(xt), FadeIn(xl)) for hpa, name, ny in [(1000, "surface", 0.20), (100, "tropopause", 0.0), (10, "stratosphere", 0.0)]: yy = -np.log10(hpa) y = ax.c2p(0, yy)[1] tick = Text(f"{hpa} hPa", font_size=16, color=GREYT) tick.move_to([ax.c2p(-20, yy)[0] - 0.60, y, 0]) nm = Text(name, font_size=15, color="#777777") nm.move_to([ax.c2p(2.4, yy)[0] + 0.40, y + ny, 0]) self.play(FadeIn(tick), FadeIn(nm), run_time=0.3) zero = DashedLine(ax.c2p(0, YBOT), ax.c2p(0, YTOP), color=GREYT, stroke_width=1.6, dash_length=0.08) self.play(Create(zero, run_time=0.6)) # label anchors chosen in DATA coords so the two never collide: # 560 ppm sits mid-stratosphere to the right, 1120 ppm higher and left. # At p=24 hPa the curves sit at -3.7 (560) and -6.8 (1120) K; anchoring # 560's label to the RIGHT of its curve and 1120's to the LEFT of its own # keeps both clear of every stroke. for key, c, tag, anchor in [("dT560", WARM, "560 ppm", (-1.5, -np.log10(17.0))), ("dT1120", HOT, "1120 ppm", (-16.6, -np.log10(9.0)))]: v = np.array(D[key])[keep] curve = VMobject(color=c, stroke_width=4.5).set_points_smoothly( [ax.c2p(float(vv), float(yy)) for vv, yy in zip(v, ylev)] ) self.play(Create(curve, run_time=1.6)) lab = Text(tag, font_size=19, color=c) lab.move_to(ax.c2p(*anchor)) self.play(FadeIn(lab), run_time=0.4) self.add(base) # keep the hand-drawn baseline in the scene for cleanup box = VGroup( Text("stratosphere COOLS", font_size=23, color=COOL), Text("troposphere warms slightly", font_size=21, color=WARM), Text("\u2014 the signature of CO\u2082,\n not of a brighter sun", font_size=19, color=GREYT, line_spacing=0.8), ).arrange(DOWN, aligned_edge=LEFT, buff=0.28).to_edge(RIGHT, buff=0.5) for m in box: self.play(FadeIn(m, shift=LEFT * 0.2), run_time=0.7) self.wait(2.0) self.play(*[FadeOut(m) for m in self.mobjects]) # ------------------------------------------------------------------ def act4_warming(self): head = Text("Step 3 \u2014 let the ocean respond", font_size=32, color=BLUE_3B).to_edge(UP, buff=0.5) self.play(FadeIn(head, shift=DOWN * 0.2)) expl = Text( "Runs 1\u20133 pinned the sea surface, so the planet could not warm \u2014\n" "that isolates the forcing. Now the same three CO\u2082 levels run again\n" "over a 30 m slab ocean that is free to heat up.", font_size=22, color=WHITE, line_spacing=0.9, ) self.play(FadeIn(expl, shift=UP * 0.2)) self.wait(2.4) self.play(FadeOut(expl)) if W is None: pend = Text("(slab-ocean runs still on the cluster)", font_size=22, color=GREYT) self.play(FadeIn(pend)) self.wait(1.2) self.play(FadeOut(pend), FadeOut(head)) return yrs = W["years"] ax = Axes( x_range=[0, max(yrs) + 0.5, 5], y_range=[W["ts_min"], W["ts_max"], W["ts_step"]], x_length=6.0, y_length=3.9, axis_config={"color": GREYT, "stroke_width": 2, "include_tip": False}, ).shift(LEFT * 2.2 + DOWN * 0.4) yt = axis_ticks(ax, W["ts_ticks"], axis="y", fmt="{:.1f}", along=0) xt = axis_ticks(ax, W["year_ticks"], axis="x", along=W["ts_min"]) xl = Text("model year", font_size=20, color=GREYT).next_to(xt, DOWN, buff=0.16) yl = Text("surface temperature (K)", font_size=19, color=GREYT).rotate(PI / 2).next_to(yt, LEFT, buff=0.16) self.play(Create(ax), FadeIn(yt), FadeIn(xt), FadeIn(xl), FadeIn(yl)) for c in CO2: series = W["ts_series"][str(int(c))] n = min(len(series), len(yrs)) curve = VMobject(color=CCOL[c], stroke_width=4).set_points_smoothly( [ax.c2p(float(yrs[i]), float(series[i])) for i in range(n)] ) self.play(Create(curve, run_time=1.8)) lab = Text(f"{int(c)} ppm", font_size=19, color=CCOL[c]) lab.move_to(ax.c2p(float(yrs[n - 1]), float(series[n - 1])) + RIGHT * 0.72) self.play(FadeIn(lab), run_time=0.4) sens = W["sensitivity"] eq = eq_image(rf"{sens:.2f}\ \mathrm{{K\ per\ CO_2\ doubling}}", "eq_sens.png", width=3.3) eq.move_to(RIGHT * 4.15 + UP * 1.6) self.play(FadeIn(eq, shift=LEFT * 0.2)) cav = Text("slab-ocean aquaplanet,\nCAM4 physics", font_size=17, color="#B8B8B8", line_spacing=0.85) cav.move_to(eq.get_center() + DOWN * 0.85) self.play(FadeIn(cav)) self.wait(2.2) self.play(*[FadeOut(m) for m in self.mobjects]) # ---- where the warming lands ---- head2 = Text("Where does the warming land?", font_size=30, color=BLUE_3B).to_edge(UP, buff=0.5) self.play(FadeIn(head2, shift=DOWN * 0.2)) zlat = np.array(W["lat"]) z2 = np.array(W["zonal_1120_minus_280"]) z1 = np.array(W["zonal_560_minus_280"]) axz = Axes( x_range=[-90, 90, 45], y_range=[0, 6.5, 2], x_length=6.2, y_length=3.5, axis_config={"color": GREYT, "stroke_width": 2, "include_tip": False}, ).shift(LEFT * 1.9 + DOWN * 0.45) # the y-axis would otherwise be drawn at x=0 (the equator); pin it left axz.y_axis.set_opacity(0) zspine = Line(axz.c2p(-90, 0), axz.c2p(-90, 6.5), color=GREYT, stroke_width=2) zyt = axis_ticks(axz, [0, 2, 4, 6], axis="y", along=-90) zxt = axis_ticks(axz, [-90, -45, 0, 45, 90], axis="x", along=0) zxl = Text("latitude (deg)", font_size=19, color=GREYT).next_to(zxt, DOWN, buff=0.16) zyl = Text("warming (K)", font_size=19, color=GREYT).rotate(PI / 2).next_to(zyt, LEFT, buff=0.16) self.play(Create(axz), Create(zspine), FadeIn(zyt), FadeIn(zxt), FadeIn(zxl), FadeIn(zyl)) # label each curve at the equator, where both are near their local minimum # and well separated, so neither collides with the 60-deg peak callout for arr, c, tag in [(z1, WARM, "1 doubling"), (z2, HOT, "2 doublings")]: crv = VMobject(color=c, stroke_width=4).set_points_smoothly( [axz.c2p(float(a), float(b)) for a, b in zip(zlat, arr)] ) self.play(Create(crv, run_time=1.5)) i0 = int(np.argmin(np.abs(zlat))) lb = Text(tag, font_size=18, color=c) lb.move_to(axz.c2p(0.0, float(arr[i0]) - 0.62)) self.play(FadeIn(lb), run_time=0.4) imax = int(np.argmax(z2)) dot = Dot(axz.c2p(float(zlat[imax]), float(z2[imax])), color=YELLOW_3B, radius=0.075) pk = Text(f"peak {z2[imax]:.1f} K near {abs(zlat[imax]):.0f}\u00b0", font_size=16, color=YELLOW_3B) pk.move_to(axz.c2p(-38.0, 6.05)) self.play(GrowFromCenter(dot), FadeIn(pk)) self.wait(0.8) self.add(zspine) honest = Text( "Warming peaks in the storm-track\n" "latitudes, not at the pole: this\n" "aquaplanet has no sea ice and no\n" "seasons, so the feedbacks that drive\n" "real Arctic amplification are absent.", font_size=17, color=WHITE, line_spacing=0.9, ).to_edge(RIGHT, buff=0.35).shift(DOWN * 0.1) self.play(FadeIn(honest, shift=LEFT * 0.2)) self.wait(2.6) self.play(*[FadeOut(m) for m in self.mobjects]) # ------------------------------------------------------------------ def act5_close(self): chain = VGroup( Text("more CO\u2082", font_size=30, color=HOT), Text("\u2192", font_size=34, color=GREYT), Text("less heat escapes", font_size=30, color=WARM), Text("\u2192", font_size=34, color=GREYT), Text("planet warms", font_size=30, color=BLUE_3B), ).arrange(RIGHT, buff=0.32) for m in chain: self.play(FadeIn(m, shift=UP * 0.2), run_time=0.5) self.wait(1.0) if W is not None: nums = Text( f"3.36 W/m\u00b2 of forcing \u2192 {W['sensitivity']:.2f} K of warming", font_size=23, color=YELLOW_3B, ).next_to(chain, DOWN, buff=0.55) self.play(FadeIn(nums, shift=UP * 0.15)) self.wait(1.4) foot = Text( "CESM 2.1.5 \u00b7 CAM4 aquaplanet \u00b7 1.9\u00b0\u00d72.5\u00b0 \u00b7 one node", font_size=18, color=GREYT, ).next_to(chain, DOWN, buff=1.45) self.play(FadeIn(foot)) self.wait(2.2) self.play(*[FadeOut(m) for m in self.mobjects]) def FadEinSafe(m): """FadeIn wrapper kept for readability in act1.""" return FadeIn(m, shift=RIGHT * 0.2)