weatherlinguist

A Sunset Eclipse Over Spain: Animating 12 August 2026 eclipse, City by City

An animation worth copying

Pierre Chevillard posted an animation for France's slice of tomorrow's eclipse: a map dotted with cities, each one carrying a small icon of the Sun with the Moon's black disc sliding across it, ticking forward through the evening in sync with a clock in the corner. It's a nice piece of graphic design and, underneath it, a fairly approachable computation. This post rebuilds the same idea for Spain, independently, using Skyfield for the astronomy and matplotlib for the drawing.

The timing could not be more on the nose: 12 August 2026 is tomorrow, and it's a total solar eclipse whose path clips northern Spain right around sunset, a rare and dramatic geometry that's been generating a lot of anticipation among eclipse chasers. Plus, it is my birthday, and I am currently in Spain!

What each city's icon actually encodes

As in the animation above, for a given city and a given moment, three numbers decide what the icon looks like:

A fourth number, the Sun's altitude above the horizon, answers a more practical question than the crescent shape does: is there actually a realistic chance of seeing this from where you're standing? A city can have a dramatic near-total crescent on paper and still be useless for viewing if the Sun is 2 degrees above a hazy horizon at the time. Each city below carries a live altitude readout alongside its icon, colour-coded green (comfortably up), orange (low, under 8 degrees) and red (already set).

import geopandas as gpd                                                    
import numpy as np                                                         
import pandas as pd                                                        
from skyfield.api import load, wgs84                                       

Cities and geometry

Twenty cities spread across mainland Spain, chosen for coverage rather than any special eclipse significance, plus the Sun and Moon's physical radii for the angular-size calculation:

SUN_RADIUS_KM = 696000.0
MOON_RADIUS_KM = 1737.4

CITIES = {
    "Madrid": (40.4168, -3.7038), "Barcelona": (41.3874, 2.1686),
    "Valencia": (39.4699, -0.3763), "Sevilla": (37.3891, -5.9845),
    "Zaragoza": (41.6488, -0.8891), "Malaga": (36.7213, -4.4214),
    "Murcia": (37.9922, -1.1307), "Bilbao": (43.2630, -2.9350),
    "Alicante": (38.3452, -0.4810), "Cordoba": (37.8882, -4.7794),
    "Valladolid": (41.6523, -4.7245), "Vigo": (42.2406, -8.7207),
    "A Coruna": (43.3623, -8.4115), "Gijon": (43.5322, -5.6611),
    "Granada": (37.1773, -3.5986), "Santander": (43.4623, -3.8100),
    "Badajoz": (38.8794, -6.9707), "Salamanca": (40.9701, -5.6635),
    "Almeria": (36.8340, -2.4637), "Lleida": (41.6176, 0.6200),
}

WINDOW_START = (2026, 8, 12, 16, 0)  # UTC
WINDOW_END = (2026, 8, 12, 19, 45)
STEP_MINUTES = 3

For each city, Skyfield's topocentric observer (earth + wgs84.latlon(...)) gives the apparent Sun and Moon positions at every time step in one vectorised call. Angular radius comes from the small-angle relation between physical radius and distance; the offset between the two discs is computed in the local alt/az sky (azimuth scaled by cos(altitude)) rather than equatorial coordinates, since that's the frame the icon is actually drawn in. Results are cached to CSV, this pulls a ~17 MB ephemeris file (de421.bsp) on first run and is instant afterwards:

import os

CACHE_PATH = "data/eclipse_geometry.csv"

if os.path.exists(CACHE_PATH):
    geom = pd.read_csv(CACHE_PATH, parse_dates=["time_utc"])
else:
    ts = load.timescale()
    eph = load("de421.bsp")
    earth, sun, moon = eph["earth"], eph["sun"], eph["moon"]

    t0 = ts.utc(*WINDOW_START)
    t1 = ts.utc(*WINDOW_END)
    n_steps = int(round((t1.tt - t0.tt) * 24 * 60 / STEP_MINUTES)) + 1
    times = ts.linspace(t0, t1, n_steps)

    rows = []
    for city, (lat, lon) in CITIES.items():
        observer = earth + wgs84.latlon(lat, lon)
        astro_sun = observer.at(times).observe(sun).apparent()
        astro_moon = observer.at(times).observe(moon).apparent()

        sep_deg = astro_sun.separation_from(astro_moon).degrees
        sun_r_deg = np.degrees(np.arcsin(SUN_RADIUS_KM / astro_sun.distance().km))
        moon_r_deg = np.degrees(np.arcsin(MOON_RADIUS_KM / astro_moon.distance().km))
        alt_sun, az_sun, _ = astro_sun.altaz()
        alt_moon, az_moon, _ = astro_moon.altaz()
        d_az = (az_moon.degrees - az_sun.degrees + 180) % 360 - 180
        dx_deg = d_az * np.cos(np.radians(alt_sun.degrees))
        dy_deg = alt_moon.degrees - alt_sun.degrees
        for i in range(len(times)):
            rows.append({      
                "city": city, "lat": lat, "lon": lon,
                "time_utc": times[i].utc_iso(), "sep_deg": sep_deg[i],
                "sun_r_deg": sun_r_deg[i], "moon_r_deg": moon_r_deg[i],
                "sun_alt_deg": alt_sun.degrees[i],
                "dx_deg": dx_deg[i], "dy_deg": dy_deg[i],
            })

    geom = pd.DataFrame(rows)
    geom.to_csv(CACHE_PATH, index=False)

print(f"{len(geom)} rows, {geom['city'].nunique()} cities, "
      f"{geom['time_utc'].nunique()} time steps")

A quick check of which cities land closest to totality (separation smaller than the difference between the two radii means the Moon's disc fully covers the Sun's) lines up with where the published path of totality actually crosses Spain, a reasonable sanity check for a model this simple:

chk = geom[geom["sun_alt_deg"] > 0].copy()
chk["margin"] = chk["sep_deg"] - (chk["moon_r_deg"] - chk["sun_r_deg"]).abs()
chk.sort_values("margin").groupby("city").first().sort_values("margin")[
    ["time_utc", "sep_deg", "margin"]
].head(6)

A basemap and the render

A low-resolution Spain outline is enough for an icon map like this one, no need for a full projected basemap:

SPAIN_GEOJSON = "data/spain.geojson"

if not os.path.exists(SPAIN_GEOJSON):
    import requests

    url = (
        "https://raw.githubusercontent.com/johan/world.geo.json/"
        "master/countries/ESP.geo.json"
    )
    with open(SPAIN_GEOJSON, "wb") as f:
        f.write(requests.get(url, timeout=30).content)

spain = gpd.read_file(SPAIN_GEOJSON)

Each city gets two matplotlib Circle patches, a gold Sun and a dark Moon, created once and repositioned every frame, plus a small altitude readout underneath that gets recoloured every frame. Cities whose Sun has already set (sun_alt_deg <= 0) are hidden rather than drawn, and their label switches to "set":

import matplotlib.pyplot as plt
from matplotlib import patheffects as pe
from matplotlib.animation import FuncAnimation, PillowWriter
from matplotlib.patches import Circle, FancyBboxPatch

COLOR_SEA, COLOR_LAND, COLOR_LAND_EDGE = "#cfe0ec", "#1c3f66", "#0e2038"
COLOR_SUN, COLOR_MOON, COLOR_LABEL = "#f5a623", "#12141a", "#0e2038"
COLOR_ALT_OK, COLOR_ALT_LOW, COLOR_ALT_SET = "#245a2e", "#8a4b12", "#8a1f1f"

ICON_SUN_RADIUS = 0.23  # degrees on the map, per icon


def alt_label_color(alt_deg):
    if alt_deg <= 0:
        return COLOR_ALT_SET
    if alt_deg < 8:
        return COLOR_ALT_LOW
    return COLOR_ALT_OK


cities = sorted(geom["city"].unique())
times = sorted(geom["time_utc"].unique())

minx, miny, maxx, maxy = spain.total_bounds
pad_x, pad_y = 0.9, 0.6
xlim = (minx - pad_x, maxx + pad_x)
ylim = (miny - pad_y, maxy + pad_y)

fig_h = 12.5  # large canvas + large fonts so the altitude readouts stay legible
fig_w = fig_h * (xlim[1] - xlim[0]) / (ylim[1] - ylim[0])

fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=150)
fig.patch.set_facecolor("white")
txt_effect = [pe.withStroke(linewidth=3.0, foreground="white")]
ax.set_facecolor(COLOR_SEA)
spain.plot(ax=ax, facecolor=COLOR_LAND, edgecolor=COLOR_LAND_EDGE, linewidth=1.4, zorder=1)
ax.set_xlim(*xlim); ax.set_ylim(*ylim)
ax.set_aspect("equal")
ax.set_xticks([]); ax.set_yticks([])
for spine in ax.spines.values():
    spine.set_visible(False)
fig.subplots_adjust(left=0.01, right=0.99, top=0.99, bottom=0.01)

sun_patches, moon_patches, alt_texts = {}, {}, {}
row0 = geom[geom["time_utc"] == times[0]].set_index("city")
for city in cities:
    lon, lat = row0.loc[city, "lon"], row0.loc[city, "lat"]
    sun = Circle((lon, lat), ICON_SUN_RADIUS, facecolor=COLOR_SUN, edgecolor="none", zorder=3)
    moon = Circle((lon, lat), ICON_SUN_RADIUS, facecolor=COLOR_MOON, edgecolor="none", zorder=4)
    ax.add_patch(sun); ax.add_patch(moon)
    sun_patches[city], moon_patches[city] = sun, moon
    ax.text(lon, lat - ICON_SUN_RADIUS - 0.11, city, ha="center", va="top",
            fontsize=19, color=COLOR_LABEL, zorder=5, fontweight="bold", path_effects=txt_effect)
    alt_texts[city] = ax.text(lon, lat - ICON_SUN_RADIUS - 0.36, "", ha="center", va="top",
                               fontsize=17, color=COLOR_ALT_OK, zorder=5, fontweight="bold",
                               path_effects=txt_effect)

ax.text(0.015, 0.985, "Solar eclipse of 12 August 2026 - Spain", transform=ax.transAxes,
        ha="left", va="top", fontsize=28, fontweight="bold", color=COLOR_LABEL, zorder=6)
ax.add_patch(FancyBboxPatch((0.775, 0.915), 0.21, 0.075, transform=ax.transAxes,
             boxstyle="round,pad=0.006,rounding_size=0.02", facecolor="white",
             edgecolor="none", zorder=6, alpha=0.92))
clock_text = ax.text(0.88, 0.952, "", transform=ax.transAxes, ha="center", va="center",
                      fontsize=26, fontweight="bold", color=COLOR_LABEL, zorder=7)
ax.text(0.015, 0.012,
        "Sun/Moon geometry: JPL DE421 via Skyfield  ·  local circumstances per city, not eclipse-path modeling\n"
        "green = sun > 8° up, orange = sun < 8° up, red = below horizon",
        transform=ax.transAxes, ha="left", va="bottom", fontsize=13, color="#3a5068", zorder=6)

def update(frame_idx):
    t = times[frame_idx]
    row = geom[geom["time_utc"] == t].set_index("city")
    local_dt = pd.Timestamp(t) + pd.Timedelta(hours=2)  # CEST
    clock_text.set_text(local_dt.strftime("%H:%M"))
    for city in cities:
        r = row.loc[city]
        alt = r["sun_alt_deg"]
        visible = alt > 0
        sun_patches[city].set_visible(visible)
        moon_patches[city].set_visible(visible)
        alt_texts[city].set_color(alt_label_color(alt))
        alt_texts[city].set_text(f"{alt:.0f}° up" if visible else "set")
        if not visible:
            continue
        scale = ICON_SUN_RADIUS / r["sun_r_deg"]
        lon, lat = r["lon"], r["lat"]
        sun_patches[city].set_center((lon, lat)); sun_patches[city].set_radius(ICON_SUN_RADIUS)
        moon_patches[city].set_center((lon + r["dx_deg"] * scale, lat + r["dy_deg"] * scale))
        moon_patches[city].set_radius(r["moon_r_deg"] * scale)
    return []


anim = FuncAnimation(fig, update, frames=len(times), interval=140, blit=False)
anim.save("images/spain_eclipse_20260812.gif", writer=PillowWriter(fps=8))
plt.close(fig)

sun

By 20:30 CEST several cities, Sevilla, Malaga, Almeria, Bilbao among them, are down to a thin crescent, while the cluster nearer the path of totality, Zaragoza, Lleida, Valladolid, Barcelona, has the Moon's disc fully covering the Sun's in this simplified geometry. The northwest coast, A Coruna and Vigo, drops out of the animation earliest: the Sun sets there while the eclipse is still in progress, and Madrid itself is down to about 8 degrees of altitude by the time the crescent is at its most dramatic.

What this is and isn't

This is a geometry sketch, not an eclipse calculator. A few specific simplifications worth naming:

For the actual times, path, and magnitude at a specific location, the usual authoritative sources, timeanddate.com or a national institute like the IAA-CSIC, are worth checking instead. This was built to answer a narrower question: whether a nice piece of eclipse graphic design travels well to a different country and a Python-only toolchain, and it does, in well under 350 lines, altitude readout and all.