The eclipse in 3D — Sun → Moon → Earth, and the satellites that watched it¶
The other two notebooks show the 12 Aug 2026 eclipse from the geostationary satellites. This one steps back and shows the orbital line-up that causes an eclipse: as the Moon orbits Earth, it every so often passes directly between Earth and the Sun — and at that moment its shadow falls on Earth. Circling alongside are the two geostationary satellites that filmed it, GOES-East (75.2°W) and Meteosat-12 / MTG-I1 (0°).
It is a conceptual animation (matplotlib 3D), not an ephemeris-accurate simulation.
A note on scale¶
Sizes and distances cannot both be shown to scale: the Sun is ~109× Earth's width and 150 million km away. To keep the Sun, Earth, Moon and the satellites all visible in one frame, this view keeps Earth and Moon at correct relative size (1 : 0.273) but draws the Sun much smaller than reality and compresses all distances. The inset (bottom-left) shows the honest size ratio — 109 : 3.7 : 1 — where even at half-scale the Sun barely fits.
from pathlib import Path
import imageio_ffmpeg
import matplotlib
import matplotlib.animation as manim
import matplotlib.patheffects as pe
import matplotlib.pyplot as plt
import numpy as np
from IPython.display import Image, display
from matplotlib.patches import FancyBboxPatch
matplotlib.rcParams["animation.ffmpeg_path"] = imageio_ffmpeg.get_ffmpeg_exe()
R_EARTH, R_MOON = 1.0, 0.273 # correct relative size
R_SUN_VIS = 2.5 # Sun VISUAL size in the main view (far smaller than reality)
R_GEO_TRUE = 6.61 # geostationary orbit radius in Earth radii -- the real one
R_GEO = 2.2 # what is drawn: compressed (really 6.61)
# so the Moon's orbit clears it the way it really does
TILT = np.deg2rad(23.5)
MOON_INCL = np.deg2rad(28.6) # Moon orbit tilt to the equator; real range 18.3-28.6
D_SUN = 11.0 # Sun distance (compressed; clears the satellite orbit)
D_MOON = 6.0 # Moon orbit radius: compressed (really 60.3), but kept
# outside the geostationary ring, as it really is
SUN_POS = np.array([D_SUN, 0.0, 0.0])
SUN = np.array([1.0, 0.0, 0.0])
XLIM = (-7.0, 15.8) # left edge clears the Moon's orbit, right edge the Sun's corona
YZ = 7.0
# Greatest eclipse was 17:47 UTC, so the sub-solar longitude is 15 * (12 - 17.783) = -86.8 deg
# and Earth must be spun to put its noon meridian there. The umbra struck ~65N 25W.
# Frames per full Moon orbit / Earth turn in the wide clip. This is the clip's pace:
# raising it slows the Moon and the satellites without changing any geometry.
N_WIDE = 180
SUBSOLAR_LON = -86.8
SPIN_ECLIPSE = np.deg2rad(-SUBSOLAR_LON)
ECLIPSE_LAT, ECLIPSE_LON = 65.0, -25.0
import os
import subprocess
from matplotlib.path import Path as MPath
from scipy.ndimage import gaussian_filter
def tilt_y(x, z):
"""Apply Earth's 23.5° axial tilt (rotation about the y-axis)."""
return x * np.cos(TILT) + z * np.sin(TILT), -x * np.sin(TILT) + z * np.cos(TILT)
def sphere(c, r, n=40):
"""(x, y, z) surface mesh of a sphere centred at `c`, radius `r`."""
u = np.linspace(0, 2 * np.pi, n)
v = np.linspace(0, np.pi, n)
return (
c[0] + r * np.outer(np.cos(u), np.sin(v)),
c[1] + r * np.outer(np.sin(u), np.sin(v)),
c[2] + r * np.outer(np.ones_like(u), np.cos(v)),
)
def shade(x, y, z, c, rgb, amb=0.12):
"""Day/night shading from the angle between the surface normal and the Sun direction."""
nx, ny, nz = x - c[0], y - c[1], z - c[2]
nn = np.sqrt(nx**2 + ny**2 + nz**2)
it = np.clip((nx * SUN[0] + ny * SUN[1] + nz * SUN[2]) / nn, 0, 1) * (1 - amb) + amb
return it[..., None] * np.array(rgb)[None, None, :]
def ll_to_xyz(lon, lat, spin):
"""Longitude/latitude (deg) -> tilted 3D coordinates on Earth's surface, rotated by `spin`."""
lon = np.deg2rad(lon) + spin
lat = np.deg2rad(lat)
x = np.cos(lat) * np.cos(lon)
y = np.cos(lat) * np.sin(lon)
z = np.sin(lat)
xr, zr = tilt_y(x, z)
return xr, y, zr
_ORBIT = None
def moon_orbit_basis():
"""Orbit basis whose phase-0 direction lies on the Sun -> real-eclipse-point line.
Two things have to hold at once. Phase 0 must point at the Moon position that throws the
umbra onto the real 12 Aug 2026 landing point, otherwise the alignment frame is wrong. And
the orbit plane must sit at a believable angle to the equator: the Moon's orbit is tilted
18.3 to 28.6 degrees to it over the nodal cycle, never over the poles.
Of all the planes containing the phase-0 direction, this picks the one inclined
`MOON_INCL` to Earth's rotation axis. That direction already sits 25.7 degrees off the
equator here -- the compressed Sun distance steepens the Sun-to-ground ray -- so the
inclination cannot go below that, and `MOON_INCL` is the real upper limit.
"""
global _ORBIT
if _ORBIT is None:
gx, gy, gz = ll_to_xyz(
np.array([ECLIPSE_LON]), np.array([ECLIPSE_LAT]), SPIN_ECLIPSE
)
target = np.array([gx[0], gy[0], gz[0]])
d = target - SUN_POS
d /= np.linalg.norm(d)
b = 2.0 * float(SUN_POS @ d)
c = float(SUN_POS @ SUN_POS) - D_MOON**2
s_near = (-b - np.sqrt(b * b - 4.0 * c)) / 2.0 # root between Sun and Earth
m_hat = SUN_POS + s_near * d
m_hat /= np.linalg.norm(m_hat)
axis = np.array([np.sin(TILT), 0.0, np.cos(TILT)]) # Earth's rotation axis
u = np.cross(axis, m_hat) # perpendicular to both
u /= np.linalg.norm(u)
v = np.cross(m_hat, u) # completes the frame around m_hat
share = np.cos(MOON_INCL) / float(v @ axis) # how much of the normal tilts away
normal = np.sqrt(max(1.0 - share * share, 0.0)) * u + share * v
_ORBIT = (m_hat, np.cross(normal, m_hat))
return _ORBIT
def moon_position(phase):
"""Moon position on its inclined orbit; phase 0 is the eclipse alignment."""
m_hat, w = moon_orbit_basis()
return D_MOON * (np.cos(phase) * m_hat + np.sin(phase) * w)
def umbra_hits_earth(m):
"""Does the Moon's shadow (the Sun->Moon ray, extended) strike Earth? Return (hits, hit_point)."""
d = m - SUN_POS
d = d / np.linalg.norm(d)
t_star = -np.dot(m, d)
if t_star <= 0:
return False, None
closest = m + t_star * d
if np.linalg.norm(closest) < R_EARTH * 1.05:
t_hit = t_star - np.sqrt(max(R_EARTH**2 - np.dot(closest, closest), 0))
return True, m + t_hit * d
return False, None
_RELIEF = None
def relief_texture():
"""cleopatra's global shaded relief as an (H, W, 3) array in 0..1, on a lon/lat grid."""
global _RELIEF
if _RELIEF is None:
import cleopatra.basemap.reference as ref
fig, ax = plt.subplots()
ref.add_relief(ax, resolution="medium", crs=4326)
img = np.asarray(ax.images[-1].get_array()).astype("float32")
plt.close(fig)
_RELIEF = np.clip(img[..., :3] / 255.0, 0, 1)
return _RELIEF
def textured_earth(ax, spin, n_lon=200, n_lat=100):
"""Earth as a lit, texture-mapped sphere -- real continents, tilted and spun."""
tex = relief_texture()
h, w = tex.shape[:2]
lon_g, lat_g = np.meshgrid(
np.linspace(-180.0, 180.0, n_lon), np.linspace(90.0, -90.0, n_lat)
)
col = np.clip(((lon_g + 180.0) / 360.0 * (w - 1)).astype(int), 0, w - 1)
row = np.clip(((90.0 - lat_g) / 180.0 * (h - 1)).astype(int), 0, h - 1)
rgb = tex[row, col]
lo, la = np.deg2rad(lon_g) + spin, np.deg2rad(lat_g)
x, y, z = np.cos(la) * np.cos(lo), np.cos(la) * np.sin(lo), np.sin(la)
xr, zr = tilt_y(x, z)
lit = np.clip(xr * SUN[0] + y * SUN[1] + zr * SUN[2], 0, 1) # day / night
face = np.clip(rgb * (0.13 + 0.87 * lit)[..., None], 0, 1)
ax.plot_surface(
xr,
y,
zr,
facecolors=face,
rstride=1,
cstride=1,
linewidth=0,
antialiased=False,
shade=False,
zorder=4,
)
def draw_sun(ax, centre, radius, elev=27.0, azim=-62.0, corona=True, alpha=1.0, n=80):
"""A photosphere instead of a flat disc: limb darkening, granulation and a corona.
Real solar images are bright and near-white at disc centre and redden sharply toward
the edge, because the slanted line of sight there samples higher, cooler gas. That
limb darkening, some granulation mottling and a few translucent shells for the corona
are enough to read as the Sun rather than a yellow ball.
"""
view = np.array(
[
np.cos(np.deg2rad(elev)) * np.cos(np.deg2rad(azim)),
np.cos(np.deg2rad(elev)) * np.sin(np.deg2rad(azim)),
np.sin(np.deg2rad(elev)),
]
)
x, y, z = sphere(centre, radius, n)
nx = (x - centre[0]) / radius
ny = (y - centre[1]) / radius
nz = (z - centre[2]) / radius
mu = np.clip(
nx * view[0] + ny * view[1] + nz * view[2], 0.0, 1.0
) # cos(angle to viewer)
inten = 1.0 - 0.68 * (1.0 - mu) # classic limb-darkening law
rng = np.random.default_rng(7)
grain = gaussian_filter(rng.normal(0.0, 1.0, size=inten.shape), sigma=1.6)
grain *= 0.05 / max(float(np.abs(grain).max()), 1e-6) * 1.0
inten = np.clip(inten + grain * mu, 0.0, 1.0) # faint granulation mottling
core = np.array([1.00, 0.98, 0.90]) # near-white at disc centre
edge = np.array([0.96, 0.45, 0.09]) # deep orange at the limb
t = inten[..., None] ** 1.6
face = np.clip(edge + (core - edge) * t, 0, 1)
face = face * float(alpha) # dim toward the black sky: no translucent-quad seams
ax.plot_surface(
x,
y,
z,
facecolors=face,
rstride=1,
cstride=1,
linewidth=0,
antialiased=False,
shade=False,
zorder=3,
)
if corona:
# a soft screen-space halo: concentric line rings fade out smoothly, unlike
# translucent spheres, whose silhouettes stack into visible onion rings
up = np.cross(view, [0.0, 0.0, 1.0])
up /= np.linalg.norm(up)
side = np.cross(view, up)
ang = np.linspace(0, 2 * np.pi, 240)
for scale in np.linspace(0.995, 1.9, 44):
fade = 0.085 * (1.0 - (scale - 0.995) / 0.905) ** 2.0
ring = centre[:, None] + radius * scale * (
np.cos(ang) * up[:, None] + np.sin(ang) * side[:, None]
)
ax.plot(
ring[0],
ring[1],
ring[2],
color="#ffbe55",
lw=3.6,
alpha=float(fade * alpha),
zorder=1,
)
def save_clip(
fig, render, n_frames, stem, fps=20, bitrate=3600, dpi=150, gif_width=1080
):
"""Write an MP4 of the clip, then derive a GIF from it.
Drawing is by far the expensive part, so each frame is rendered exactly once, into the
MP4. The GIF is then built from that file with ffmpeg's two-pass palette: one palette
chosen across the whole clip, applied with error diffusion.
Quantising per frame instead fails here. Palette entries get handed out by pixel
population, and the Sun's corona is a huge smooth gradient, so it claims nearly the
whole palette and the satellites' colours are dropped -- their meridians and labels
come out grey for as long as the Sun is on screen.
Args:
render: Callable taking a frame index and drawing that frame onto `fig`.
stem: Output path without a suffix; `.mp4` and `.gif` are written beside it.
gif_width: Width of the GIF in pixels; the MP4 keeps the full `dpi` size.
Returns:
Path to the GIF, as a string.
"""
writer = manim.FFMpegWriter(fps=fps, bitrate=bitrate)
mp4 = f"{stem}.mp4"
with writer.saving(fig, mp4, dpi):
for i in range(n_frames):
render(i)
writer.grab_frame()
gif, palette = f"{stem}.gif", f"{stem}_palette.png"
exe = imageio_ffmpeg.get_ffmpeg_exe()
scale = f"scale={gif_width}:-1:flags=lanczos"
subprocess.run(
[
exe,
"-y",
"-v",
"error",
"-i",
mp4,
"-vf",
f"{scale},palettegen=max_colors=256:stats_mode=diff",
palette,
],
check=True,
)
subprocess.run(
[
exe,
"-y",
"-v",
"error",
"-i",
mp4,
"-i",
palette,
"-lavfi",
f"{scale} [x]; [x][1:v] paletteuse=dither=sierra2_4a:diff_mode=rectangle",
gif,
],
check=True,
)
os.remove(palette)
return gif
def _glyph_rect(x0, y0, x1, y1):
"""Vertices and codes for one closed rectangle of a compound marker path."""
return (
[(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)],
[MPath.MOVETO, MPath.LINETO, MPath.LINETO, MPath.LINETO, MPath.CLOSEPOLY],
)
def _satellite_marker():
"""A satellite silhouette: two solar wings flanking a small bus on short booms.
Drawn as a marker rather than as 3D geometry on purpose. A modelled satellite would
be two or three pixels across in the wide framing and vanish; a marker is sized in
points, so it stays legible at every zoom.
"""
verts, codes = [], []
for box in (
(-0.17, -0.30, 0.17, 0.30), # bus
(-0.40, -0.035, -0.17, 0.035), # left boom
(0.17, -0.035, 0.40, 0.035), # right boom
(-1.00, -0.62, -0.40, 0.62), # left wing
(0.40, -0.62, 1.00, 0.62),
): # right wing
v, c = _glyph_rect(*box)
verts += v
codes += c
return MPath(verts, codes)
SATELLITE = _satellite_marker()
def draw(ax, moon_phase, spin, cam_azim=-62, moon_pos=None, rect=None):
"""One frame: Sun, spinning tilted Earth, orbiting Moon (+ shadow when aligned), satellites."""
if rect is not None:
ax.set_position(rect)
ax.clear()
ax.set_facecolor("black")
ax.set_axis_off()
# Sun (visual size) + Sun-Earth line
draw_sun(ax, SUN_POS, R_SUN_VIS, elev=27.0, azim=cam_azim)
ax.text(
D_SUN,
0,
R_SUN_VIS + 1.5,
"Sun",
color="#ffd9a0",
fontsize=13,
ha="center",
weight="bold",
)
ax.plot([0, D_SUN], [0, 0], [0, 0], color="#666", lw=0.5, ls=(0, (5, 4)), zorder=1)
# Earth: real continents, texture-mapped from cleopatra's global relief
textured_earth(ax, spin, n_lon=96, n_lat=48)
# Moon orbit ring + Moon (orbit inclined so the umbra lands where it really did)
oa = np.linspace(0, 2 * np.pi, 160)
ring = np.array([moon_position(a) for a in oa])
ax.plot(ring[:, 0], ring[:, 1], ring[:, 2], color="#444", lw=0.5, zorder=3)
m = moon_position(moon_phase) if moon_pos is None else moon_pos
mx, my, mz = sphere(m, R_MOON, 26)
ax.plot_surface(
mx,
my,
mz,
facecolors=shade(mx, my, mz, m, (0.72, 0.72, 0.72)),
rstride=1,
cstride=1,
linewidth=0,
antialiased=False,
shade=False,
zorder=7,
)
ax.text(m[0], m[1], m[2] + 0.5, "Moon", color="#dddddd", fontsize=11, ha="center")
# Moon shadow: a cone along the Sun->Moon direction; reaches Earth only at alignment
d = m - SUN_POS
d = d / np.linalg.norm(d)
hits, hp = umbra_hits_earth(m)
length = np.linalg.norm(hp - m) if hits else 3.0
tv = np.linspace(0, 1, 16)
th = np.linspace(0, 2 * np.pi, 16)
tt, thh = np.meshgrid(tv, th)
up = np.array([0, 0, 1.0])
a1 = np.cross(d, up)
a1 /= np.linalg.norm(a1)
a2 = np.cross(d, a1)
rad = R_MOON * (1 - 0.7 * tt)
pc = (
m[:, None, None]
+ d[:, None, None] * (length * tt)[None]
+ a1[:, None, None] * (rad * np.cos(thh))[None]
+ a2[:, None, None] * (rad * np.sin(thh))[None]
)
ax.plot_surface(
pc[0],
pc[1],
pc[2],
color="#000000",
alpha=0.33 if hits else 0.16,
linewidth=0,
antialiased=False,
shade=False,
zorder=6,
)
if hits:
ax.scatter(
[hp[0] * 1.01],
[hp[1] * 1.01],
[hp[2] * 1.01],
s=60,
color="black",
edgecolors="#111",
zorder=8,
)
ax.text(
0,
0,
R_EARTH + 2.2,
"☀ SUN – MOON – EARTH aligned: eclipse!",
color="#ffd21a",
fontsize=12.5,
ha="center",
weight="bold",
)
# geostationary satellites (co-rotate with Earth) + orbit ring
ang = np.linspace(0, 2 * np.pi, 120)
ox, oz = tilt_y(R_GEO * np.cos(ang), np.zeros_like(ang))
ax.plot(ox, R_GEO * np.sin(ang), oz, color="#555", lw=0.5, zorder=3)
for lon_deg, col, name in [
(0.0, "#b06bff", "Meteosat-12"),
(-75.2, "#ff6a3d", "GOES-East"),
]:
a = np.deg2rad(lon_deg) + spin
px, pz = tilt_y(R_GEO * np.cos(a), 0.0)
py = R_GEO * np.sin(a)
ax.scatter(
[px],
[py],
[pz],
s=150,
marker=SATELLITE,
color=col,
depthshade=False,
zorder=8,
)
ax.text(
px, py, pz - 0.85, name, color=col, fontsize=10.5, ha="center", zorder=9
)
# the scene is a long, flat corridor, so crop the vertical black; matching box_aspect
# to the ranges keeps equal units per axis, so the spheres stay round
zlim = YZ * 0.46
ax.set_box_aspect((XLIM[1] - XLIM[0], 2 * YZ, 2 * zlim))
ax.set_xlim(*XLIM)
ax.set_ylim(-YZ, YZ)
ax.set_zlim(-zlim, zlim)
ax.view_init(elev=27, azim=cam_azim)
The animation¶
The Moon makes one orbit of Earth (90 frames). Watch it swing round from behind Earth to in front: as it crosses directly between Earth and the Sun, the three line up, the Moon's shadow cone reaches Earth, and the eclipse callout fires — then the alignment breaks as the Moon moves on. Earth spins on its tilted axis throughout, and GOES-East and Meteosat-12 ride around with it (they are geostationary).
Branding the frames¶
Two overlays go onto every frame. The satellite name is the fixed identity so it takes the upper line, with the per-frame clock beneath it; top-left is the cleanest text zone in these clips, though not clean enough for bare white text, so both lines get an outline and a shared scrim. The earthlens mark goes bottom-right at 8% of the frame width -- the largest size that stays clear of the Sun in the geometry animation, and still above the mark's 80 px legibility floor in the GIF.
BRAND_DIR = Path("../../../_images/branding/earthlens-brand-kit/logo")
BRAND_MARK = BRAND_DIR / "earthlens-lockup-stacked-overlay-full-transparent.png"
# the transparent variant: this clip is black under the mark (mean luminance 0.8), so the
# shadow alone separates it and no plate is needed -- a plate would read as a box on black
LOGO_FRAC = 0.11 # of figure width
LOGO_MARGIN_Y = 0.0 # of figure height -- tucked low, which also clears the Sun
LOGO_MARGIN = 0.025
def _mark_with_shadow(path, blur=0.05, strength=0.9):
"""The mark pre-composited over a blurred dark copy of itself.
A shadow rather than a panel: the mark keeps its transparent background, but the dark halo
gives the near-white wordmark something to sit against. Over sunlit cloud the bare mark
measures ~1 contrast, which is invisible.
Args:
path: Path to the RGBA lockup.
blur: Shadow blur radius, as a fraction of mark width.
strength: Shadow opacity, 0..1.
Returns:
tuple: `(rgba, grow)` -- the composited image and how much wider it is than the mark,
so the caller can size the axes by the mark rather than by the padded canvas.
"""
mark = plt.imread(path)
if mark.shape[2] == 3: # no alpha: treat black as transparent
mark = np.dstack([mark, mark.max(axis=2)])
pad = int(round(mark.shape[1] * blur * 3))
mark = np.pad(mark, ((pad, pad), (pad, pad), (0, 0)))
shade = gaussian_filter(mark[..., 3], sigma=mark.shape[1] * blur) * strength
out = np.zeros_like(mark)
out[..., 3] = np.clip(mark[..., 3] + shade * (1.0 - mark[..., 3]), 0.0, 1.0)
share = np.divide(mark[..., 3], np.maximum(out[..., 3], 1e-6))
out[..., :3] = mark[..., :3] * share[..., None] # shadow contributes black
return out, mark.shape[1] / (mark.shape[1] - 2 * pad)
def stamp_logo(fig, frac=LOGO_FRAC, margin=LOGO_MARGIN):
"""Draw the earthlens mark in the figure's bottom-right corner.
Sized as a fraction of the figure, not in pixels, because the MP4 and the GIF are saved
from the same figure at different dpi -- a pixel size would come out inconsistent.
"""
rgba, grow = _mark_with_shadow(BRAND_MARK)
fig_w, fig_h = fig.get_size_inches()
width = frac * grow
height = width * (rgba.shape[0] / rgba.shape[1]) * (fig_w / fig_h)
inset = fig.add_axes([1.0 - margin - width, LOGO_MARGIN_Y, width, height], zorder=6)
inset.set_axis_off()
inset.patch.set_alpha(0.0)
inset.imshow(rgba, interpolation="antialiased")
return inset
SCRIM = {"facecolor": "#091c30", "alpha": 0.55, "edgecolor": "none"}
TITLE_LINES = ("Eclipse geometry · geostationary orbits", "Meteosat-12 & GOES-East")
def stamp_title_block(fig, lines=TITLE_LINES, sizes=(16, 14), x=0.02, y=0.965):
"""Draw the fixed identity block top-left, on a scrim, in figure coordinates.
Figure coordinates rather than axes coordinates: every frame calls `ax.clear()`, which
would take axes-level text and patches with it. `y` sits one line below the centred
caption, which now sits along the bottom.
"""
stroke = [pe.withStroke(linewidth=3, foreground="black")]
step = 0.048
texts = [
fig.text(
x,
y - i * step,
line,
ha="left",
va="top",
color="white",
fontsize=size,
zorder=5,
path_effects=stroke,
)
for i, (line, size) in enumerate(zip(lines, sizes))
]
fig.canvas.draw() # text extents are only known once the figure has been laid out
boxes = [t.get_window_extent() for t in texts]
pad = 0.30 * min(b.height for b in boxes)
x0, y0 = min(b.x0 for b in boxes) - pad, min(b.y0 for b in boxes) - pad
x1, y1 = max(b.x1 for b in boxes) + pad, max(b.y1 for b in boxes) + pad
(fx0, fy0), (fx1, fy1) = fig.transFigure.inverted().transform([(x0, y0), (x1, y1)])
fig.add_artist(
FancyBboxPatch(
(fx0, fy0),
fx1 - fx0,
fy1 - fy0,
boxstyle="round,pad=0.003,rounding_size=0.010",
transform=fig.transFigure,
zorder=4,
**SCRIM,
)
)
return texts
OUT = Path("out") / "eclipse_geometry_3d"
OUT.mkdir(parents=True, exist_ok=True)
N = N_WIDE
fig = plt.figure(figsize=(12, 8), facecolor="black")
ax = fig.add_axes([-0.353, -0.517, 1.763, 1.931], projection="3d")
# mplot3d orders artists by mean depth, which lets Earth's surface mesh paint over lines
# in front of it; honour the zorder the drawing assigns instead. draw_scene already masks
# the far side, so nothing hidden becomes visible.
ax.computed_zorder = False
def _frame(i):
moon_phase = (
-np.pi + 2 * np.pi * i / N
) # behind Earth -> aligned (middle) -> behind
spin = (
SPIN_ECLIPSE + 2 * np.pi * (i - N / 2) / N
) # aligned frame == the real 17:47 UTC orientation
draw(ax, moon_phase=moon_phase, spin=spin)
return []
gif_path = save_clip(fig, _frame, N, OUT / "eclipse_geometry_3d", bitrate=3400)
plt.close(fig)
display(Image(filename=gif_path))
What to notice¶
- The Moon orbits Earth. Only when it passes directly between Earth and the Sun do the three bodies line up — that alignment is what makes a solar eclipse. Most orbits it passes above or below the line and there is no eclipse; here it is drawn in the same plane so the line-up happens each pass.
- The shadow only reaches Earth at alignment. The dark cone is the Moon's umbra; the black spot on Earth is where totality is seen — and it is placed for real: at the aligned frame Earth carries the orientation it had at 17:47 UTC (noon meridian at 86.8°W) and the umbra strikes 65°N 25°W, the North Atlantic. That is why GOES-East sits almost under the Sun while Meteosat-12 rides the evening terminator — exactly the vantage points the two imagery notebooks show.
- Earth spins on its 23.5°-tilted axis; the lit hemisphere always faces the Sun.
- The two satellites orbit with Earth — geostationary, so each stays above its own longitude (GOES-East 75.2°W, Meteosat-12 0°), which is what lets them film the shadow crossing the ground.
- The Sun is drawn far smaller than reality so everything fits (it is really ~109× Earth's width).
MP4 + GIF are written to
out/eclipse_geometry_3d/.
Closing in on the moment of alignment¶
The wide clip above shows that the line-up happens; this one flies in on it. The camera zooms and swings round while the rotation eases to a stop exactly at 17:47 UTC, the annotation fades in, and the last fifteen percent of the clip holds on the instant itself. The satellites' orbit is compressed on the way in so the globe reads large — the same trade-off the still below uses.
The final frame is the still that follows: both come from the same draw_scene() call, so the animation
and the diagram cannot drift apart.
# --- the real 12 Aug 2026 track -------------------------------------------------------
# Anchors are the published central-line extremes and greatest eclipse (EclipseWise /
# NASA); the points between them are interpolated along the same hook-shaped path. The
# track runs unusually east-to-west over the Arctic, then bends south-east to Iberia:
# Arctic Russia -> Greenland -> Iceland -> Spain -> western Mediterranean.
TRACK = [
(17.001, 75.08, 113.46), # central line touches down, Arctic Siberia
(17.20, 79.5, 70.0), # north-west across the Arctic Ocean
(17.40, 80.5, 20.0), # closest approach to the pole (it just misses it)
(17.55, 77.0, -10.0), # Greenland Sea
(17.65, 71.5, -20.0), # eastern Greenland
(17.765, 65.22, -25.23), # GREATEST ECLIPSE -- 45 km west of Iceland, 2 min 18 s
(17.90, 58.5, -22.0), # out over the North Atlantic
(18.05, 52.5, -17.0),
(18.20, 47.0, -11.0),
(18.35, 43.0, -3.5), # northern Spain, minutes before sunset
(18.536, 38.68, 5.42), # central line lifts off, western Mediterranean
]
T_GREATEST = 17.765 # 17:45:53 UT
T_START, T_END = 17.10, 18.536
# named places the shadow passes, shown as a caption while it is near them
PLACES = [
(17.00, 17.52, "the Arctic"),
(17.52, 17.70, "Greenland"),
(17.70, 17.87, "Iceland"),
(17.87, 18.26, "the North Atlantic"),
(18.26, 18.46, "Spain"),
(18.46, 19.00, "the Mediterranean"),
]
SATS = [
(0.0, "#b06bff", "Meteosat-12 0°\nEurope / Africa"),
(-75.2, "#ff6a3d", "GOES-East 75.2°W\nthe Americas"),
]
CAP = np.degrees(
np.arccos(1.0 / R_GEO_TRUE)
) # 81.3 deg: the disk a geostationary satellite
# sees. It follows from the real orbit radius, not the compressed one the drawing uses.
# The close-up is a schematic, not a scale model: the geostationary orbit and the Moon's
# distance are both compressed so that Earth, both satellites, their whole orbit and the
# Moon fit one frame with Earth still large enough to read continents on.
RGEO_0, RGEO_1 = R_GEO, 1.60 # orbit radius, wide -> close-up
MOON_0, MOON_1 = 1.0, 0.50 # how far along its true direction the Moon is *drawn*:
# 8.0 wide (outside the 6.61 ring), 2.40 close (outside 1.95)
LIM_0, LIM_1 = 7.0, 2.75 # half-width of the view box; LIM_0 is animation 1's exactly
XLIM_0 = (-7.0, 15.8) # the wide shot is a corridor: Earth left, Sun right
ZCROP_0, ZCROP_1 = 0.46, 0.62 # the scene is flat, so crop the vertical black
AZIM_0, AZIM_1 = 60.0, 51.0 # animation 1's own -62 deg puts Meteosat-12 behind Earth,
AZIM_WIDE = -62.0 # so the run-on frames swing the camera round to AZIM_0
# before the cut: both meridians are then up immediately
ELEV_0, ELEV_1 = 27.0, 25.0
N_CLOSE = 240
APPROACH_FRAMES = 70 # fly in while the shadow crosses the Arctic
SUN_EXIT = 0.22 # fraction of the fly-in spent fading the Sun out first
# One axes rect cannot compose both a wide corridor and a tight globe, so the rect is
# interpolated along with the camera. Each was fitted to fill the 12x8 frame at that stage.
RECT_WIDE = (-0.353, -0.517, 1.763, 1.931) # animation 1's framing, azim -62
RECT_OPEN = (-0.462, -0.582, 1.824, 1.998) # the close-up's first frame, azim +60
RECT_CLOSE = (-0.251, -0.552, 1.851, 2.010) # the close-up once it has settled
def lerp_rect(a, b, s):
"""Blend two axes rects, so the frame recomposes as the shot changes."""
s = float(np.clip(s, 0.0, 1.0))
return tuple(x + (y - x) * s for x, y in zip(a, b))
def spin_for_time(t_hours):
"""Earth's rotation angle for a UT time, so the noon meridian sits at 15*(12 - t)."""
return np.deg2rad(15.0 * (t_hours - 12.0))
def track_point(t_hours):
"""Interpolate the central line's (lat, lon) at a UT time."""
ts = [w[0] for w in TRACK]
t = float(np.clip(t_hours, ts[0], ts[-1]))
lat = np.interp(t, ts, [w[1] for w in TRACK])
lon = np.interp(t, ts, [w[2] for w in TRACK])
return float(lat), float(lon)
def place_at(t_hours):
"""The name of the region the shadow is over, for the caption."""
for t0, t1, label in PLACES:
if t0 <= t_hours < t1:
return label
return ""
def moon_for_target(target):
"""Moon position that throws the umbra onto `target` (a unit vector on Earth).
Solves |Sun + s*d| = D_MOON along the Sun->target ray and takes the near root, so the
Moon sits between Sun and Earth on exactly the line that hits the wanted ground point.
"""
d = target - SUN_POS
d /= np.linalg.norm(d)
b = 2.0 * float(SUN_POS @ d)
c = float(SUN_POS @ SUN_POS) - D_MOON**2
disc = max(b * b - 4.0 * c, 0.0)
return SUN_POS + ((-b - np.sqrt(disc)) / 2.0) * d
def _cam(elev, azim):
"""Unit vector from Earth toward the camera, used to hide far-side annotation."""
return np.array(
[
np.cos(np.deg2rad(elev)) * np.cos(np.deg2rad(azim)),
np.cos(np.deg2rad(elev)) * np.sin(np.deg2rad(azim)),
np.sin(np.deg2rad(elev)),
]
)
def draw_scene(
ax,
t_hours,
spin,
moon_pos,
r_geo,
lim,
view,
xlim=None,
z_crop=ZCROP_1,
moon_scale=1.0,
context=1.0,
sun_alpha=0.0,
zoom=1.0,
):
"""Earth, Moon and both satellites at one UT instant.
Args:
moon_pos: the Moon's true position, which fixes where the umbra lands.
moon_scale: how far along that direction the Moon is *drawn*; below 1 it is
pulled in so it still fits the close-up frame. The shadow is always computed
from the true position, then drawn from the position on screen.
context: fades out the Moon's orbit ring and the shadow cone as the camera
settles; the satellite annotation does not wait for it.
sun_alpha: opacity of the Sun; it dissolves rather than being cut by the frame.
z_crop: vertical crop of the view box, as a fraction of `lim`.
zoom: 0 at the opening framing, 1 once settled; recomposes the axes rect.
"""
elev, azim = view # the camera pair, kept together so the signature stays short
ax.set_position(lerp_rect(RECT_OPEN, RECT_CLOSE, zoom))
ax.clear()
ax.set_facecolor("black")
ax.set_axis_off()
cam = _cam(elev, azim)
m_true = moon_pos
m = m_true * moon_scale
# Sun -> Moon -> Earth alignment axis: always drawn, so it survives the Sun leaving frame
to_sun = SUN_POS - m
to_sun /= np.linalg.norm(to_sun)
tail_pt = m - to_sun * 3.2
head_pt = m + to_sun * lim * 1.25
ax.plot(
[tail_pt[0], head_pt[0]],
[tail_pt[1], head_pt[1]],
[tail_pt[2], head_pt[2]],
color="#ffd21a",
lw=0.8,
ls=(0, (7, 5)),
alpha=0.6,
zorder=1,
)
if sun_alpha > 0.02:
# The Sun recedes rather than dimming. `draw_sun` fades by multiplying its colours
# toward black -- alpha on a plot_surface leaves seams between the quads -- so a
# straight fade takes it through dark orange and then brown. Shrinking it as it
# goes reads as distance instead, and it keeps its own colour until it vanishes.
shrink = float(sun_alpha) ** 0.45
ax.plot(
[SUN_POS[0]], [SUN_POS[1]], [SUN_POS[2]], alpha=0.0
) # keep the box stable
draw_sun(
ax,
SUN_POS,
R_SUN_VIS * shrink,
elev=elev,
azim=azim,
alpha=float(np.clip(sun_alpha * 2.2, 0.0, 1.0)),
)
ax.text(
D_SUN,
0,
R_SUN_VIS * shrink * 1.35,
"Sun",
color="#ffd9a0",
fontsize=13,
ha="center",
weight="bold",
alpha=float(sun_alpha),
)
if sun_alpha < 0.35:
tip = m + to_sun * lim * 0.62
ax.text(
tip[0],
tip[1],
tip[2],
"to the Sun →",
color="#ffd21a",
fontsize=11.5,
ha="center",
va="center",
alpha=float(1.0 - sun_alpha),
zorder=10,
)
ring_alpha = 1.0 - float(np.clip(context, 0, 1))
if ring_alpha > 0.02:
oa = np.linspace(0, 2 * np.pi, 160)
ring = np.array([moon_position(a) for a in oa]).T * moon_scale
ax.plot(
ring[0], ring[1], ring[2], color="#444", lw=0.5, alpha=ring_alpha, zorder=3
)
# Earth spans a few dozen pixels in the wide framing and a few hundred in the
# close-up, so the mesh follows the zoom instead of always paying for the close-up
detail = int(np.clip(round(240 * LIM_1 / lim), 96, 240))
textured_earth(ax, spin, n_lon=detail, n_lat=detail // 2)
mx, my, mz = sphere(m, R_MOON, 26)
ax.plot_surface(
mx,
my,
mz,
facecolors=shade(mx, my, mz, m, (0.72, 0.72, 0.72)),
rstride=1,
cstride=1,
linewidth=0,
antialiased=False,
shade=False,
zorder=7,
)
ax.text(m[0], m[1], m[2] + 0.45, "Moon", color="#dddddd", fontsize=11, ha="center")
# the umbra is solved from the true Moon, then drawn from the Moon on screen
hits, hp = umbra_hits_earth(m_true)
d = (hp - m) if hits else (m - SUN_POS)
d = d / np.linalg.norm(d)
length = float(np.linalg.norm(hp - m)) if hits else 2.5
tv, th = np.meshgrid(np.linspace(0, 1, 16), np.linspace(0, 2 * np.pi, 16))
a1 = np.cross(d, [0, 0, 1.0])
a1 /= np.linalg.norm(a1)
a2 = np.cross(d, a1)
rad = R_MOON * (1 - 0.7 * tv)
cone = (
m[:, None, None]
+ d[:, None, None] * (length * tv)[None]
+ a1[:, None, None] * (rad * np.cos(th))[None]
+ a2[:, None, None] * (rad * np.sin(th))[None]
)
cone_alpha = (0.34 if hits else 0.15) * (1.0 - float(np.clip(context, 0, 1))) ** 2
if cone_alpha > 0.02:
ax.plot_surface(
cone[0],
cone[1],
cone[2],
color="#000000",
alpha=cone_alpha,
linewidth=0,
antialiased=False,
shade=False,
zorder=6,
)
# the track the umbra has already swept, plus the footprint itself
if t_hours > T_START + 1e-3:
trail_t = np.linspace(T_START, t_hours, 60)
trail = []
for tt in trail_t:
lat_t, lon_t = track_point(tt)
gx, gy, gz = ll_to_xyz(np.array([lon_t]), np.array([lat_t]), spin)
trail.append([gx[0], gy[0], gz[0]])
trail = np.asarray(trail).T
keep = (trail * cam[:, None]).sum(0) > 0.02
ax.plot(
*[np.where(keep, c, np.nan) * 1.015 for c in trail],
color="#ff5a3c",
lw=1.6,
alpha=0.75,
zorder=9,
)
if hits:
n_hat = hp / np.linalg.norm(hp)
f1 = np.cross(n_hat, [0, 0, 1.0])
f1 /= np.linalg.norm(f1)
f2 = np.cross(n_hat, f1)
tt = np.linspace(0, 2 * np.pi, 60)
cap_r = np.deg2rad(6.0)
spot = np.cos(cap_r) * n_hat[:, None] + np.sin(cap_r) * (
np.cos(tt) * f1[:, None] + np.sin(tt) * f2[:, None]
)
ax.plot(
spot[0] * 1.012,
spot[1] * 1.012,
spot[2] * 1.012,
color="#ffd21a",
lw=1.4,
alpha=0.95,
zorder=10,
)
ax.scatter(
[hp[0] * 1.01],
[hp[1] * 1.01],
[hp[2] * 1.01],
s=90,
color="black",
edgecolors="#ffd21a",
linewidths=0.8,
zorder=10,
)
ang = np.linspace(0, 2 * np.pi, 200)
ox, oz = tilt_y(r_geo * np.cos(ang), np.zeros_like(ang))
ax.plot(ox, r_geo * np.sin(ang), oz, color="#666", lw=0.6, zorder=3)
for lon_deg, colour, label in SATS:
a = np.deg2rad(lon_deg) + spin
px, pz = tilt_y(np.cos(a), 0.0)
sub = np.array([px, np.sin(a), pz])
sat = sub * r_geo
ax.scatter(
[sat[0]],
[sat[1]],
[sat[2]],
marker=SATELLITE,
color=colour,
s=150 + 260 * float(np.clip(context, 0, 1)),
depthshade=False,
zorder=9,
)
al = 1.0
lat = np.linspace(-85, 85, 90)
gx, gy, gz = ll_to_xyz(np.full_like(lat, lon_deg), lat, spin)
pts = np.stack([gx, gy, gz])
keep = (pts * cam[:, None]).sum(0) > 0.02
wide = 1.0 - float(np.clip(zoom, 0, 1)) # Earth is small at the start, so
ax.plot(
*[np.where(keep, c, np.nan) * 1.01 for c in pts], # draw the meridian
color=colour,
lw=1.8 + 1.2 * wide,
alpha=0.95,
zorder=6,
) # boldly there
ax.plot(
*[[sub[i] * 1.01, sat[i]] for i in range(3)],
color=colour,
lw=0.8 + 1.0 * wide,
ls=(0, (3, 3)),
alpha=0.95,
zorder=7,
)
ax.scatter(
[sub[0] * 1.03],
[sub[1] * 1.03],
[sub[2] * 1.03],
s=24,
color=colour,
edgecolors="white",
linewidths=0.4,
alpha=al,
zorder=8,
)
e1 = np.cross(sub, [0, 0, 1.0])
e1 /= np.linalg.norm(e1)
e2 = np.cross(sub, e1)
tcirc = np.linspace(0, 2 * np.pi, 160)
ring = np.cos(np.deg2rad(CAP)) * sub[:, None] + np.sin(np.deg2rad(CAP)) * (
np.cos(tcirc) * e1[:, None] + np.sin(tcirc) * e2[:, None]
)
keep = (ring * cam[:, None]).sum(0) > 0.02
ax.plot(
*[np.where(keep, c, np.nan) * 1.01 for c in ring],
color=colour,
lw=0.9 + 1.1 * wide,
ls=(0, (2, 2)),
alpha=0.75,
zorder=6,
)
if float(sub @ cam) > -0.15:
ax.text(
sat[0],
sat[1],
sat[2] + 0.30,
label,
color=colour,
fontsize=11,
ha="center",
va="bottom",
linespacing=1.3,
alpha=al,
zorder=10,
)
x0, x1 = xlim if xlim is not None else (-lim, lim)
zlim = lim * z_crop
# matching box_aspect to the ranges keeps equal units per axis, so spheres stay round
ax.set_box_aspect((x1 - x0, 2 * lim, 2 * zlim))
ax.set_xlim(x0, x1)
ax.set_ylim(-lim, lim)
ax.set_zlim(-zlim, zlim)
ax.view_init(elev=elev, azim=azim)
def frame_params(u):
"""Scene parameters for clip position `u` (0..1).
Time runs over the real eclipse: the camera holds wide while the Sun dissolves out of
frame, flies in as the shadow crosses the Arctic, settles at greatest eclipse, then
holds while the umbra sweeps on to Spain.
"""
hold = APPROACH_FRAMES / N_CLOSE
approach = min(u / hold, 1.0)
e = 1.0 - (1.0 - approach) ** 3 # ease-out cubic on the camera only
fade = float(np.clip(e / SUN_EXIT, 0, 1)) # Sun dissolves before the box contracts,
z = float(np.clip((e - SUN_EXIT) / (1.0 - SUN_EXIT), 0, 1)) # so it is never sliced
lerp = lambda a, b: a + (b - a) * z
t = T_START + (T_END - T_START) * u # UT advances steadily the whole clip
spin = spin_for_time(t)
lat, lon = track_point(t)
gx, gy, gz = ll_to_xyz(np.array([lon]), np.array([lat]), spin)
return {
"t_hours": t,
"spin": spin,
"moon_pos": moon_for_target(np.array([gx[0], gy[0], gz[0]])),
"xlim": (lerp(XLIM_0[0], -LIM_1), lerp(XLIM_0[1], LIM_1)),
"r_geo": lerp(RGEO_0, RGEO_1),
"lim": lerp(LIM_0, LIM_1),
"z_crop": lerp(ZCROP_0, ZCROP_1),
"moon_scale": lerp(MOON_0, MOON_1),
"view": (lerp(ELEV_0, ELEV_1), lerp(AZIM_0, AZIM_1)),
"context": float(np.clip((z - 0.35) / 0.35, 0, 1)),
"sun_alpha": 1.0 - fade,
"zoom": z,
}
def eclipse_params():
"""Greatest eclipse (17:45:53 UT) -- the instant the still panel shows."""
return frame_params((T_GREATEST - T_START) / (T_END - T_START))
# --- one continuous clip: animation 1, run on until it meets animation 2 --------------
# The combined clip does not re-stage the wide view -- it plays animation 1 itself, then
# simply keeps its motion going for another `N_EXTRA` frames, which is exactly how long
# Earth needs to turn from where animation 1 stops to the orientation animation 2 opens
# on. Over the last `N_BLEND` of those the Moon eases onto the hand-over point as well,
# so at the cut the Sun, the Moon, Earth's rotation and both satellites already agree and
# animation 2 can start on its own first frame untouched.
# How many extra frames of animation 1's own motion are needed to turn Earth from where
# the clip stops to the orientation the close-up opens on. Derived, not hard-coded, so it
# stays right if N_WIDE changes.
N_EXTRA = int(
round(
np.mod(
spin_for_time(T_START)
- (SPIN_ECLIPSE + 2 * np.pi * ((N_WIDE - 1) - N_WIDE / 2) / N_WIDE),
2 * np.pi,
)
/ (2 * np.pi / N_WIDE)
)
)
N_BLEND = 50 # over these last ones the Moon, spin and camera settle
N_FULL = N_WIDE + N_EXTRA - 1 + N_CLOSE
HANDOVER = N_WIDE + N_EXTRA - 1
def _slerp(a, b, s):
"""Great-circle interpolation, so the Moon keeps its orbit radius throughout."""
ua, ub = a / np.linalg.norm(a), b / np.linalg.norm(b)
ang = float(np.arccos(np.clip(ua @ ub, -1.0, 1.0)))
if ang < 1e-9:
return b
r = np.linalg.norm(a) + (np.linalg.norm(b) - np.linalg.norm(a)) * s
return r * (np.sin((1 - s) * ang) * ua + np.sin(s * ang) * ub) / np.sin(ang)
def wide_frame_args(i):
"""Animation 1's own arguments at frame `i` of the combined clip."""
moon_phase = -np.pi + 2 * np.pi * i / N_WIDE
spin = SPIN_ECLIPSE + 2 * np.pi * (i - N_WIDE / 2) / N_WIDE
moon = moon_position(moon_phase)
s = 0.0
if i > HANDOVER - N_BLEND:
target = frame_params(0.0)
s = (i - (HANDOVER - N_BLEND)) / N_BLEND
s = s * s * (3.0 - 2.0 * s)
moon = _slerp(moon, target["moon_pos"], s)
spin += s * (np.mod(target["spin"] - spin + np.pi, 2 * np.pi) - np.pi)
cam_azim = AZIM_WIDE
rect = RECT_WIDE
if i > HANDOVER - N_BLEND:
cam_azim = AZIM_WIDE + s * (AZIM_0 - AZIM_WIDE)
rect = lerp_rect(RECT_WIDE, RECT_OPEN, s)
return {
"moon_phase": moon_phase,
"spin": spin,
"moon_pos": moon,
"cam_azim": cam_azim,
"rect": rect,
}
fig = plt.figure(figsize=(12, 8), facecolor="black")
ax = fig.add_axes([-0.213, -0.435, 1.582, 1.718], projection="3d")
# mplot3d orders artists by mean depth, which lets Earth's surface mesh paint over lines
# in front of it; honour the zorder the drawing assigns instead. draw_scene already masks
# the far side, so nothing hidden becomes visible.
ax.computed_zorder = False
def _closeup_frame(i):
p = frame_params(i / (N_CLOSE - 1))
draw_scene(ax, **p)
hh, mm = divmod(int(round(p["t_hours"] * 60)), 60)
fig.suptitle(
f"{hh:02d}:{mm:02d} UTC the umbra over {place_at(p['t_hours'])}",
color="white",
fontsize=12,
y=0.965,
)
return []
closeup_gif = save_clip(fig, _closeup_frame, N_CLOSE, OUT / "eclipse_closeup")
plt.close(fig)
display(Image(filename=closeup_gif))
Where each satellite actually sits¶
The settled frame, held still. At true scale geostationary orbit is 6.6 Earth radii, so with the whole orbit in view Earth would be a speck; the radius is compressed here (and labelled as such) so the globe reads large. Each satellite carries its own coloured meridian, a sub-satellite point, a dashed hover line and the dotted arc of the disk it can see, and the yellow ring marks the umbra at 65°N 25°W.
The division of labour is then plain: Meteosat-12 at 0° looks down on Europe and Africa — which is why it saw the eclipse head-on — while GOES-East at 75.2°W sits over the Americas and caught the same shadow away at its limb.
fig = plt.figure(figsize=(12, 8), facecolor="black")
ax = fig.add_axes([-0.254, -0.526, 1.853, 2.012], projection="3d")
# mplot3d orders artists by mean depth, which lets Earth's surface mesh paint over lines
# in front of it; honour the zorder the drawing assigns instead. draw_scene already masks
# the far side, so nothing hidden becomes visible.
ax.computed_zorder = False
draw_scene(ax, **eclipse_params()) # the alignment instant the clip settles on
fig.text(
0.5,
0.965,
"17:47 UTC \u2014 each satellite over its own meridian",
color="white",
fontsize=12.5,
ha="center",
)
fig.text(
0.5,
0.03,
"orbit radius compressed for clarity (really 6.6 Earth radii)",
color="#888",
fontsize=8,
ha="center",
style="italic",
)
still = OUT / "eclipse_alignment.png"
fig.savefig(still, dpi=90, facecolor="black")
plt.close(fig)
display(Image(filename=str(still)))
The whole thing, end to end¶
The two clips above, joined into one shot. The close-up's opening state is the wide view -- same camera, true orbit radius, Sun in frame -- so the establishing shot only has to arrive there and the two halves meet without a cut. The Moon swings round its orbit, Earth's rotation eases into eclipse time, the camera flies in, and the umbra runs its course from the Arctic to Spain.
fig = plt.figure(figsize=(12, 8), facecolor="black")
ax = fig.add_axes([-0.199, -0.452, 1.555, 1.720], projection="3d")
# mplot3d orders artists by their mean depth, which lets Earth's surface mesh paint over
# lines that sit in front of it -- the satellite meridians rendered at under 15% of their
# pixels. Honour the zorder the drawing already assigns instead; the far side stays hidden
# because draw_scene masks it explicitly.
ax.computed_zorder = False
WIDE_TITLE = "Sun, Moon, Earth and the two satellites that watched"
def _full_frame(i):
"""Animation 1 up to the hand-over frame, then animation 2 from its own first frame."""
if i < HANDOVER:
draw(ax, **wide_frame_args(i))
fig.suptitle(WIDE_TITLE, color="white", fontsize=12, y=0.035, va="bottom")
else:
p = frame_params((i - HANDOVER) / (N_CLOSE - 1))
draw_scene(ax, **p)
hh, mm = divmod(int(round(p["t_hours"] * 60)), 60)
fig.suptitle(
f"{hh:02d}:{mm:02d} UTC the umbra over {place_at(p['t_hours'])}",
color="white",
fontsize=12,
y=0.035,
va="bottom",
)
return []
stamp_title_block(fig)
stamp_logo(fig)
full_gif = save_clip(fig, _full_frame, N_FULL, OUT / "eclipse_full")
plt.close(fig)
display(Image(filename=full_gif))