"""3Blue1Brown-style explainer built around the FKESSLER simulation movie. The rendered CESM movie is embedded as a live video mobject (frames swapped per rendered frame), and the surrounding animation explains the physics it shows: baroclinic instability, exponential growth, ascent -> condensation -> the Kessler autoconversion threshold -> rain. Every number spoken by the animation is measured from the model output (fkessler_wave_lifecycle_days0_10.csv / the history files), not invented. Render: manim -qm baroclinic_explainer.py BaroclinicWaveExplainer """ import numpy as np import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from manim import * config.background_color = "#161616" BLUE_3B1B = "#58C4DD" YELLOW_3B1B = "#FFFF00" ORANGE = "#FF8C2B" CLOUDC = "#CFE0F2" RAINC = "#5B9BFF" VIOLET = "#B07CD0" GREYT = "#8A93A8" # ----------------------------------------------------------------- model data # days 0-10, measured from the run (see fkessler_wave_lifecycle_days0_10.csv) DAYS = np.arange(11) AMP = np.array([0.8046, 0.2323, 0.2838, 0.4529, 0.7593, 1.3813, 2.6069, 5.1730, 11.3023, 27.3309, 49.7354]) # hPa ASC = np.array([0.03556, 0.00347, 0.00439, 0.00579, 0.00892, 0.01427, 0.02818, 0.07915, 0.23572, 0.52131, 0.59649]) # Pa/s CLW = np.array([4.47, 4.35, 4.34, 4.37, 4.33, 4.27, 4.29, 575.46, 889.71, 629.71, 990.23]) # mg/kg RAIN = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 12.254, 32.497, 55.064]) # mm/day NWET = np.array([0, 0, 0, 0, 0, 0, 0, 0, 31, 76, 130]) CL_THRESHOLD = 1000.0 VID = np.load("vid.npy", mmap_mode="r") VID_FPS = 12.0 # 720 stored frames span the 60 s source movie def tex_png(expr, path, color="white", fs=64, dpi=260): """Typeset math via matplotlib mathtext (platform LaTeX is unusable).""" 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 class VideoMobject(ImageMobject): """ImageMobject that advances through an in-memory RGBA frame stack.""" def __init__(self, vid, fps=VID_FPS, **kw): self.vid = vid self.src_fps = fps self._t = 0.0 self.rate = 1.0 self._loop = True super().__init__(np.array(vid[0]), **kw) def show_time(self, t): i = int(np.clip(t * self.src_fps, 0, len(self.vid) - 1)) self.set_pixel_array(np.array(self.vid[i])) return self def play_from(self, t0=0.0, rate=1.0, loop=True): self._t = t0 self.rate = rate self._loop = loop self.show_time(t0) self.add_updater(self._tick) return self def follow(self, tracker): """Slave the displayed frame to a ValueTracker holding source-seconds. Lets the scene scrub to an exact movie time (hence an exact model day) instead of accumulating dt, which drifts and can end mid-sequence. """ self._tracker = tracker self.add_updater(lambda m: m.show_time(tracker.get_value())) return self def freeze(self): self.clear_updaters() return self def _tick(self, m, dt): m._t += dt * m.rate span = len(m.vid) / m.src_fps t = m._t % span if m._loop else min(m._t, span - 1e-3) m.show_time(t) def screen_frame(mob, color=GREYT, w=1.6): return SurroundingRectangle(mob, color=color, stroke_width=w, buff=0.0) class BaroclinicWaveExplainer(ThreeDScene): def construct(self): self.sec1_title() self.sec2_ingredients() self.sec3_instability() self.sec4_growth_curve() self.sec5_ascent_to_cloud() self.sec6_threshold() self.sec7_full_movie() self.sec8_closing() # ------------------------------------------------------------- 1. title def sec1_title(self): v = VideoMobject(VID).scale_to_fit_width(14.2).set_opacity(0.28) v.play_from(t0=52.0, rate=0.30, loop=False) self.add(v) title = Text("How a Ripple Becomes a Storm", font_size=54, color=WHITE, weight=BOLD) sub = Text("baroclinic instability in a climate model", font_size=27, color=BLUE_3B1B) cred = Text("CESM 2.1.5 · CAM with Kessler warm-rain physics · 10 model days", font_size=19, color=GREYT) title.move_to(UP * 0.9) sub.next_to(title, DOWN, buff=0.34) cred.next_to(sub, DOWN, buff=0.5) self.play(Write(title), run_time=2.0) self.play(FadeIn(sub, shift=UP * 0.25), run_time=1.1) self.play(FadeIn(cred), run_time=0.9) self.wait(1.4) self.play(FadeOut(title), FadeOut(sub), FadeOut(cred), v.animate.set_opacity(0.0), run_time=1.1) v.freeze() self.remove(v) # ------------------------------------------------- 2. the two ingredients def sec2_ingredients(self): head = Text("Two ingredients", font_size=40, color=WHITE, weight=BOLD) head.to_edge(UP, buff=0.55) self.play(FadeIn(head, shift=DOWN * 0.2), run_time=0.9) # --- left: a temperature contrast across latitude axL = Axes(x_range=[-1, 1, 0.5], y_range=[0, 1, 0.5], x_length=4.6, y_length=2.9, axis_config={"color": GREYT, "stroke_width": 2, "include_ticks": False, "tip_length": 0.18}) axL.shift(LEFT * 3.6 + DOWN * 0.5) warm = Text("warm", font_size=20, color="#E8703C") cold = Text("cold", font_size=20, color=BLUE_3B1B) warm.next_to(axL, DOWN, buff=0.22).shift(LEFT * 1.5) cold.next_to(axL, DOWN, buff=0.22).shift(RIGHT * 1.5) band = VGroup(*[ Rectangle(width=4.6 / 14, height=2.9, fill_color=interpolate_color(ManimColor("#E8703C"), ManimColor(BLUE_3B1B), i / 13), fill_opacity=0.85, stroke_width=0) for i in range(14) ]).arrange(RIGHT, buff=0).move_to(axL.get_center()) labL = Text("equator-to-pole\ntemperature contrast", font_size=21, color=WHITE) labL.next_to(band, UP, buff=0.3) self.play(FadeIn(band, shift=UP * 0.2), Create(axL), run_time=1.3) self.play(FadeIn(labL), FadeIn(warm), FadeIn(cold), run_time=0.9) # --- right: the jet that contrast implies jet_ax = Axes(x_range=[-1, 1], y_range=[0, 1], x_length=4.6, y_length=2.9, axis_config={"color": GREYT, "stroke_width": 2, "include_ticks": False, "tip_length": 0.18}) jet_ax.shift(RIGHT * 3.6 + DOWN * 0.5) jet = jet_ax.plot(lambda x: np.exp(-((x - 0.0) ** 2) / 0.10), color=BLUE_3B1B, stroke_width=5) arrows = VGroup(*[ Arrow(jet_ax.c2p(-0.85 + 0.36 * k, 0.16 + 0.62 * np.exp(-((-0.85 + 0.36 * k) ** 2) / 0.10)), jet_ax.c2p(-0.55 + 0.36 * k, 0.16 + 0.62 * np.exp(-((-0.85 + 0.36 * k) ** 2) / 0.10)), buff=0, stroke_width=3, max_tip_length_to_length_ratio=0.35, color=CLOUDC) for k in range(5) ]) labR = Text("a westerly jet stream", font_size=21, color=WHITE) labR.next_to(jet_ax, UP, buff=0.3) peak = Text("28 m/s at 230 hPa", font_size=19, color=YELLOW_3B1B) peak.next_to(jet_ax, DOWN, buff=0.24) self.play(Create(jet_ax), run_time=0.7) self.play(Create(jet), run_time=1.2) self.play(FadeIn(labR), LaggedStart(*[GrowArrow(a) for a in arrows], lag_ratio=0.15), run_time=1.3) self.play(FadeIn(peak, shift=UP * 0.15), run_time=0.8) self.wait(1.0) note = Text("The contrast stores energy. The jet is how it is held.", font_size=25, color=BLUE_3B1B) note.to_edge(DOWN, buff=0.55) self.play(FadeIn(note, shift=UP * 0.2), run_time=1.0) self.wait(1.5) self.play(*[FadeOut(m) for m in (head, band, axL, labL, warm, cold, jet_ax, jet, arrows, labR, peak, note)], run_time=1.0) # ------------------------------------------------------- 3. the instability def sec3_instability(self): head = Text("A tiny ripple taps that energy", font_size=38, color=WHITE, weight=BOLD) head.to_edge(UP, buff=0.55) self.play(FadeIn(head, shift=DOWN * 0.2), run_time=0.9) ax = Axes(x_range=[0, 4 * np.pi, np.pi], y_range=[-1.6, 1.6], x_length=11.0, y_length=3.2, axis_config={"color": GREYT, "stroke_width": 2, "include_ticks": False, "tip_length": 0.16}) ax.shift(DOWN * 0.35) amp = ValueTracker(0.08) wave = always_redraw(lambda: ax.plot( lambda x: amp.get_value() * np.sin(x), color=BLUE_3B1B, stroke_width=5)) flat = DashedLine(ax.c2p(0, 0), ax.c2p(4 * np.pi, 0), color=GREYT, stroke_width=1.6, dash_length=0.12) self.play(Create(ax), Create(flat), run_time=1.0) self.add(wave) lab = Text("pressure anomaly along the jet", font_size=21, color=GREYT) lab.next_to(ax, UP, buff=0.28) self.play(FadeIn(lab), run_time=0.7) self.wait(0.6) d1 = Text("day 1: 0.23 hPa (24 h mean)", font_size=24, color=GREYT) d1.next_to(ax, DOWN, buff=0.42) self.play(FadeIn(d1), run_time=0.7) self.wait(0.7) # the wave feeds and grows d10 = Text("day 10: 49.7 hPa (24 h mean)", font_size=26, color=YELLOW_3B1B, weight=BOLD) d10.move_to(d1) self.play(amp.animate.set_value(1.42), run_time=4.0, rate_func=rate_functions.ease_in_quad) self.play(FadeOut(d1, shift=DOWN * 0.15), FadeIn(d10, shift=DOWN * 0.15), run_time=0.7) self.play(Indicate(d10, color=YELLOW_3B1B, scale_factor=1.15), run_time=1.0) self.wait(0.9) growth = Text("214× in nine days", font_size=30, color=WHITE, weight=BOLD) growth.next_to(d10, DOWN, buff=0.3) self.play(FadeIn(growth, shift=UP * 0.2), run_time=0.9) self.wait(1.4) self.play(*[FadeOut(m) for m in (head, ax, flat, lab, d10, growth)], FadeOut(wave), run_time=1.0) # ------------------------------------------------- 4. exponential growth def sec4_growth_curve(self): head = Text("Growth is exponential, not gradual", font_size=36, color=WHITE, weight=BOLD) head.to_edge(UP, buff=0.5) self.play(FadeIn(head, shift=DOWN * 0.2), run_time=0.9) ax = Axes(x_range=[0, 10, 2], y_range=[-1, 2, 1], x_length=7.2, y_length=4.0, axis_config={"color": GREYT, "stroke_width": 2, "font_size": 22}, y_axis_config={"include_ticks": True, "include_numbers": False}) ax.shift(DOWN * 0.45 + LEFT * 2.3) xlab = Text("model day", font_size=21, color=GREYT).next_to(ax, DOWN, buff=0.30) # y tick labels first, THEN the axis title outside them (avoids collision) yt = VGroup() for val, txt in ((-1, "0.1"), (0, "1"), (1, "10"), (2, "100")): yt.add(Text(txt, font_size=19, color=GREYT) .next_to(ax.c2p(0, val), LEFT, buff=0.20)) ylab = Text("peak |PS′| (hPa, log)", font_size=20, color=GREYT) ylab.rotate(PI / 2).next_to(yt, LEFT, buff=0.22) self.play(Create(ax), FadeIn(xlab), FadeIn(ylab), FadeIn(yt), run_time=1.4) # days 1-10 only: the day-0 file is an instantaneous snapshot, the rest # are 24 h means, so including it would compare unlike quantities. pts = [ax.c2p(d, np.log10(a)) for d, a in zip(DAYS[1:], AMP[1:])] curve = VMobject(color="#C0392B", stroke_width=5).set_points_as_corners(pts) dots = VGroup(*[Dot(p, radius=0.055, color="#C0392B") for p in pts]) self.play(Create(curve), run_time=2.6) self.play(LaggedStart(*[FadeIn(d, scale=0.6) for d in dots], lag_ratio=0.06), run_time=1.1) straight = Text("straight on a log axis\n= constant doubling time", font_size=22, color=YELLOW_3B1B, line_spacing=0.85) straight.next_to(ax, RIGHT, buff=0.40).shift(UP * 1.05) straight.set_x(min(straight.get_x(), config.frame_width / 2 - straight.width / 2 - 0.25)) self.play(FadeIn(straight, shift=LEFT * 0.25), run_time=1.1) self.wait(1.0) tex_png(r"A(t)\;=\;A_0\,e^{\,t/\tau}", "eq_growth.png", color=BLUE_3B1B, fs=58) eq = ImageMobject("eq_growth.png").scale(0.46) eq.next_to(straight, DOWN, buff=0.55).set_x(straight.get_x()) tau = Text("e-folding time ≈ 1.7 days\n(doubles every 1.2 days)", font_size=20, color=WHITE, line_spacing=0.85) tau.next_to(eq, DOWN, buff=0.35).set_x(straight.get_x()) self.play(FadeIn(eq, shift=UP * 0.2), run_time=1.0) self.play(FadeIn(tau), run_time=0.8) self.wait(1.6) self.play(*[FadeOut(m) for m in (head, ax, xlab, ylab, yt, curve, dots, straight, tau)], FadeOut(eq), run_time=1.0) # --------------------------------------- 5. ascent, cooling, condensation def sec5_ascent_to_cloud(self): head = Text("Growing waves lift air", font_size=38, color=WHITE, weight=BOLD) head.to_edge(UP, buff=0.5) self.play(FadeIn(head, shift=DOWN * 0.2), run_time=0.9) # a rising parcel, cooling as it goes ground = Line(LEFT * 5.6 + DOWN * 2.5, RIGHT * 1.2 + DOWN * 2.5, color=GREYT, stroke_width=2.5) col = Rectangle(width=1.5, height=4.4, stroke_color=GREYT, stroke_width=1.4, fill_opacity=0) col.move_to(LEFT * 2.2 + DOWN * 0.3) parcel = Dot(col.get_bottom() + UP * 0.22, radius=0.15, color="#E8703C") plab = always_redraw(lambda: Text("air parcel", font_size=18, color="#E8703C") .next_to(parcel, RIGHT, buff=0.22)) arrow = Arrow(col.get_bottom() + UP * 0.1, col.get_top() - UP * 0.1, color=ORANGE, stroke_width=5, buff=0, max_tip_length_to_length_ratio=0.09) self.play(Create(ground), Create(col), run_time=0.9) self.play(FadeIn(parcel, scale=0.5), run_time=0.6) self.add(plab) self.play(GrowArrow(arrow), run_time=0.9) omega = Text("ω < 0 (ascent)", font_size=22, color=ORANGE) omega.next_to(col, LEFT, buff=0.35).shift(UP * 0.6) self.play(FadeIn(omega, shift=RIGHT * 0.2), run_time=0.8) steps = [ ("air rises", "#E8703C"), ("pressure falls", CLOUDC), ("it expands and cools", BLUE_3B1B), ("vapour condenses", CLOUDC), ] chain = VGroup() for i, (txt, colr) in enumerate(steps): t = Text(txt, font_size=25, color=colr) chain.add(t) chain.arrange(DOWN, buff=0.42, aligned_edge=LEFT) chain.move_to(RIGHT * 3.1 + UP * 0.35) arrows_c = VGroup(*[ Arrow(chain[i].get_bottom() + DOWN * 0.04, chain[i + 1].get_top() - DOWN * 0.04, buff=0.05, color=GREYT, stroke_width=2.2, max_tip_length_to_length_ratio=0.28) for i in range(len(steps) - 1) ]) self.play(parcel.animate.move_to(col.get_top() - UP * 0.3), run_time=3.0, rate_func=smooth) for i, t in enumerate(chain): self.play(FadeIn(t, shift=LEFT * 0.25), run_time=0.62) if i < len(arrows_c): self.play(GrowArrow(arrows_c[i]), run_time=0.3) cloud = VGroup(*[ Circle(radius=r, color=CLOUDC, fill_opacity=0.55, stroke_width=0) .move_to(col.get_top() + UP * 0.18 + RIGHT * dx + UP * dy) for r, dx, dy in ((0.30, -0.34, 0.0), (0.38, 0.0, 0.09), (0.28, 0.33, -0.02), (0.22, 0.14, 0.24)) ]) self.play(FadeIn(cloud, scale=0.6), run_time=1.0) self.wait(0.5) meas = Text("in the model: ascent 0.003 → 0.60 Pa/s (172×)", font_size=22, color=YELLOW_3B1B) meas.to_edge(DOWN, buff=0.5) self.play(FadeIn(meas, shift=UP * 0.2), run_time=1.0) self.wait(1.6) self.play(*[FadeOut(m) for m in (head, ground, col, parcel, arrow, omega, chain, arrows_c, cloud, meas)], FadeOut(plab), run_time=1.0) # ----------------------------------------------------- 6. the threshold def sec6_threshold(self): head = Text("Cloud droplets are not yet rain", font_size=38, color=WHITE, weight=BOLD) head.to_edge(UP, buff=0.5) self.play(FadeIn(head, shift=DOWN * 0.2), run_time=0.9) sub = Text("Kessler warm-rain physics needs about 1 g/kg of cloud water\n" "before droplets coalesce into raindrops", font_size=23, color=BLUE_3B1B, line_spacing=0.8) sub.next_to(head, DOWN, buff=0.34) self.play(FadeIn(sub), run_time=1.1) tex_png(r"\frac{dq_r}{dt}\;=\;k\,\left(q_c - q_{c,\mathrm{crit}}\right)", "eq_auto.png", color=CLOUDC, fs=54) eq = ImageMobject("eq_auto.png").scale(0.5) eq.next_to(sub, DOWN, buff=0.44) self.play(FadeIn(eq, shift=UP * 0.2), run_time=1.0) crit = Text("autoconversion begins only above q_c,crit ≈ 1000 mg/kg", font_size=20, color=VIOLET) crit.next_to(eq, DOWN, buff=0.3) self.play(FadeIn(crit), run_time=0.8) self.wait(0.8) self.play(FadeOut(sub), FadeOut(eq), FadeOut(crit), run_time=0.8) # gauge: cloud water vs the threshold, day by day gh, gw = 4.0, 1.05 gauge = Rectangle(width=gw, height=gh, stroke_color=GREYT, stroke_width=2.0) gauge.move_to(LEFT * 2.6 + DOWN * 0.55) thr = Line(gauge.get_corner(UL) + LEFT * 0.30, gauge.get_corner(UR), color=VIOLET, stroke_width=4) thrlab = Text("1000 mg/kg\nrain threshold", font_size=19, color=VIOLET, line_spacing=0.75) thrlab.next_to(thr, UP, buff=0.16).align_to(gauge, LEFT) lvl = ValueTracker(CLW[0]) fill = always_redraw(lambda: Rectangle( width=gw - 0.06, height=max(gh * min(lvl.get_value() / CL_THRESHOLD, 1.0), 0.012), fill_color=interpolate_color(ManimColor("#EAF1F8"), ManimColor(VIOLET), min(lvl.get_value() / CL_THRESHOLD, 1.0)), fill_opacity=0.92, stroke_width=0) .align_to(gauge, DOWN).shift(UP * 0.03) .set_x(gauge.get_x())) val = always_redraw(lambda: Text( f"{lvl.get_value():,.0f} mg/kg", font_size=24, color=WHITE, weight=BOLD) .next_to(gauge, LEFT, buff=0.34)) self.play(Create(gauge), Create(thr), FadeIn(thrlab), run_time=1.0) self.add(fill, val) # day counter driven by a tracker (a Text->Text Transform morphs glyphs # and shows garbled intermediates like "day s") dayv = ValueTracker(0.0) day_txt = always_redraw(lambda: Text( f"day {int(round(dayv.get_value()))}", font_size=26, color=YELLOW_3B1B if dayv.get_value() >= 7 else GREYT) .next_to(gauge, DOWN, buff=0.42)) self.add(day_txt) # walk through the days rainbox = None for i in range(1, 11): rt = 0.7 if i < 7 else 1.1 self.play(lvl.animate.set_value(CLW[i]), dayv.animate.set_value(float(DAYS[i])), run_time=rt, rate_func=smooth) if i == 7: jump = Text("condensation deepens\n4 → 575 mg/kg", font_size=22, color=CLOUDC, line_spacing=0.8) jump.move_to(RIGHT * 3.0 + DOWN * 1.75) self.play(FadeIn(jump, shift=LEFT * 0.2), run_time=0.8) self.wait(0.5) self.play(FadeOut(jump), run_time=0.5) if i == 8: rainbox = Text("rain begins", font_size=24, color=RAINC, weight=BOLD) rainbox.next_to(gauge, DOWN, buff=1.05) self.play(FadeIn(rainbox, shift=UP * 0.2), run_time=0.7) self.play(Flash(thr.get_center(), color=VIOLET, line_length=0.3, num_lines=14, flash_radius=0.7), run_time=1.0) punch = Text("99% of the threshold —\nnever quite crossed in the\ndaily means of days 1–10", font_size=23, color=VIOLET, line_spacing=0.85) punch.move_to(RIGHT * 3.0 + UP * 1.45) self.play(FadeIn(punch, shift=LEFT * 0.25), run_time=1.1) self.wait(0.8) why = Text("rain still falls: within each\n24 h mean, instantaneous\npeaks do cross it", font_size=21, color=RAINC, line_spacing=0.85) why.next_to(punch, DOWN, buff=0.40) self.play(FadeIn(why, shift=LEFT * 0.2), run_time=1.0) self.wait(2.0) self.play(*[FadeOut(m) for m in self.mobjects], run_time=1.1) # -------------------------------------------------------- 7. the movie def sec7_full_movie(self): head = Text("The simulation, ten days in sixty seconds", font_size=34, color=WHITE, weight=BOLD) head.to_edge(UP, buff=0.38) self.play(FadeIn(head, shift=DOWN * 0.2), run_time=1.0) v = VideoMobject(VID).scale_to_fit_width(12.3) v.shift(DOWN * 0.42) fr = screen_frame(v) v.show_time(0.0) self.play(FadeIn(v, scale=0.94), Create(fr), run_time=1.2) # key overlay: what to watch, tied to the video's own colours keys = VGroup( Text("orange = rising air", font_size=19, color=ORANGE), Text("pale = condensing cloud water", font_size=19, color=CLOUDC), Text("blue = rain at the surface", font_size=19, color=RAINC), ).arrange(RIGHT, buff=0.75) keys.next_to(v, DOWN, buff=0.26) self.play(FadeIn(keys), run_time=0.8) # Scrub the movie with a tracker so every caption lands on the model day # it describes, and the section always finishes at day 10. # Movie timing: the render dwells on days 0-6, so source-time is NOT # linear in model day. These cut points are the inverse of the movie's # own DAY_KEYS easing (day 4 -> 18.0 s, day 7 -> 34.2 s, day 8 -> 39.6 s, # day 10 -> 57.0 s), so each caption is on screen for the days it names. MOVIE_LEN = len(VID) / VID_FPS # 60.0 s t = ValueTracker(0.0) v.freeze() v.follow(t) beats = [ (18.0, 7.0, "days 0–4: the wave is invisible unless the colour scale is stretched", GREYT), (34.2, 6.0, "days 5–7: ascent strengthens and condensate appears", CLOUDC), (39.6, 3.0, "day 8: the first rain reaches the surface", RAINC), (MOVIE_LEN - 0.1, 8.0, "days 9–10: a mature cyclone with frontal rain bands", YELLOW_3B1B), ] cap = None for t_end, dur, txt, colr in beats: new = Text(txt, font_size=23, color=colr) new.next_to(keys, DOWN, buff=0.24) if cap is None: self.play(FadeIn(new), t.animate.set_value(t_end), run_time=dur, rate_func=linear) else: self.play(FadeOut(cap, shift=UP * 0.12), FadeIn(new, shift=UP * 0.12), t.animate.set_value(t_end), run_time=dur, rate_func=linear) cap = new v.freeze() self.wait(0.4) self.play(FadeOut(head), FadeOut(keys), FadeOut(cap), FadeOut(fr), v.animate.set_opacity(0.25), run_time=1.1) self.movie_mob = v # -------------------------------------------------------- 8. closing def sec8_closing(self): v = self.movie_mob lines = VGroup( Text("A 0.23 hPa ripple, left alone for nine days,", font_size=30, color=WHITE), Text("became a 49.7 hPa cyclone that rains 55 mm a day.", font_size=30, color=YELLOW_3B1B), ).arrange(DOWN, buff=0.34) lines.move_to(UP * 0.55) self.play(Write(lines[0]), run_time=1.6) self.play(FadeIn(lines[1], shift=UP * 0.2), run_time=1.2) self.wait(1.0) foot = Text("no external forcing, no source mods — just the jet's own stored energy", font_size=21, color=BLUE_3B1B) foot.next_to(lines, DOWN, buff=0.6) self.play(FadeIn(foot), run_time=1.0) self.wait(2.2) self.play(FadeOut(lines), FadeOut(foot), FadeOut(v), run_time=1.4) self.wait(0.5)