"""One Month of Weather — a CESM FKESSLER baroclinic wave life cycle. Every number and every map frame comes from the run: case fkessler_f19_1mo_bwave, CESM 2.1.5, compset FKESSLER, f19_f19_mg17, 31 days, 1488 steps, 94 s of model execution on 24 cores. Map frames were pre-rendered from the 3-hourly h1 history tape (frames/f####.png); scalar diagnostics come from anim_data.json. """ import json import numpy as np import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from manim import * config.background_color = "#161616" BLUE_ = "#58C4DD" YEL = "#FFFF00" ORNG = "#E07A3C" GREYT = "#9AA0AA" DIM = "#5A6068" D = json.load(open("anim_data.json")) DAY = np.array(D["day"]) EKE = np.array(D["eke"]) EKESH = np.array(D["eke_sh"]) PSLM = np.array(D["psl_min"]) PMAX = np.array(D["prec_max"]) TMQ = np.array(D["tmq"]) NF = len(DAY) - 1 # 248 EFOLD = D["efold"] JLAT = np.array(D["jet"]["lat"]) JD0 = np.array(D["jet"]["d0"]) JD31 = np.array(D["jet"]["d31"]) MAP_W = 10.0 # -> 4.36 tall MAP_C = np.array([0.0, 0.58, 0.0]) # spans y -1.60 .. 2.76 Y_TITLE, Y_COUNT, Y_CAP = 3.66, 3.06, -1.72 SAX_C = np.array([-2.55, -2.62, 0.0]) # strip chart, left of legend # Frame access is strictly sequential, so a small LRU cache decodes each PNG at # most once (~250 decodes instead of ~700) while holding only ~13 MB of pixels. # Preloading all 249 frames costs 417 MB and thrashes a small box. from functools import lru_cache from PIL import Image @lru_cache(maxsize=8) def frame_px(k): return np.asarray(Image.open(f"frames/f{k:04d}.png").convert("RGBA")) def tex_png(expr, path, color="white", fs=64, dpi=300): """Typeset math to a transparent PNG via matplotlib mathtext (no LaTeX here).""" 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.05) plt.close(fig) return path def jet_axes(center): """Latitude-vs-wind axes. The y-axis sits at x=0 (the equator), so tick numbers are placed beside THAT line, not floating at the left edge.""" ax = Axes(x_range=[-90, 90, 30], y_range=[0, 45, 10], x_length=8.6, y_length=3.8, axis_config={"color": DIM, "stroke_width": 2, "tip_length": 0.16}, y_axis_config={"include_ticks": True}) ax.move_to(center) xt = VGroup(*[Text(s, font_size=16, color=DIM).move_to(ax.c2p(v, 0) + DOWN * 0.26) for v, s in [(-60, "60°S"), (-30, "30°S"), (30, "30°N"), (60, "60°N")]]) yt = VGroup(*[Text(str(v), font_size=15, color=DIM) .move_to(ax.c2p(0, v) + LEFT * 0.30) for v in [10, 20, 30, 40]]) xl = Text("latitude", font_size=19, color=GREYT).move_to(ax.c2p(0, 0) + DOWN * 0.62) yl = Text("zonal wind (m/s)", font_size=19, color=GREYT) yl.next_to(ax.c2p(0, 45), UP, buff=0.18) return ax, VGroup(xt, yt, xl, yl) def kidx(v): return int(np.clip(round(v), 0, NF)) def frame_at(k): return ImageMobject(frame_px(kidx(k))).scale_to_fit_width(MAP_W).move_to(MAP_C) class BaroclinicWave(Scene): def construct(self): self.act0_title() self.act1_setup() self.act2_lifecycle() self.act3_jet() self.act4_close() # ---------------------------------------------------------------- title def act0_title(self): t1 = Text("One Month of Weather", font_size=54, color=BLUE_) t2 = Text("from a single bump on a jet stream", font_size=27, color=GREYT) t2.next_to(t1, DOWN, buff=0.32) g = VGroup(t1, t2).move_to(UP * 0.55) cred = Text("CESM 2.1.5 · FKESSLER · 1.9° × 2.5° · 31 model days in 94 seconds", font_size=19, color=DIM).move_to(DOWN * 2.25) self.play(Write(t1), run_time=1.5) self.play(FadeIn(t2, shift=UP * 0.25), run_time=1.0) self.play(FadeIn(cred), run_time=0.9) self.wait(1.5) self.play(FadeOut(g), FadeOut(cred), run_time=0.8) # ---------------------------------------------------------------- setup def act1_setup(self): head = Text("We start with one thing: a midlatitude jet.", font_size=30, color=WHITE).move_to(UP * Y_TITLE) ax, deco = jet_axes(DOWN * 0.30) curve0 = ax.plot_line_graph(JLAT, JD0, line_color=BLUE_, stroke_width=4, add_vertex_dots=False)["line_graph"] self.play(FadeIn(head, shift=DOWN * 0.2), run_time=0.9) self.play(Create(ax), FadeIn(deco), run_time=1.4) self.play(Create(curve0), run_time=2.0) self.wait(0.6) pk = int(np.argmax(np.where(JLAT > 0, JD0, -1e9))) dot = Dot(ax.c2p(JLAT[pk], JD0[pk]), color=YEL, radius=0.075) bump = Text("+ one small bump, here", font_size=21, color=YEL) bump.next_to(dot, UR, buff=0.20).shift(LEFT * 0.30) self.play(GrowFromCenter(dot), FadeIn(bump, shift=DOWN * 0.15), run_time=1.0) self.wait(0.9) note = VGroup( Text("No sunlight. No oceans. No surface friction.", font_size=23, color=ORNG), Text("Every storm you are about to see is powered by this curve alone.", font_size=21, color=GREYT), ).arrange(DOWN, buff=0.22).move_to(DOWN * 3.20) self.play(FadeIn(note[0]), run_time=0.9) self.play(FadeIn(note[1]), run_time=0.9) self.wait(2.0) self.play(FadeOut(head), FadeOut(note), FadeOut(dot), FadeOut(bump), FadeOut(ax), FadeOut(deco), FadeOut(curve0), run_time=1.0) # ------------------------------------------------------------ life cycle def act2_lifecycle(self): tv = ValueTracker(0) img = frame_at(0) set_frame = lambda m: m.__setattr__("pixel_array", frame_px(kidx(tv.get_value()))) # --- eddy-KE strip chart (log scale), sweeping in sync with the map sax = Axes( x_range=[0, 31, 5], y_range=[-4, 2.4, 1], x_length=8.5, y_length=1.55, axis_config={"color": DIM, "stroke_width": 1.6, "tip_length": 0.10}, y_axis_config={"include_ticks": False}, ).move_to(SAX_C) logn = np.log10(np.maximum(EKE, 1e-4)) logs = np.log10(np.maximum(EKESH, 1e-4)) def trace(vals, color, w): def f(): k = kidx(tv.get_value()) if k < 2: return VMobject() m = VMobject(stroke_color=color, stroke_width=w) m.set_points_as_corners([sax.c2p(DAY[i], vals[i]) for i in range(k + 1)]) return m return always_redraw(f) tr_nh, tr_sh = trace(logn, BLUE_, 3.2), trace(logs, ORNG, 2.5) sweep = always_redraw(lambda: DashedLine( sax.c2p(DAY[kidx(tv.get_value())], -4.15), sax.c2p(DAY[kidx(tv.get_value())], 2.4), color=DIM, stroke_width=1.5, dash_length=0.07)) sx = VGroup(*[Text(f"day {v}" if v == 0 else str(v), font_size=14, color=DIM) .move_to(sax.c2p(v, -4) + DOWN * 0.24) for v in [0, 5, 10, 15, 20, 25, 30]]) slab = Text("eddy energy (log)", font_size=15, color=GREYT) slab.next_to(sax, UP, buff=0.08).align_to(sax, LEFT) # --- live counters, tucked under the title band dayl = Text("day", font_size=21, color=GREYT) dayv = DecimalNumber(0, num_decimal_places=1, font_size=32, color=WHITE, mob_class=Text) set_day = lambda m: m.set_value(DAY[kidx(tv.get_value())]) daybox = VGroup(dayl, dayv).arrange(RIGHT, buff=0.16) daybox.move_to(np.array([-5.55, Y_COUNT, 0.0])) psll = Text("lowest pressure", font_size=18, color=GREYT) pslv = DecimalNumber(1000, num_decimal_places=0, font_size=29, color=YEL, mob_class=Text) set_psl = lambda m: m.set_value(PSLM[kidx(tv.get_value())]) pslu = Text("hPa", font_size=18, color=GREYT) pslbox = VGroup(psll, pslv, pslu).arrange(RIGHT, buff=0.14) pslbox.move_to(np.array([4.55, Y_COUNT, 0.0])) # --- legend: to the RIGHT of the strip chart, inside the frame legend = VGroup( VGroup(Line(ORIGIN, RIGHT * 0.40, color="#c8ccd4", stroke_width=2), Text("pressure", font_size=15, color=GREYT)).arrange(RIGHT, buff=0.13), VGroup(Square(0.15, fill_color="#4FA8DE", fill_opacity=1, stroke_width=0), Text("rainfall", font_size=15, color=GREYT)).arrange(RIGHT, buff=0.13), ).arrange(DOWN, buff=0.22, aligned_edge=LEFT) legend.move_to(np.array([5.25, -2.62, 0.0])) self.play(FadeIn(img), FadeIn(daybox), FadeIn(pslbox), run_time=1.0) img.add_updater(set_frame) dayv.add_updater(set_day) pslv.add_updater(set_psl) self.play(Create(sax), FadeIn(sx), FadeIn(slab), FadeIn(legend), run_time=1.1) self.add(tr_nh, tr_sh, sweep) self.wait(0.5) # chapter titles live in their own band, centred between the counters def chapter(txt): return Text(txt, font_size=25, color=WHITE).move_to(UP * Y_TITLE) # ============ days 0 -> 9 : exponential growth ============ c1 = chapter("Days 0–9 the bump grows exponentially") self.play(FadeIn(c1, shift=DOWN * 0.15), run_time=0.7) self.play(tv.animate.set_value(72), run_time=7.0, rate_func=linear) tex_png(rf"E \propto e^{{\sigma t}}, \quad \sigma = {EFOLD:.2f}\ \mathrm{{day}}^{{-1}}", "eq_growth.png", color=YEL, fs=54) eq = ImageMobject("eq_growth.png").scale_to_fit_width(3.9) eq.move_to(np.array([2.95, 1.30, 0.0])) # empty E-Pacific sector at day 9 eqbg = RoundedRectangle(corner_radius=0.12, width=eq.width + 0.45, height=eq.height + 0.38, fill_color="#0d0d0d", fill_opacity=0.93, stroke_color=DIM, stroke_width=1.2).move_to(eq) dbl = Text("energy doubles every 14 hours", font_size=18, color=GREYT) dbl.next_to(eqbg, DOWN, buff=0.14) self.play(FadeIn(eqbg), FadeIn(eq), run_time=0.9) self.play(FadeIn(dbl), run_time=0.7) self.wait(2.2) self.play(FadeOut(eqbg), FadeOut(eq), FadeOut(dbl), run_time=0.7) # ============ days 9 -> 16 : breaking, and rain ============ c2 = chapter("Days 9–16 the wave breaks, and it starts to rain") self.play(ReplacementTransform(c1, c2), run_time=0.6) self.play(tv.animate.set_value(96), run_time=4.0, rate_func=linear) rain = Text("narrow frontal bands — peaks of 295 mm/day", font_size=19, color=BLUE_).move_to(np.array([-0.35, Y_CAP, 0.0])) self.play(FadeIn(rain), run_time=0.7) self.play(tv.animate.set_value(128), run_time=5.0, rate_func=linear) self.play(Indicate(pslbox, color=YEL, scale_factor=1.10), run_time=1.1) self.wait(0.4) self.play(FadeOut(rain), run_time=0.5) # ============ days 16 -> 31 : saturation, and the SH ============ c3 = chapter("Days 16–31 the eddies saturate — and cross the equator") self.play(ReplacementTransform(c2, c3), run_time=0.6) # direct-label each trace at its right-hand end, inside the chart # label where the traces are widely separated (day ~20), not at day 31 # where they converge to 0.04 units apart and the labels would collide nhlab = Text("N. hemisphere", font_size=15, color=BLUE_) nhlab.next_to(sax.c2p(19, logn[152]), UP, buff=0.06) shlab = Text("S. hemisphere", font_size=15, color=ORNG) shlab.next_to(sax.c2p(19, logs[152]), DOWN, buff=0.06) self.play(FadeIn(nhlab), FadeIn(shlab), run_time=0.8) self.play(tv.animate.set_value(NF), run_time=9.0, rate_func=linear) self.wait(0.4) # shorter, and shifted right of the strip-chart's own label pk = Text("deepest storm of the month: 880 hPa, on day 31", font_size=19, color=YEL).move_to(np.array([-0.35, Y_CAP, 0.0])) self.play(FadeOut(c3), FadeOut(nhlab), FadeOut(shlab), run_time=0.4) self.play(FadeIn(pk), Indicate(pslbox, color=YEL, scale_factor=1.10), run_time=1.2) self.wait(2.0) img.remove_updater(set_frame) # FadeIn/FadeOut interpolation conflict dayv.clear_updaters(); pslv.clear_updaters() self.play(FadeOut(pk), run_time=0.5) self.play(FadeOut(img), FadeOut(daybox), FadeOut(pslbox), FadeOut(legend), FadeOut(sax), FadeOut(sx), FadeOut(slab), FadeOut(tr_nh), FadeOut(tr_sh), FadeOut(sweep), run_time=1.0) # ------------------------------------------------------------- jet payoff def act3_jet(self): head = Text("What did a month of storms do to the jet?", font_size=29, color=WHITE).move_to(UP * Y_TITLE) ax, deco = jet_axes(DOWN * 0.20) c0 = ax.plot_line_graph(JLAT, JD0, line_color=DIM, stroke_width=3.2, add_vertex_dots=False)["line_graph"] c31 = ax.plot_line_graph(JLAT, JD31, line_color=BLUE_, stroke_width=4.4, add_vertex_dots=False)["line_graph"] self.play(FadeIn(head, shift=DOWN * 0.2), run_time=0.8) self.play(Create(ax), FadeIn(deco), run_time=1.1) self.play(Create(c0), run_time=1.2) l0 = Text("day 0", font_size=19, color=DIM).move_to(ax.c2p(-40, 8)) self.play(FadeIn(l0), run_time=0.5) self.wait(0.5) self.play(ReplacementTransform(c0.copy(), c31), run_time=2.6) l31 = Text("day 31", font_size=20, color=BLUE_).move_to(ax.c2p(52, 43)) self.play(FadeIn(l31), run_time=0.5) self.wait(0.7) i0 = int(np.argmax(np.where(JLAT > 0, JD0, -1e9))) i31 = int(np.argmax(np.where(JLAT > 0, JD31, -1e9))) d0 = Dot(ax.c2p(JLAT[i0], JD0[i0]), color=GREYT, radius=0.06) d1 = Dot(ax.c2p(JLAT[i31], JD31[i31]), color=YEL, radius=0.07) arr = Arrow(d0.get_center(), d1.get_center(), color=YEL, stroke_width=4, buff=0.06, max_tip_length_to_length_ratio=0.22) self.play(FadeIn(d0), FadeIn(d1), run_time=0.5) self.play(GrowArrow(arr), run_time=1.0) cap = VGroup( Text("28 → 38 m/s, and 10° further poleward", font_size=23, color=YEL), Text("the storms fed their momentum back into the jet that made them", font_size=19, color=GREYT), ).arrange(DOWN, buff=0.20).move_to(DOWN * 3.25) self.play(FadeIn(cap[0]), run_time=0.8) self.play(FadeIn(cap[1]), run_time=0.8) self.wait(2.6) # every mobject from this act leaves before the closing card self.play(*[FadeOut(m) for m in [head, ax, deco, c0, c31, l0, l31, d0, d1, arr, cap]], run_time=1.0) def act4_close(self): rows = [ ("1.22 / day", "growth rate of the eddies"), ("880 hPa", "deepest storm"), ("295 mm/day", "peak rainfall"), ("8.9 %", "of the atmosphere's water vapour rained out"), ] # two aligned columns: values right-aligned, labels left-aligned, so no # long label can ever run back under a neighbouring value vals = VGroup(*[Text(v, font_size=28, color=YEL) for v, _ in rows]) labs = VGroup(*[Text(l, font_size=20, color=GREYT) for _, l in rows]) vals.arrange(DOWN, buff=0.40, aligned_edge=RIGHT) labs.arrange(DOWN, buff=0.40, aligned_edge=LEFT) for v, l in zip(vals, labs): l.align_to(v, DOWN) labs.next_to(vals, RIGHT, buff=0.40) items = VGroup(vals, labs).move_to(UP * 0.15) head = Text("One month of weather", font_size=32, color=BLUE_) head.next_to(items, UP, buff=0.70) self.play(FadeIn(head), run_time=0.8) for v, l in zip(vals, labs): self.play(FadeIn(v, shift=RIGHT * 0.2), FadeIn(l, shift=RIGHT * 0.2), run_time=0.55) self.wait(1.2) foot = Text("CESM 2.1.5 · FKESSLER · 1488 steps · 94 s on 24 cores", font_size=19, color=DIM).to_edge(DOWN, buff=0.75) self.play(FadeIn(foot), run_time=0.8) self.wait(2.2) self.play(FadeOut(head), FadeOut(items), FadeOut(foot), run_time=1.2)