The Moon's shadow from Meteosat — the 12 Aug 2026 total solar eclipse (MTG-FCI)¶
On 12 August 2026 the Moon's shadow swept the far north — the Arctic, eastern Greenland, western Iceland, the North Atlantic and northern Spain — with greatest eclipse at 17:47 UTC. Unlike GOES-East (which sees this reach only at its limb), Meteosat-12 (MTG-I1) sits at 0°E, so its FCI full-disk imager looks straight at the eclipse.
We build true-colour imagery from FCI Level-1c granules on the EUMETSAT Data Store and render the shadow two ways: a curved-limb "from space" cinematic (near-side perspective) and a flat lat/lon map of the eclipse track.
About Meteosat-12 / MTG-I1¶
Meteosat is EUMETSAT's family of European geostationary weather satellites. MTG-I1 (Meteosat Third Generation – Imaging 1, operationally Meteosat-12) carries the Flexible Combined Imager (FCI), a 16-band imager that scans the full Earth disk every 10 minutes. Like GOES it is geostationary — ~35,786 km over the equator, turning with Earth so it stares at one fixed hemisphere.
The crucial difference for this eclipse is where it is parked: MTG-I1 sits at 0° longitude (the prime meridian), directly over the Gulf of Guinea. That places Europe and the North Atlantic — where the 12 Aug 2026 eclipse tracked — near the centre of its disk, so it looks almost head-on at the shadow instead of catching it at the limb the way GOES-East does. That is why this notebook's imagery shows the eclipse so much more dramatically.
import gc
import shutil
import urllib.request
import warnings
import zipfile
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 dotenv import find_dotenv, load_dotenv
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.collection import DatasetCollection
from pyresample import create_area_def
from satpy import Scene, config
from scipy.ndimage import binary_erosion, gaussian_filter
from earthlens.core import EarthLens, cache_dir
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="#7a3ea8", zorder=7)
ax.text(
sx + 0.15,
0,
"Meteosat-12\n(MTG-I1)",
ha="left",
va="center",
fontsize=10,
weight="bold",
color="#5c2e82",
)
tx, ty = R * np.cos(np.radians(half)), R * np.sin(np.radians(half))
ax.plot([sx, tx], [0, ty], color="#7a3ea8", lw=1.1, alpha=0.75, zorder=2)
ax.plot([sx, tx], [0, -ty], color="#7a3ea8", lw=1.1, alpha=0.75, zorder=2)
ax.fill([sx, tx, tx], [0, ty, -ty], color="#7a3ea8", 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, 0\u00b0E\n(Gulf of Guinea)",
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(Europe, Africa, the Atlantic)",
ha="center",
va="bottom",
fontsize=9,
color="#5c2e82",
style="italic",
)
ax.text(
0.0,
-R - 1.7,
"The eclipse (far-north Atlantic / Europe) lies near this central meridian,\n"
"so Meteosat sees it close to disk-centre and head-on \u2014 where GOES-East\n"
"saw the same eclipse out at its limb.",
ha="center",
va="top",
fontsize=8.5,
color="#444",
)
ax.set_xlim(-2.0, D + 2.6)
ax.set_ylim(-D - 1.8, D + 0.6)
ax.set_title(
"Geostationary geometry \u2014 Meteosat-12 (MTG-I1) hangs fixed at 0\u00b0 and looks head-on at Europe",
fontsize=11.5,
color="#222",
pad=10,
)
plt.show()
How the frames are built¶
earthlens reaches EUMETSAT through the eumdac Data Store client (the same client the
earthlens.eumetsat backend wraps). FCI L1C has a 10-minute repeat cycle, so we query the Data Store
for each 10-minute slot from 16:30 to 19:00 UTC and download that one full-disk cycle (~770 MB, ~40
chunk files). satpy's fci_l1c_nc reader calibrates and assembles the channels and builds a day/night
composite: true_color by day (with the Rayleigh correction pyspectral provides), grey thermal cloud
tops over NASA Black Marble city lights by night. We resample once per projection, then clean up the
frame edges. The raw granules stay in the cache dir and the resampled frames are cached too, so a re-run
costs no downloads and no resampling.
SLOTS = pd.date_range("2026-08-12 16:30", "2026-08-12 19:00", freq="10min") # 16 cycles
DATASET = "mtg-fci-l1c" # earthlens catalog key for EO:EUM:DAT:0662, 10-min full disk
DISC = {"lat_lim": [-79.0, 79.0], "lon_lim": [-79.0, 79.0]} # the FCI full disk
COMPOSITE = "true_color_night_grey" # defined below: true colour by day, grey cloud tops by night
OUT = Path("out") / "eclipse_fci"
# Raw granules are staged under the earthlens cache dir (set_cache_dir() /
# EARTHLENS_CACHE, else the per-platform user cache) and KEPT, so re-running -- or
# re-framing to another projection later -- never re-downloads the ~770 MB/cycle.
RAW_DIR = cache_dir() / "eclipse_fci" / "raw"
RAW_DIR.mkdir(parents=True, exist_ok=True)
# Resampling is the expensive step, so its output is cached in `frames_*_raw` and the
# edge treatment below writes its own copies into `frames_*`. Keeping the two apart
# means the cheap pass can be retuned and re-run without resampling anything again.
NSPER_RAW = OUT / "frames_nsper_raw"
TPERS_RAW = OUT / "frames_tpers_raw"
NSPER_DIR = OUT / "frames_nsper"
TPERS_DIR = OUT / "frames_tpers"
for d in (NSPER_RAW, TPERS_RAW, NSPER_DIR, TPERS_DIR):
d.mkdir(parents=True, exist_ok=True)
# Two "from space" perspectives on the eclipse track, both rendered from the full disk:
# 1) NSPER -- vertical near-side perspective, looking straight down on the N Atlantic.
# 2) TPERS -- *tilted* perspective: the low-angle, curved-limb view a crew in orbit would
# see, with Iberia in the foreground and the shadow riding the northern limb.
NSPER = create_area_def(
"eclipse_nsper",
{
"proj": "nsper",
"lat_0": 60.0,
"lon_0": -10.0,
"h": 35785831.0,
"a": 6378137.0,
"b": 6356752.314,
"units": "m",
},
width=1600,
height=1600,
area_extent=[-4.2e6, -4.2e6, 4.2e6, 4.2e6],
)
TPERS = create_area_def(
"eclipse_tpers",
{
"proj": "tpers",
"lat_0": 45.0,
"lon_0": 0.0,
"tilt": 28.0,
"azi": 0.0,
"h": 35785831.0,
"a": 6378137.0,
"b": 6356752.314,
"units": "m",
},
width=1680,
height=1120,
area_extent=[-5.0e6, -3.2e6, 5.0e6, 3.4e6],
)
# EUMETSAT credentials come from the repo-root .env (EUMETSAT_CONSUMER_KEY / _SECRET).
# The earthlens backend mints its own token from those same variables, so nothing else
# is needed here.
load_dotenv(find_dotenv(usecwd=True))
# --- Day/night city-lights setup -------------------------------------------------
# The night side is painted with NASA Black Marble (VIIRS city lights). satpy ships this
# composite but its bundled download URL is dead, so we fetch the image once from the NASA
# Visible Earth mirror, cache it, and point satpy's `_night_background` at the local copy.
AUX = OUT / "aux"
AUX.mkdir(parents=True, exist_ok=True)
BLACK_MARBLE = AUX / "BlackMarble_2016_3km_geo.tif"
if not BLACK_MARBLE.exists():
# A timeout matters here: a hung NASA host would otherwise stall the
# notebook forever. Write to a temp name and rename, so an interrupted
# download is never mistaken for a complete cache entry.
url = "https://eoimages.gsfc.nasa.gov/images/imagerecords/144000/144898/BlackMarble_2016_3km_geo.tif"
partial = BLACK_MARBLE.with_suffix(BLACK_MARBLE.suffix + ".part")
with urllib.request.urlopen(url, timeout=120) as response:
with open(partial, "wb") as handle:
shutil.copyfileobj(response, handle)
partial.replace(BLACK_MARBLE)
SATPY_CFG = AUX / "satpy_config"
(SATPY_CFG / "composites").mkdir(parents=True, exist_ok=True)
(SATPY_CFG / "composites" / "visir.yaml").write_text(
"sensor_name: visir\n"
"composites:\n"
" _night_background:\n"
" compositor: !!python/name:satpy.composites.aux_data.StaticImageCompositor\n"
" standard_name: night_background\n"
f' filename: "{BLACK_MARBLE.resolve().as_posix()}"\n'
" _night_background_hires:\n"
" compositor: !!python/name:satpy.composites.aux_data.StaticImageCompositor\n"
" standard_name: night_background\n"
f' filename: "{BLACK_MARBLE.resolve().as_posix()}"\n'
)
# satpy's stock night layer is a three-channel IR false colour (ir_38 / ir_105 / ir_123) in
# which cold cloud tops come out cyan. Feeding the same window channel to all three colours
# gives neutral grey cloud tops instead, and repeating it as the fourth prerequisite keeps
# the alpha channel, so Black Marble's city lights still show through the gaps.
(SATPY_CFG / "composites" / "fci.yaml").write_text(
"sensor_name: visir/fci\n"
"composites:\n"
" night_grey_alpha:\n"
" compositor: !!python/name:satpy.composites.core.GenericCompositor\n"
" standard_name: night_ir_alpha\n"
" prerequisites:\n"
" - name: ir_105\n"
" - name: ir_105\n"
" - name: ir_105\n"
" - name: ir_105\n"
" night_grey_with_background:\n"
" compositor: !!python/name:satpy.composites.fill.BackgroundCompositor\n"
" standard_name: night_ir_with_background\n"
" prerequisites:\n"
" - night_grey_alpha\n"
" - _night_background\n"
f" {COMPOSITE}:\n"
" compositor: !!python/name:satpy.composites.fill.DayNightCompositor\n"
" standard_name: fci_day_night_blend\n"
" lim_low: 78\n"
" lim_high: 88\n"
" prerequisites:\n"
" - true_color\n"
" - night_grey_with_background\n"
)
config.set(config_path=[str(SATPY_CFG)])
Cleaning up the frame edges¶
Two artefacts survive the resample, and both sit exactly where the eye goes first.
The first is a teal ridge along the day/night seam. satpy's stock night layer is an IR false colour in
which cold cloud tops come out cyan, so the obvious suspect is the night side — but swapping it for a
neutral grey layer (the composite defined above) leaves the ridge untouched. It is on the day side of
the blend: at the terminator the true-colour Rayleigh correction is working at a grazing solar angle and
over-corrects green and blue. declip_cyan below removes it by comparing the green/blue excess against its
own locally-blurred background, so a thin bright ridge is pulled down while broad real colour is left alone.
The second is the hard cut where FCI's scan coverage stops: satpy writes a binary alpha, so the imagery
ends in a razor edge against black. soften ramps the brightness down over the last few pixels and adds the
atmospheric halo that real full-disk imagery carries, because at the horizon the line of sight grazes the
atmosphere. Neither pass invents imagery — both only attenuate or tint pixels that already carry data.
def declip_cyan(rgb, tol=10.0, sigma=22.0, strength=0.9):
"""Tame the teal ridge along the day/night seam.
At the terminator the `true_color` layer's Rayleigh correction is working at a grazing
solar angle and over-corrects, so green and blue run well above red in a ridge a few
pixels wide. Neutralising the night layer does not remove it, because the ridge is on
the day side of the blend. Clamping colour globally would also wash out real vegetation
and shallow water, so the excess is compared against its own locally-blurred background
instead: a broad green region matches its background and is left alone, while a thin
ridge stands far above it and is pulled back down.
Args:
rgb: `(H, W, 3)` uint8 image.
tol: How far above the local background the excess may sit before clamping.
sigma: Blur radius, in pixels, defining "local background".
strength: How much of the anomaly to remove, 0..1.
Returns:
`(H, W, 3)` uint8 image with the seam neutralised.
"""
out = rgb.astype(np.float32).copy()
red = out[..., 0]
for chan in (1, 2):
excess = out[..., chan] - red
ridge = excess - gaussian_filter(excess, sigma=sigma)
out[..., chan] -= strength * np.clip(ridge - tol, 0.0, None)
return np.clip(out, 0, 255).astype(np.uint8)
def soften(rgb, alpha, feather=14.0, glow=0.55, glow_rgb=(0.60, 0.78, 1.0), trim=4):
"""Soften the hard edge where FCI's scan coverage stops, and add a limb glow.
satpy writes a binary alpha (0 or 255, nothing between), so the imagery ends in a razor
cut against black. Two passes fix it: the feather ramps brightness down over the last
few pixels of real data, and the glow adds the atmospheric halo that real full-disk
imagery carries, because the line of sight grazes the atmosphere at the horizon.
Neither pass invents imagery -- both only attenuate or tint pixels that already carry
data.
Args:
rgb: `(H, W, 3)` uint8 image.
alpha: `(H, W)` binary coverage mask (0 outside the scan, 255 inside).
feather: Gaussian sigma, in pixels, of the fade-out ramp.
glow: Strength of the atmospheric halo; 0 disables it.
glow_rgb: Colour of the halo.
trim: Pixels of the outermost, ragged rim to drop before feathering.
Returns:
`(H, W, 3)` uint8 image with a soft boundary.
"""
data = alpha > 0
if trim:
data = binary_erosion(data, iterations=int(trim))
data = data.astype(np.float32)
ramp = np.clip(gaussian_filter(data, sigma=feather), 0.0, 1.0)
out = rgb.astype(np.float32) * ramp[..., None]
if glow > 0:
limb = (
np.clip(
gaussian_filter(data, sigma=feather * 0.55)
- gaussian_filter(data, sigma=feather * 1.9),
0.0,
1.0,
)
* data
)
peak = float(limb.max())
if peak > 1e-6:
limb = np.clip(limb / peak, 0.0, 1.0) ** 1.3 # tighten the rim
out += (
glow * 255.0 * limb[..., None] * np.asarray(glow_rgb, dtype=np.float32)
)
return np.clip(out, 0, 255).astype(np.uint8)
def soften_file(path, **kwargs):
"""Read an RGBA GeoTIFF frame, clean its seam and boundary, write it back in place."""
ds = gdal.Open(str(path), gdal.GA_Update)
bands = [ds.GetRasterBand(i + 1).ReadAsArray() for i in range(ds.RasterCount)]
rgb = np.dstack(bands[:3])
alpha = bands[3] if len(bands) > 3 else (rgb.max(axis=2) > 0).astype(np.uint8) * 255
out = soften(declip_cyan(rgb), alpha, **kwargs)
for i in range(3):
ds.GetRasterBand(i + 1).WriteArray(out[..., i])
ds.FlushCache()
ds = None
return path
def rendered(path):
"""True when `path` holds a complete frame; an interrupted write leaves a stub."""
return path.exists() and path.stat().st_size > (1 << 20)
def render_cycle(slot):
"""Fetch one FCI cycle (kept in the cache dir) and write its nsper + tilted frames.
Resampling is cached in the `frames_*_raw` directories; the edge treatment always
re-runs, writing its own copies into `frames_nsper` / `frames_tpers`.
"""
tag = slot.strftime("%Y%m%d%H%M")
raw = ((NSPER, NSPER_RAW / f"{tag}.tif"), (TPERS, TPERS_RAW / f"{tag}.tif"))
final = (NSPER_DIR / f"{tag}.tif", TPERS_DIR / f"{tag}.tif")
if not all(rendered(path) for _, path in raw):
work = RAW_DIR / tag
if not list(work.rglob("*.nc")): # not cached yet -> fetch once
work.mkdir(parents=True, exist_ok=True)
got = EarthLens(
data_source="eumetsat",
variables={DATASET: []}, # whole product, no band subset
start=slot.strftime("%Y-%m-%d %H:%M"),
end=(slot + pd.Timedelta(minutes=9)).strftime("%Y-%m-%d %H:%M"),
fmt="%Y-%m-%d %H:%M",
path=work,
**DISC,
).download(progress_bar=False)
if not got:
return None # occasional missing cycle
for archive in got: # FCI ships as a zipped product
with zipfile.ZipFile(archive) as bundle:
bundle.extractall(work)
archive.unlink()
chunks = sorted(str(p) for p in work.rglob("*.nc"))
scene = Scene(filenames=chunks, reader="fci_l1c_nc")
scene.load([COMPOSITE])
for area, path in raw:
scene.resample(area, resampler="bilinear").save_dataset(
COMPOSITE,
filename=str(path),
writer="geotiff",
dtype=np.uint8,
enhance=True,
)
del scene
gc.collect()
for (_, src), dst in zip(raw, final):
shutil.copyfile(src, dst)
soften_file(dst)
return final
Fetch each 10-minute cycle and render both frames¶
One cycle (~770 MB) is downloaded and both frames are written. Only the zip archive is removed — the extracted chunks stay in the cache so a re-run costs no downloads. Cache-aware: a slot whose frames exist is skipped. Across the 16 cycles this leaves roughly 12 GB under the earthlens cache directory; delete <cache dir>/eclipse_fci when you are done with it.
nsper_frames, tpers_frames = [], []
for slot in SLOTS:
result = render_cycle(slot)
if result is None:
continue # occasional missing cycle -> skip the frame
nsper_frames.append(result[0])
tpers_frames.append(result[1])
print(f"{len(nsper_frames)} frames: {SLOTS[0]:%H:%M} -> {SLOTS[-1]:%H:%M} UTC")
The cinematic — the Moon's shadow from space¶
Near-side perspective centred on the North Atlantic, one frame every 10 minutes (fps 3). The penumbra darkens the middle of the disk around greatest eclipse, then lifts.
Branding the frames¶
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 11% of the frame width -- the largest size that stays clear of the Sun in the geometry animation.
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(p) for p in nsper_frames], date_format="%Y%m%d%H%M", date_regex=r"\d{12}"
)
labels = [t.strftime("%H:%M UTC") for t in dc.time]
glyph = dc.plot(
rgb_options={"rgb": [0, 1, 2], "surface_reflectance": 255},
figsize=(10, 10),
full_bleed=True,
)
glyph.animate(
labels,
interval=350,
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, "Meteosat-12 · MTG-I1 / FCI")
stamp_logo(glyph.fig)
mp4 = OUT / "eclipse_fci_nsper.mp4"
glyph.save_animation(str(mp4), fps=3, dpi=150)
gif = str(OUT / "eclipse_fci_nsper.gif")
glyph.fig.set_dpi(90)
glyph.save_animation(gif, fps=3)
plt.close("all")
del dc, glyph
gc.collect()
display(Image(filename=gif))
The tilted view — the shadow riding the limb¶
The same cycles in a tilted near-side perspective (PROJ tpers): a low-angle, curved-limb view like the
one a crew in orbit would get, with Iberia and the Sahara in the foreground and the Moon's shadow sweeping
the northern limb. Because it is rendered from the full disk, the limb and the edge of the atmosphere
are real, not a crop.
dc2 = DatasetCollection.from_files(
[str(p) for p in tpers_frames], date_format="%Y%m%d%H%M", date_regex=r"\d{12}"
)
labels2 = [t.strftime("%H:%M UTC") for t in dc2.time]
glyph2 = dc2.plot(
rgb_options={"rgb": [0, 1, 2], "surface_reflectance": 255},
figsize=(12, 8),
full_bleed=True,
)
glyph2.animate(
labels2,
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.
(label2,) = glyph2.fig.axes[0].texts
stamp_titles(glyph2.fig, label2, "Meteosat-12 · MTG-I1 / FCI")
stamp_logo(glyph2.fig)
mp4b = OUT / "eclipse_fci_tpers.mp4"
glyph2.save_animation(str(mp4b), fps=3, dpi=150)
gif2 = str(OUT / "eclipse_fci_tpers.gif")
glyph2.fig.set_dpi(90)
glyph2.save_animation(gif2, fps=3)
plt.close("all")
del dc2, glyph2
gc.collect()
display(Image(filename=gif2))
Both MP4s are written under out/eclipse_fci/ (eclipse_fci_nsper.mp4, eclipse_fci_tpers.mp4) for sharing, and the raw granules stay in the earthlens cache dir so a re-run costs no downloads.