Eclipse Visibility Index — the model

Total solar eclipse, 12 August 2026 · Catalonia / Aragón / Valencia

The problem

On 12 August 2026 the Moon’s umbra crosses northern Spain. From the latitude of Barcelona totality happens with the Sun 3–7° above the horizon, at azimuth ≈285° (WNW), roughly twenty minutes before sunset.

That is the whole problem in one sentence. At 5° altitude a ridge only has to be 90 m high at 1 km to erase the event. Every other eclipse map you have seen is a map of the sky. This is a map of the ground, because at this altitude the ground is what decides.

The output is a single number per 50 m pixel, the Eclipse Visibility Index, plus every ingredient that went into it, so you can disagree with the weighting and re-run it in your browser without re-running anything on a server.


1 · Where the shadow is — Besselian elements

The Moon’s shadow is described in the classical Besselian frame: a plane through the centre of the Earth, perpendicular to the shadow axis, with the axis piercing it at \((x(t), y(t))\). The IAU/NASA canon publishes, for each eclipse, polynomials in \(t\) (hours from a reference instant \(t_0\)) for

\[x,\; y,\; d,\; \mu,\; l_1,\; l_2,\; \tan f_1,\; \tan f_2\]

where \(d, \mu\) are the declination and hour angle of the shadow axis, and \(l_1, l_2\) are the radii of the penumbral and umbral cones on the fundamental plane.

For an observer at \((\varphi, \lambda, h)\) we form the observer’s coordinates in that same frame, \((\xi, \eta, \zeta)\), and the umbral radius at the observer’s distance along the axis:

\[L_2' = l_2 - \zeta \tan f_2\]

The observer is inside totality when their distance from the axis is smaller than that radius:

\[m^2 = (\xi - x)^2 + (\eta - y)^2 \;\le\; L_2'^2\]

Solving \(m(t) = |L_2'(t)|\) for the two roots gives second contact C2 and third contact C3; totality lasts \(\Delta t = t_{C3} - t_{C2}\). We use \(\Delta T = 69.1\) s.

Self-check. Maximum duration on the central line inside our area is 138 s, and the central line crosses at 41.066 N, 1.529 W at 18:30:51 UT. NASA’s canon says 02m18s. The path drawn on the map and the heatmap under it come from the same elements, so they cannot disagree with each other — only with reality, and they don’t.

What we keep per pixel: the Sun’s altitude and azimuth at mid-totality, its altitude at C2 and at C3, and the duration.

These fields are astronomically smooth — the Sun’s altitude changes by about 0.007° per km — so they are computed on a stride-64 subgrid and bilinearly upsampled. That is not an approximation you can see.


2 · Where the ground is — the horizon ray-march

For every pixel we need one number: how high does the terrain rise, in the direction the Sun will be?

Take the Copernicus GLO-30 DEM (30 m). From each pixel, march outward along the Sun’s azimuth, sampling the DEM at increasing distance \(s\), and take the maximum apparent elevation angle, correcting for Earth curvature and standard refraction:

\[\theta_{\text{hor}} \;=\; \max_{150\,\text{m} \le s \le 30\,\text{km}} \arctan\!\left(\frac{z(s) - z_0 - h_{\text{eye}} - \dfrac{s^2}{2R_{\text{eff}}}}{s}\right)\]

with \(h_{\text{eye}} = 1.7\) m and \(R_{\text{eff}} = \tfrac{7}{6}R_\oplus\) (the standard refraction fudge). The \(s^2/2R_{\text{eff}}\) term is the drop of the Earth’s surface: at 30 km it is 60 m, which at 30 km subtends 0.11° — a fifth of the solar disc. It is not optional.

Three things about this that cost an iteration each:

We also keep the distance to the ridge that set the maximum. A 3° horizon 800 m away is a completely different proposition from a 3° horizon 25 km away: you can walk around the first one.


3 · Can you see it — the gate \(f_{\text{vis}}\)

The Sun’s disc is 0.53° across. For the whole disc (and the inner corona with it) to be above the skyline, the disc’s centre must clear the horizon by half of that:

\[\theta_{\text{req}} = \theta_{\text{hor}} + \tau_{\text{clear}}, \qquad \tau_{\text{clear}}^{\;\text{default}} = 0.27°\]

But the Sun is setting during totality. It is higher at C2 than at C3, by

\[\Delta_{\text{drop}} = \alpha_{C2} - \alpha_{C3} \quad (\text{up to } 0.31° \text{ here})\]

which is comparable to the threshold itself — so “visible” is not a yes/no at these altitudes, it is a fraction of the event. Approximating the altitude as falling linearly from C2 to C3, the fraction of totality during which the whole disc is clear is

\[f_{\text{vis}} \;=\; \operatorname{clamp}_{[0,1]} \left(\frac{\alpha_{C2} - \theta_{\text{req}}}{\Delta_{\text{drop}}}\right), \qquad \alpha_{C2} = \alpha_{\text{mid}} + \tfrac{1}{2}\Delta_{\text{drop}}\]

\(f_{\text{vis}} = 1\) means you see all of totality. \(f_{\text{vis}} = 0.4\) means the Sun sets behind the ridge 40 % of the way through — you get the diamond ring and then nothing. \(f_{\text{vis}} = 0\) means the ridge wins.

This is the hard gate. Pixels with \(f_{\text{vis}} \approx 0\) are painted flat dark red and excluded from the colour ramp entirely. They are not scored zero — scoring them zero put ~30 % of the map at exactly the bottom of the ramp, where they consumed a third of the available colour saying nothing. The ramp’s whole dynamic range is spent discriminating between places you could actually go.


4 · How good is it — the EVI

Passing the gate is necessary, not sufficient. Three quality factors:

\[\boxed{\;\text{EVI} \;=\; f_{\text{vis}} \;\cdot\; \underbrace{\sqrt{\frac{\Delta t}{120\,\text{s}}}}_{\text{duration}} \;\cdot\; \underbrace{e^{-\tau X(\alpha)}}_{\text{extinction}} \;\cdot\; \underbrace{\operatorname{clamp}_{[0,1]}\!\left(\frac{\alpha - \theta_{\text{hor}}}{\sigma}\right)}_{Q_{\text{margin}}}\;}\]

Duration, \(\sqrt{\Delta t / 120\,\text{s}}\) — square root, not linear: the difference between 40 s and 80 s of totality is enormous; between 95 s and 100 s it is nothing. 120 s is a reference, not a cap.

Extinction, \(e^{-\tau X}\) — you are looking through a lot of atmosphere. The airmass at altitude \(\alpha\) (Kasten–Young):

\[X(\alpha) = \frac{1}{\sin\alpha + 0.50572\,(\alpha + 6.07995°)^{-1.6364}}\]

At 3° that is \(X \approx 12\); at 7°, \(X \approx 8\). With \(\tau = 0.20\) (a clear inland summer day) the corona is dimmed by \(e^{-2.4} = 0.09\) versus \(e^{-1.6} = 0.20\) — a factor of two in brightness between the bottom and the top of our altitude range. This term is what makes the model prefer the west and the high ground, and setting \(\tau = 0\) is the way to see how much of the ranking it is driving.

Margin, \(Q_{\text{margin}}\) — the anti-knife-edge term. A site that clears the ridge by 0.05° passes the gate and is, in reality, a coin toss: a 30 m DEM does not know about the treeline, the barn, or the fact that refraction is unstable this close to the horizon. Quality ramps linearly from 0 at the ridge to 1 at \(\sigma = 1.5°\) above it. It penalises knife-edge sites without excluding them, which is the honest treatment of a model error you can bound but not fix.

The knobs, and why they are knobs

symbol UI default it means
\(\tau_{\text{clear}}\) Clearance required 0.27° half the solar disc. Raise it to buy headroom for trees, buildings and horizon haze the DEM cannot see.
\(\sigma\) Margin saturates at 1.5° how far above the ridge before a site stops being a gamble.
\(\tau\) Extinction τ 0.20 atmospheric opacity. 0.2 clear, 0.4 hazy, 0 = ignore altitude.

None of these three is a measurement. They are judgement calls, which is exactly why they are sliders and not constants baked into a raster on a server. Move them and watch the map. If your conclusion survives the whole range, it is a real conclusion.


5 · Versus the official map

The Generalitat de Catalunya / IEEC map (eclipsi2026.cat) is the one most people in Catalonia are looking at. It is binary: a place either sees the eclipse or it does not.

Overlay it (checkbox IEEC / Generalitat verdict) and the disagreements are systematic, not random: their map says “no” across broad areas we score as marginal-but-usable. Reverse- engineering the boundary, their map behaves as if it demands roughly 1.0° of clearance rather than the geometric 0.27°.

That is a defensible choice — it is buying exactly the margin that \(Q_{\text{margin}}\) models, but as a hard cut instead of a penalty. Drag Clearance required to ~1.0 and the two maps converge, which is a much stronger statement than either map alone: the difference between them is one number, and it is a matter of taste, not of physics.

The popup reports both verdicts for any pixel you click, and flags the disagreement.


6 · The pipeline

  Besselian elements            Copernicus GLO-30 DEM
  (NASA/IAU canon, ΔT=69.1s)    (30 m, stitched over the AOI)
          │                              │
          │  besselian.py                │  horizon.py
          │  contacts, altitude,         │  ray-march to 30 km at
          │  azimuth, duration           │  az 283 / 285 / 287
          │                              │  + curvature + refraction
          ▼                              ▼
     alt_mid, alt_c2, alt_c3,       horizon_deg, occluder_km
     az_mid, duration_s             (interpolated to each pixel's
     (stride-64 + bilinear)          own sun azimuth)
          │                              │
          └──────────────┬───────────────┘
                         │  score.py  ·  export_cogs.py
                         ▼
              ┌──────────────────────────┐
              │  evi.tif   5-band int16  │   ← the model's INPUTS, not its output
              │  EPSG:3857, 50 m, COG    │      ~190 MB, overviews, range-requested
              └──────────────────────────┘
                         │
                         │  cog://  +  HTTP range requests
                         ▼
              ┌──────────────────────────┐
              │  your browser            │
              │  setColorFunction()      │   ← EVI evaluated PER PIXEL, PER FRAME
              │  = the equation above    │      which is why the knobs are live
              └──────────────────────────┘

Nothing is computed on the server. The server is nginx handing out byte ranges of a static file; every colour you see was computed on your own machine, from the components, with the weights currently on the sliders.

What is actually in the file

evi.tif is raw int16 — no GDAL scale/offset tags, deliberately (see cogspec.py): the library’s setColorFunction is handed the raw tile while locationValues applies tags, so a tagged file would colour the map from 529 while the popup said 5.29°. Instead there are no tags, and one JS decoder that both paths call.

band field ×scale means
1 horizon_deg 100 terrain horizon angle at that pixel’s own sun azimuth
2 sun_alt_deg 100 Sun altitude at mid-totality
3 alt_drop_deg 1000 \(\Delta_{\text{drop}}\), the C2→C3 fall
4 duration_s 10 totality duration (0 = outside the path)
5 occluder_km 100 distance to the ridge that set the horizon

Elevation is not in there: it is the DEM, which is already shipped for the hillshade.

Two things that are not raster bands

The browser needs to know, at an arbitrary click, when totality happens there and which way the Sun will be — to aim the PeakFinder and Street View links. Neither earns a band. Both are smooth to the point of dullness across the area, so score.sun_fit() fits each as a quadratic surface in \((\lambda, \varphi)\) at export time and stats.json carries six coefficients apiece:

\[ t_{\max}(\lambda,\varphi) \;=\; c_0 + c_1\,\delta\lambda + c_2\,\delta\varphi + c_3\,\delta\lambda^2 + c_4\,\delta\lambda\,\delta\varphi + c_5\,\delta\varphi^2 \]

Residual over the whole area: 0.004 s for the time, 0.0001° for the azimuth — i.e. exact, for six numbers. This is not a decoration. Mid-totality moves 5.5 minutes from one side of the area to the other, which is 1.4° of solar azimuth — nearly three solar diameters. Point PeakFinder with an area-wide constant and it draws the Sun in the wrong place, which is the one question you opened PeakFinder to ask.

The contours are not data either

The elevation contours are computed on your GPU, from the DEM tiles already streaming for the hillshade, by a MapLibre color-relief layer: a colour ramp that is transparent everywhere except in a thin band of elevations around each multiple of the interval. No isoline extraction, no vector tiles, no extra bytes. The honest cost is the one every DEM-derived contour pays — a line defined as a band of elevations is thin where the ground is steep and fat where it is flat, and on a cliff it can thin to nothing. That is why stepped bands are offered alongside lines: terracing cannot disappear on you, and “is there a ridge between me and the Sun” is a question terracing answers.


7 · What this model does not know

Stated plainly, because a map that looks this confident should tell you where it is lying:

← back to the map