Watching the Moon's shadow cross the North Atlantic — the 12 Aug 2026 total solar eclipse¶
On 12 August 2026 the Moon's shadow swept across the far north — the Arctic, eastern Greenland, western Iceland and the North Atlantic — with greatest eclipse at 17:47 UTC. A solar eclipse is one of the few things you can watch from space: the Moon casts a real shadow on Earth and a geostationary satellite sees it directly.
We use GOES-19 (GOES-East) full-disk imagery and render it two ways: a wide map centred on the eclipse track (the North Atlantic), so the shadow sweeps through the middle of the frame, and the full disk in its native geostationary grid — the round-Earth "from space" view, with the shadow near the northern limb. GOES-East sits at 75°W, so this reach is toward its eastern limb.
About GOES-19 / GOES-East¶
GOES stands for Geostationary Operational Environmental Satellite — NOAA's flagship weather satellites. Geostationary means the satellite orbits ~35,786 km above the equator at exactly Earth's rotation rate, so it hangs over one fixed spot on the ground and stares at the same face of the planet continuously (unlike a polar orbiter that sweeps past). GOES-19 is the current GOES-East, parked at 75.2°W longitude, directly over the equator just off the mouth of the Amazon.
Its main instrument, the Advanced Baseline Imager (ABI), scans the full Earth disk in 16 spectral
bands every 10 minutes. We stream the visible bands (product ABI-L2-MCMIPF) for each 10-minute slot
from 15:00–19:00 UTC and build a smooth true-colour timelapse — the fixed vantage point is what makes the
frames line up into an animation.
import shutil
import tempfile
import warnings
from pathlib import Path
import matplotlib.patheffects as pe
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from cleopatra.glyphs.gridded.array_glyph import FrameLabel
from IPython.display import Image, display
from loguru import logger
from matplotlib.patches import Circle, FancyBboxPatch, Wedge
from osgeo import gdal # after pyramids, which puts its vendored GDAL on the path
from pyramids.dataset import Dataset
from pyramids.dataset.collection import DatasetCollection
from pyramids.netcdf import NetCDF
from scipy.ndimage import gaussian_filter
from earthlens.core import EarthLens
warnings.filterwarnings("ignore")
logger.remove()
gdal.UseExceptions()
plt.rcParams["figure.dpi"] = 80
# A quick schematic of the geometry described above (illustrative; the orbit ring is to scale
# relative to Earth's radius, the satellite marker is not).
R, D = (
1.0,
6.61,
) # Earth radius; geostationary orbit radius = 42,164 km / 6,371 km Earth radii
half = np.degrees(
np.arccos(R / D)
) # half-angle of the visible cap (~81 deg -> nearly a full hemisphere)
fig, ax = plt.subplots(figsize=(9, 6.2))
ax.set_aspect("equal")
ax.axis("off")
ax.add_patch(
Circle((0, 0), R, facecolor="#1b3a63", edgecolor="#0d2440", lw=1.5, zorder=3)
)
ax.add_patch(Wedge((0, 0), R, -half, half, facecolor="#2e6fb0", alpha=0.9, zorder=4))
ax.add_patch(Circle((0, 0), R, facecolor="none", edgecolor="#0d2440", lw=1.5, zorder=6))
ax.plot([-R, R], [0, 0], color="#9fb8d6", lw=0.8, ls=(0, (4, 3)), zorder=5) # equator
ax.text(
0,
R + 0.12,
"Earth",
ha="center",
va="bottom",
fontsize=11,
color="#1b3a63",
weight="bold",
)
theta = np.linspace(0, 2 * np.pi, 400)
ax.plot(
D * np.cos(theta),
D * np.sin(theta),
color="#8a8a8a",
lw=1.0,
ls=(0, (6, 4)),
zorder=1,
)
ax.text(
0,
-D - 0.35,
"geostationary orbit",
ha="center",
va="top",
fontsize=9,
color="#6a6a6a",
style="italic",
)
sx = D
ax.plot([sx], [0], marker="s", ms=11, color="#d94f2a", zorder=7)
ax.text(
sx + 0.15,
0,
"GOES-19\n(GOES-East)",
ha="left",
va="center",
fontsize=10,
weight="bold",
color="#b23a1a",
)
tx, ty = R * np.cos(np.radians(half)), R * np.sin(np.radians(half))
ax.plot([sx, tx], [0, ty], color="#d94f2a", lw=1.1, alpha=0.75, zorder=2)
ax.plot([sx, tx], [0, -ty], color="#d94f2a", lw=1.1, alpha=0.75, zorder=2)
ax.fill([sx, tx, tx], [0, ty, -ty], color="#d94f2a", alpha=0.06, zorder=1)
ax.plot([R], [0], marker="o", ms=7, color="#f2c200", mec="#7a6400", zorder=8)
ax.annotate(
"sub-satellite point\n0\u00b0N, 75.2\u00b0W\n(over the Amazon)",
xy=(R, 0),
xytext=(-1.9, 2.6),
ha="left",
va="bottom",
fontsize=9,
color="#5a4a00",
arrowprops={"arrowstyle": "->", "color": "#7a6400", "lw": 1},
)
ax.annotate(
"",
xy=(R, -0.5),
xytext=(D, -0.5),
arrowprops={"arrowstyle": "<->", "color": "#444", "lw": 1.1},
)
ax.text(
(R + D) / 2,
-0.72,
"35,786 km altitude",
ha="center",
va="top",
fontsize=9,
color="#333",
)
ax.text(
sx - 0.3,
ty + 1.15,
"sees one full hemisphere\n(the full disk)",
ha="center",
va="bottom",
fontsize=9,
color="#b23a1a",
style="italic",
)
ax.set_xlim(-2.0, D + 2.6)
ax.set_ylim(-D - 1.1, D + 0.6)
ax.set_title(
"Geostationary geometry \u2014 GOES-East hangs fixed at 75.2\u00b0W and images one hemisphere",
fontsize=11.5,
color="#222",
pad=10,
)
plt.show()
How the frames are built¶
earthlens' goes backend streams raw full-disk ABI granules from the anonymous
noaa-goes19 S3 bucket (no credentials), one per 10-minute slot from 15:00 to 19:00 UTC. For
each granule we read the visible bands (ABI has no green channel, so we synthesise one with the
CIMSS mix 0.45·R + 0.10·veg + 0.45·B) and gamma-stretch to true colour with a fixed stretch
(so the shadow reads as real darkening). From the same granule we build two frames: the
wide map — pyramids (NetCDF.warped_view) warps the geostationary grid straight to a
lat/lon window over the North Atlantic — and the globe — the native geostationary disk,
downsampled. Frames are cached, so a re-run resumes.
SLOTS = pd.date_range("2026-08-12 15:00", "2026-08-12 19:00", freq="10min")
W, S, E, N = (
-56.0,
50.0,
-8.0,
73.0,
) # map window over the eclipse track (Greenland -> Iceland)
OW = 1300
OH = int(round(OW * (N - S) / (E - W)))
CELL = (E - W) / OW # square lat/lon pixel (deg) for the reprojected map window
GLOBE_STRIDE = (
5 # downsample the ~5424-px full disk; 4 overruns cleopatra's animation path
)
GLOBE_ASPECT = 1.5 # widen the globe canvas to 3:2 -- see render_globe
OUT = Path("out") / "eclipse_goes"
REGION_DIR = OUT / "frames_region"
GLOBE_DIR = OUT / "frames_globe"
for d in (REGION_DIR, GLOBE_DIR):
d.mkdir(parents=True, exist_ok=True)
def render_region(nc_path, dst):
"""True-colour the native full disk, reproject to the North-Atlantic map, save RGB."""
nc = NetCDF.read_file(nc_path)
def cmi(name):
# Warp the geostationary band straight to the lat/lon map window: pyramids reads
# the GOES geos CRS from the granule and reprojects via GDAL (bilinear). Off-disk
# pixels come back masked -> fill with 0 so space stays black.
view = nc.get_variable(name).warped_view(
4326, method="bilinear", cell_size=CELL, bbox=(W, S, E, N)
)
arr = np.squeeze(
np.asarray(
np.ma.filled(view.read_array(unpack=True, masked=True), 0.0),
dtype="float32",
)
)
arr[~np.isfinite(arr)] = 0.0
arr[arr > 1.5] = 0.0 # residual fill leak beyond physical reflectance -> black
return arr
red, veg, blue = (
cmi("CMI_C02"),
cmi("CMI_C03"),
cmi("CMI_C01"),
) # 0.64 / 0.86 / 0.47 um
green = 0.45 * red + 0.10 * veg + 0.45 * blue # CIMSS synthetic green
rgb = np.power(np.clip([red, green, blue], 0.0, 1.0), 1 / 2.2) # (3, H, W), gamma
arr = (np.clip(np.stack(rgb), 0.0, 1.0) * 255.0).astype("uint8")
Dataset.create_from_array(
arr=arr,
geo=(W, CELL, 0.0, N, 0.0, -CELL),
epsg=4326,
no_data_value=0,
).to_file(str(dst))
def render_globe(nc_path, dst):
"""True-colour the native geostationary full disk (the round-Earth view), downsampled."""
nc = NetCDF.read_file(nc_path)
def cmi(name):
# Read the band on its native geostationary grid (no reprojection) and decimate.
# In MCMIP every band shares the common 2 km grid, so the three align pixel-for-pixel.
arr = np.squeeze(
np.asarray(
np.ma.filled(
nc.get_variable(name).read_array(unpack=True, masked=True), 0.0
),
dtype="float32",
)
)[::GLOBE_STRIDE, ::GLOBE_STRIDE]
arr[~np.isfinite(arr)] = 0.0
arr[arr > 1.5] = 0.0 # off-disk fill -> black space
return arr
red, veg, blue = cmi("CMI_C02"), cmi("CMI_C03"), cmi("CMI_C01")
green = 0.45 * red + 0.10 * veg + 0.45 * blue # CIMSS synthetic green
rgb = np.power(np.clip([red, green, blue], 0.0, 1.0), 1 / 2.2)
arr = (np.clip(np.stack(rgb), 0.0, 1.0) * 255.0).astype("uint8")
# The disk shape carries the "globe"; the grid is only a canvas for the animation, so
# the canvas is widened to `GLOBE_ASPECT` with space either side rather than left square.
# A round disk cannot fill a 3:2 figure, and `full_bleed` would stretch it to try.
height, width = arr.shape[1:]
target = int(round(height * GLOBE_ASPECT))
if target > width:
pad = (target - width) // 2
arr = np.pad(arr, ((0, 0), (0, 0), (pad, target - width - pad)))
Dataset.create_from_array(
arr=arr, geo=(0.0, 1.0, 0.0, 0.0, 0.0, -1.0), epsg=4326, no_data_value=0
).to_file(str(dst))
def wide_canvas(path):
"""True when a cached globe frame already carries the widened canvas."""
if not path.exists():
return False
ds = gdal.Open(str(path))
ratio = ds.RasterXSize / ds.RasterYSize
ds = None
return abs(ratio - GLOBE_ASPECT) < 0.01
Fetch each 10-minute full-disk granule and render both frames¶
One granule per slot (~370 MB) is downloaded, the wide-map and globe frames are written, then the granule is deleted. Cache-aware: a slot whose frames already exist is skipped.
frames = [] # (timestamp, region_path, globe_path)
for slot in SLOTS:
tag = slot.strftime("%Y%m%d%H%M")
region_path = REGION_DIR / f"{tag}.tif"
globe_path = GLOBE_DIR / f"{tag}.tif"
if not (region_path.exists() and wide_canvas(globe_path)):
granule_dir = Path(tempfile.mkdtemp())
try:
job = EarthLens(
data_source="goes",
dataset="abi-l2-mcmip",
satellite="east", # GOES-19 is GOES-East in 2026
domain="F", # full disk, native 10-min scan
start=slot.strftime("%Y-%m-%d %H:%M"),
end=(slot + pd.Timedelta(minutes=8)).strftime("%Y-%m-%d %H:%M"),
fmt="%Y-%m-%d %H:%M",
lat_lim=[S, N],
lon_lim=[W, E],
path=granule_dir,
)
got = job.download(progress_bar=False)
if not got:
continue # occasional scan gap at this exact slot -> skip the frame
if not region_path.exists():
render_region(got[0], region_path)
if not globe_path.exists():
render_globe(got[0], globe_path)
finally:
shutil.rmtree(granule_dir, ignore_errors=True)
frames.append((slot, region_path, globe_path))
frames = [f for f in frames if f[1].exists() and f[2].exists()]
print(f"{len(frames)} frames: {frames[0][0]:%H:%M} -> {frames[-1][0]:%H:%M} UTC")
The wide map — the Moon's shadow sweeping the North Atlantic¶
The eclipse track region, one frame every 10 minutes, played slowly (fps 3). Around greatest eclipse the penumbra darkens the middle of the frame (Greenland → Iceland → the eastern Atlantic), then lifts.
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.png"
# the plated variant: it carries its own navy scrim, which the transparent one needs over
# busy imagery -- under the Meteosat mark the background runs mean 99, std 84, p90 254
LOGO_FRAC = 0.11 # of figure width
LOGO_MARGIN_Y = 0.0 # of figure height -- tucked low, clear of the imagery
LOGO_MARGIN = 0.025
SCRIM = {"facecolor": "#091c30", "alpha": 0.55, "edgecolor": "none"}
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
def stamp_titles(
fig,
label,
satellite,
sizes=(16, 14),
x=0.02,
y=0.965,
step=0.048,
clock_sample="Date = 00:00 UTC",
):
"""Put the satellite name above the frame label, both on a shared scrim.
The name is the fixed identity so it takes the upper line; the clock changes every frame
and sits beneath it. Top-left is the cleanest text zone in these clips, but not clean
enough for bare white text, so both lines get an outline and a scrim behind them.
Everything is added to the axes rather than the figure. The clock is an axes-level artist,
and matplotlib draws figure-level artists after every axes -- so a figure-level scrim would
paint over the clock whatever zorder it carried, dimming it to the scrim's alpha.
Args:
fig: The figure being animated.
label: The frame-label `Text` artist `animate` created.
satellite: Identity line, e.g. `Meteosat-12 - MTG-I1 / FCI`.
sizes: Font sizes of the identity line and the clock, in points.
x: Left edge, in figure coordinates.
y: Top edge of the identity line, in figure coordinates.
step: Line spacing, in figure coordinates.
clock_sample: Stand-in used to size the scrim; `animate` leaves the clock empty until
a frame is drawn, so measuring it directly would size the scrim for one line.
Returns:
Text: The identity-line artist.
"""
ax = label.axes
stroke = [pe.withStroke(linewidth=3, foreground="black")]
top = ax.transAxes.inverted().transform(fig.transFigure.transform((x, y)))
below = ax.transAxes.inverted().transform(fig.transFigure.transform((x, y - step)))
name = ax.text(
top[0],
top[1],
satellite,
transform=ax.transAxes,
ha="left",
va="top",
color="white",
fontsize=sizes[0],
zorder=5,
path_effects=stroke,
)
label.set_position(tuple(below))
label.set_ha("left")
label.set_va("top")
label.set_size(sizes[1])
label.set_path_effects(stroke)
label.set_zorder(5)
was = label.get_text()
label.set_text(clock_sample)
fig.canvas.draw() # text extents are only known once the figure has been laid out
boxes = [t.get_window_extent() for t in (name, label)]
label.set_text(was)
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
(ax0, ay0), (ax1, ay1) = ax.transAxes.inverted().transform([(x0, y0), (x1, y1)])
ax.add_patch(
FancyBboxPatch(
(ax0, ay0),
ax1 - ax0,
ay1 - ay0,
boxstyle="round,pad=0.003,rounding_size=0.010",
transform=ax.transAxes,
zorder=4,
clip_on=False,
**SCRIM,
)
)
return name
# cleopatra 0.31 counts the in-domain cells of every ArrayGlyph by materialising one Python
# tuple per cell, and for a 4-D (RGB) stack it does that over the whole stack rather than one
# frame. A 25-frame full-disk animation needs ~15 GB of tuples for a number the library never
# reads, so it dies with MemoryError. Report the count directly instead -- same value, no list.
# Upstream: https://github.com/serapeum-org/cleopatra/issues/304
import cleopatra.glyphs.gridded.array_glyph as _array_glyph
_stock_get_indices2 = _array_glyph.get_indices2
def _domain_cell_count(arr, mask=None):
"""Stand-in for `get_indices2` whose length is the domain-cell count."""
frame = arr[0] if arr.ndim >= 3 else arr
return range(int(np.count_nonzero(~np.isnan(frame))))
_array_glyph.get_indices2 = _domain_cell_count
dc = DatasetCollection.from_files(
[str(f[1]) for f in frames], date_format="%Y%m%d%H%M", date_regex=r"\d{12}"
)
labels = [t.strftime("%H:%M UTC") for t in dc.time]
# Size the figure to the map aspect so it fills the frame edge-to-edge (no letterbox).
glyph = dc.plot(
rgb_options={"rgb": [0, 1, 2], "surface_reflectance": 255},
figsize=(12, 12 * OH / OW),
full_bleed=True,
)
glyph.animate(
labels,
interval=350, # slower playback
frame_label=FrameLabel(color="white", size=20),
full_bleed=True,
)
# `full_bleed` draws no title and no colorbar, so the frame label is the only text on the axes.
(label,) = glyph.fig.axes[0].texts
stamp_titles(glyph.fig, label, "GOES-19 · GOES-East")
stamp_logo(glyph.fig)
mp4 = OUT / "eclipse_region.mp4"
glyph.save_animation(str(mp4), fps=3, dpi=150, extra_args=["-vf", "scale=1300:-2"])
gif = str(OUT / "eclipse_region.gif")
glyph.fig.set_dpi(90)
glyph.save_animation(gif, fps=3)
plt.close("all")
display(Image(filename=gif))
The globe — GOES-East's full disk¶
This is the whole Earth exactly as GOES-East sees it, in its native geostationary projection (no reprojection at all — the round shape is the raw sensor grid). Because the satellite sits at 0°N, 75.2°W, that point is the centre of the disk, which is why the view is centred on northern South America / the Amazon: North America sits up and to the left, South America below, the bulge of Africa just creeps onto the right-hand limb, and the Atlantic and eastern Pacific fill the rest.
Where's the eclipse, then? The 12 Aug 2026 track ran across the far north — Greenland, Iceland, the North Atlantic, northern Spain — which from GOES-East's vantage is up near the top-right (north-east) limb of the disk. Near the limb the view is steeply foreshortened and naturally darkened (you're looking through far more atmosphere at a glancing angle), so the Moon's shadow shows up as a subtle darkening at the edge rather than a bold central patch. A satellite parked over that region — Europe's Meteosat at 0° longitude — sees the very same eclipse head-on and far more dramatically. That contrast is exactly why the wide map above (zoomed onto the North Atlantic) shows the shadow so much more clearly than the full globe can.
dcg = DatasetCollection.from_files(
[str(f[2]) for f in frames], date_format="%Y%m%d%H%M", date_regex=r"\d{12}"
)
labels_g = [t.strftime("%H:%M UTC") for t in dcg.time]
glyph_g = dcg.plot(
rgb_options={"rgb": [0, 1, 2], "surface_reflectance": 255},
figsize=(12, 8),
full_bleed=True,
)
glyph_g.animate(
labels_g,
interval=350,
frame_label=FrameLabel(color="white", size=18),
full_bleed=True,
)
# `full_bleed` draws no title and no colorbar, so the frame label is the only text on the axes.
(label_g,) = glyph_g.fig.axes[0].texts
stamp_titles(glyph_g.fig, label_g, "GOES-19 · GOES-East")
stamp_logo(glyph_g.fig)
mp4g = OUT / "eclipse_globe.mp4"
glyph_g.save_animation(str(mp4g), fps=3, dpi=150)
gif_g = str(OUT / "eclipse_globe.gif")
glyph_g.fig.set_dpi(90)
glyph_g.save_animation(gif_g, fps=3)
plt.close("all")
display(Image(filename=gif_g))
Both MP4s are written under out/eclipse_goes/ (eclipse_region.mp4, eclipse_globe.mp4) for sharing. The dark patch is the Moon's penumbra — the same shadow that, along the central line from Greenland through Iceland to Spain, produced totality.