Computing Solar Eclipses — Research

The direct topocentric method

workingupdated 2026-09-15local-circumstancestopocentricswiss-ephemerisspiceskyfieldastropy
  • The method is one equation. With δ(t)\delta(t) the topocentric angular separation of the centres and rsr_s, rmr_m the apparent semidiameters, the exterior contacts are the roots of δ=rs+rm\delta = r_s + r_m, the interior contacts the roots of δ=|rsrm|\delta = |r_s - r_m|, and maximum eclipse the minimum of δ\delta. USNO's Solar Eclipse Computer and Swiss Ephemeris both work this way 1 2.
  • USNO iterates topocentric positions with IAU radii, Sun 696000 km and Moon 1737.4 km, finds maximum first and then searches backwards and forwards for the contacts, and corrects the reported altitude for standard refraction 1.
  • Swiss Ephemeris eclipse_when_loc brackets the minimum separation with a shrinking three-point search. It then finds the exterior contacts as zeros of (rs+rm)δ(r_s + r_m) - \delta over ±2\pm 2 h and the interior contacts as zeros of |rsrm|δ|r_s - r_m| - \delta over ±2\pm 2 min. For the interior contacts the lunar radius is scaled by 0.99916, and that factor is k=0.272281k = 0.272281 in disguise 2.
  • The general-purpose libraries give the ingredients, not the product. Skyfield's separation_from() on apparent topocentric positions, astropy's get_body plus separation, PyEphem's separation() and radius, Horizons' observer tables and SPICE's gfsep_c all yield δ(t)\delta(t) and the radii. None of them ships a solar-eclipse contact routine 3 4 5 6.
  • Two exceptions ship something. sunpy's eclipse_amount() returns obscuration at an observer with a selectable lunar radius, and SPICE's gfoclt_c finds FULL, ANNULAR and PARTIAL occultation intervals for ellipsoid or DSK-shaped bodies from any observer defined as an SPK object 7 8.
  • Frame mistakes are the usual bug. astropy's separation() is evaluated in the frame of the first coordinate, so an ICRS separation is barycentric, not topocentric 9. Stellarium turns topocentric coordinates off to form Besselian elements and never uses the separation method for eclipses 10.

The question. Instead of reducing published Besselian elements, a program with an ephemeris can compute the apparent topocentric positions of the Sun and Moon directly and find the instants at which their discs touch. What exactly does that computation consist of, which radii and corrections does each implementation use, and which libraries and services offer the pieces?

The equation and its ingredients

At time tt the observer at geodetic position (ϕ,λ,h)(\phi, \lambda, h) sees the Sun's centre at apparent topocentric direction ŝ\hat{s} and the Moon's at m̂\hat{m}, with distances DsD_s and DmD_m. The apparent semidiameters are rs=arcsin(Rs/Ds)r_s = \arcsin(R_s/D_s) and rm=arcsin(Rm/Dm)r_m = \arcsin(R_m/D_m) and the separation is δ=arccos(ŝm̂)\delta = \arccos(\hat{s}\cdot\hat{m}). Then

  • first and fourth contact: δ=rs+rm\delta = r_s + r_m,
  • second and third contact: δ=|rsrm|\delta = |r_s - r_m|, total if rm>rsr_m > r_s, annular if rm<rsr_m < r_s,
  • maximum eclipse: δ\delta minimal,
  • magnitude (partial): (rs+rmδ)/(2rs)(r_s + r_m - \delta)/(2r_s), and ratio rm/rsr_m/r_s once the discs nest,
  • obscuration: the lens area of the two discs divided by πrs2\pi r_s^2, whose closed form is in contact times, magnitude and position angles.

"Apparent topocentric" means the positions include light time for the Moon and Sun, aberration, and the diurnal parallax of the site with its height. Refraction is optional and, if applied, must be applied to both bodies before the separation is formed. The Besselian and the topocentric method agree to the extent that the ephemeris, the radii and ΔT\Delta T agree: the Besselian elements are themselves a compressed geocentric ephemeris of the two bodies 11.

USNO Solar Eclipse Computer

The USNO service describes its algorithm in three sentences: "The computation of Eclipse Local Circumstances is started by iteratively computing topocentric positions of the Sun and Moon to find the time of Maximum Eclipse", after which "another series of position computations is performed going backwards and forwards from the time of Maximum Eclipse to find the times of contacts", and "the solar and lunar angular diameters are calculated at each position using radius values adopted by the International Astronomical Union (Sun 696000 km; Moon 1737.4 km) to determine if contact conditions have occurred" 1. The output gives "the time of each contact point, the Sun's topocentric position at that time, and its Position and Vertex Angles", with duration, magnitude and obscuration, and "the altitude is corrected for refraction assuming standard atmospheric conditions" 1. The lunar radius 1737.4 km is k=1737.4/6378.137=0.27240k = 1737.4/6378.137 = 0.27240, between the IAU 0.27250760.2725076 and the umbral 0.2722810.272281 that NASA uses, so USNO durations differ from NASA's by construction. The page does not state the JPL ephemeris version or ΔT\Delta T source 1.

Swiss Ephemeris swe_sol_eclipse_how and swe_sol_eclipse_when_loc

The file swecl.c is the most complete open implementation of the topocentric method and was read in full 2.

Constants. DSUN = 1392000000.0 / AUNIT, DMOON = 3476300.0 / AUNIT, RSUN = DSUN/2, RMOON = DMOON/2, so the Sun's radius is 696000 km and the Moon's 1738.15 km (k=0.27252k = 0.27252) 2.

Positions. eclipse_when_loc sets iflag = SEFLG_EQUATORIAL | SEFLG_TOPOCTR | ifl and calls swe_set_topo(geopos[0], geopos[1], geopos[2]), so Sun and Moon are apparent topocentric equatorial vectors including the site height 2.

Separation, radii and type, from eclipse_how 2:

rmoon = asin(RMOON / lm[2]) * RADTODEG;
rsun = asin(drad / ls[2]) * RADTODEG;
rsplusrm = rsun + rmoon;
rsminusrm = rsun - rmoon;
dctr = acos(swi_dot_prod_unit(x1, x2)) * RADTODEG;
if (dctr < rsminusrm)            retc = SE_ECL_ANNULAR;
else if (dctr < fabs(rsminusrm)) retc = SE_ECL_TOTAL;
else if (dctr < rsplusrm)        retc = SE_ECL_PARTIAL;

Magnitude and obscuration 2:

attr[1] = rmoon / rsun;                       /* ratio of diameters */
lsunleft = (-dctr + rsun + rmoon);
attr[0] = lsunleft / rsun / 2;                /* fraction of solar diameter covered */
...
attr[2] = (sc1 + sc2) * 2 / PI / lsun / lsun; /* obscuration, partial case */
attr[8] = attr[0]; if (retc & (SE_ECL_TOTAL | SE_ECL_ANNULAR)) attr[8] = attr[1];  /* NASA magnitude */

where sc1 = a*lmoon^2/2 - cos(a)sin(a)*lmoon^2/2 and sc2 = b*lsun^2/2 - cos(b)sin(b)*lsun^2/2 with a = acos((lctr^2 + lmoon^2 - lsun^2)/(2 lctr lmoon)) and b = acos((lctr^2 + lsun^2 - lmoon^2)/(2 lctr lsun)), clamped to [1,1][-1, 1]; total and annular return lmoon^2/lsun^2 or 1 2. The programmer's manual documents attr[0] as "fraction of solar diameter covered by moon; with total/annular eclipses, it results in magnitude acc. to IMCCE" and attr[8] as "magnitude acc. to NASA; = attr[0] for partial and attr[1] for annular and total eclipses" 12.

Maximum. A first guess comes from the Meeus lunar-phase polynomial for new Moon. It is then refined by a bracketing loop, for (dt = dtstart; dt > 0.00001; dt /= dtdiv). Each pass evaluates the separation at tdtt - dt, tt and t+dtt + dt, calls find_maximum on the parabola through the three points, and moves tt to the fitted extremum 2.

Contacts. For the exterior contacts the quantity (rs+rm)δ(r_s + r_m) - \delta is evaluated at t2t - 2 h, tt and t+2t + 2 h. find_zero(dc[0], dc[1], dc[2], twohr, &dt1, &dt2) returns the two roots of the parabola through the three points. Those roots are then re-refined with tensec = 10/(24*3600) brackets. Interior contacts use |rsrm|δ|r_s - r_m| - \delta with twomin = 2/(24*60) brackets, and before forming them the code does 2:

rmoon = asin(RMOON / dm) * RADTODEG;
rmoon *= 0.99916; /* gives better accuracy for 2nd/3rd contacts */

0.99916×1738.15km=1736.69km0.99916 \times 1738.15\ \text{km} = 1736.69\ \text{km}, and 0.272281×6378.137km=1736.65km0.272281 \times 6378.137\ \text{km} = 1736.65\ \text{km}, 40 m from the scaled value. The scale factor is the NASA umbral kk applied to the interior contacts only, the same convention as the Five Millennium Canon, NASA/TP-2006-214141 13 14.

Refraction and visibility. swe_azalt returns true and apparent altitude, stored as attr[5] and attr[6]. The visibility flag uses an approximate minimum apparent height, hmin_appr = -(34.4556 + (1.75 + 0.37) * sqrt(geohgt)) / 60. The first term is horizon refraction from Bennett's formula. The other two are the dip of the horizon and the refraction between horizon and observer, both scaling with the square root of the height 2. Refraction is never applied to the separation itself, so contact times are unrefracted. tret[5] and tret[6] carry sunrise and sunset between first and fourth contact when they occur 12.

Output. tret[0] maximum, tret[1] to tret[4] the four contacts, attr[3] the core-shadow diameter from eclipse_where, attr[4] azimuth, attr[7] the separation 12 2.

sunpy eclipse_amount

sunpy provides sunpy.coordinates.sun.eclipse_amount(observer, *, moon_radius='IAU'), which returns the obscuration at an observer SkyCoord for its time, "using the simplifying assumption that the Moon has a constant radius". The 'IAU' option is Rmoon/Rearth=0.2725076R_\mathrm{moon}/R_\mathrm{earth} = 0.2725076 and 'minimum' is 0.2722810.272281, the latter "more accurate for total eclipse contact predictions but less accurate for partial eclipse measurements" in the documentation's paraphrase. Light travel time is included. The docstring recommends a JPL ephemeris because astropy's built-in lunar position "is appreciably inaccurate" 7. Contact times are not returned. A root finder on this function against 0, and on 1amount1 - \mathrm{amount}, reproduces them.

Skyfield

Skyfield has no eclipse function. Issue #807 proposes the detector 15:

bluffton = eph['earth'] + wgs84.latlon(40.197303, -89.626094 * E, elevation_m=0)
sun = bluffton.at(time).observe(eph['sun']).apparent()
moon = bluffton.at(time).observe(eph['moon']).apparent()
elongation_degrees = sun.separation_from(moon).degrees

The .apparent() call applies light time, aberration and deflection, and wgs84.latlon with elevation_m gives the topocentric origin. Apparent radii come from the documented pattern Angle(radians=np.arcsin(radius_km / distance.km) * 2.0) for the angular diameter 3. The documentation's find_discrete and find_minima almanac searchers can then locate the roots of δ(rs±rm)\delta - (r_s \pm r_m) and the minimum of δ\delta. Skyfield's examples page has lunar-eclipse support but no solar-eclipse example and no contact-time function 3.

astropy

astropy gives get_body("moon", time, loc) and get_body("sun", time, loc) for an EarthLocation, and moon.separation(sun). Erik Bernhardsson's 2024 note uses exactly that pair and minimises the separation with scipy's Nelder-Mead to find where on Earth the eclipse is central, without computing contacts or obscuration 16. The astropy documentation's "Common mistakes" page warns that separation() is computed in the frame of the coordinate it is called on, so star.separation(moon) in ICRS is a barycentric separation while moon.separation(star) from a geocentric or topocentric frame is the observed one 9. No open astropy issue proposing a solar-eclipse contact routine was found in the searches run for this note. The feature lives in sunpy instead 7.

PyEphem

PyEphem bodies expose radius ("size (radius as an angle)") and size in arcseconds, separation() gives the angle between two positions, and Observer carries elevation, pressure and temperature so that apparent positions "include an adjustment to simulate atmospheric refraction". There is no eclipse function 4. Setting pressure = 0 removes refraction, as a contact computation requires.

JPL Horizons

Horizons has no eclipse or occultation product. Its observer tables, from a topocentric site with an optional refraction model, list apparent RA and Dec, the angular diameter, elongation and range rates for one target at a time, from which two tables yield δ(t)\delta(t) and the radii, one for the Sun and one for the Moon at identical times 5. Horizons is therefore a validation oracle for a local ephemeris rather than a contact calculator.

SPICE gfoclt_c and gfsep_c

gfoclt_c "determines time intervals when an observer sees one target body occulted by, or in transit across, another". Occultation types are FULL ("the full occultation of the body designated by back by the body designated by front"), ANNULAR ("front blocks part of, but not the limb of, back"), PARTIAL ("front blocks part, but not all, of the limb of back") and ANY, which "must be used if either the front or back target body is modeled as a point". Shapes are ELLIPSOID from the kernel-pool radii, POINT, and DSK for topographic models. Aberration corrections are NONE, LT, CN, XLT and XCN. "The step size should be shorter than the shortest occultation duration and the shortest time interval between two occultation events", and roots are accepted when bracketed within SPICE_GF_CNVTOL. The first documented example finds the December 2001 solar eclipse as an 85 minute interval of Moon occulting Sun as seen from Earth's centre 8. To use a surface site as the observer, the site must exist as an SPK object, which NAIF's pinpoint utility produces from a body-fixed position; the documentation does not spell this out but the observer argument is a body name. With DSK shapes the Moon's actual limb is used. That is the only library route found in this topic that folds a limb model into contact times without a separate post-correction.

gfsep_c complements it: "determine time intervals when the angular separation between the position vectors of two target bodies relative to an observer satisfies a numerical relationship", with shapes SPHERE and POINT and relations =, <, >, LOCMIN, LOCMAX, ABSMIN, ABSMAX. A SPHERE takes its radius as the maximum of BODYnnn_RADII. With SPHERE the separation is measured between the limbs rather than the centres, so = 0 with SPHERE for both bodies is the exterior contact condition and ABSMIN gives maximum eclipse 6.

Stellarium

Stellarium does not use the separation method for its eclipse tables. calcSolarEclipseBessel() switches topocentric coordinates off and takes the geocentric apparent RA and Dec of Sun and Moon. From them it forms xx, yy, dd, μ\mu, L1L_1, L2L_2, tanf1\tan f_1 and tanf2\tan f_2 with SunEarth = 109.12278 (696000/6378.1366), k = 0.2725076 and s = 0.272281. The comment reads "we will use two values (same with NASA), because durations seem to agree with NASA" 10. The local circumstances then follow the Besselian route in localSolarEclipse 17. The maintainers described the feature in 2022 as new, with contact times for lunar eclipses and solar eclipse maps as later enhancements 18. The on-screen rendering, by contrast, is topocentric by default, so what the user sees in the sky view and what the AstroCalc table reports are computed by two different methods.

Sources compared

Implementation Positions Radii Contact search Refraction Ships contacts
USNO Solar Eclipse Computer 1 Topocentric, JPL (version unstated) 696000 km, 1737.4 km Iterate to maximum, then step out Altitude only Yes, with P, V
Swiss Ephemeris 2 Topocentric apparent, SEFLG_TOPOCTR 696000 km, 1738.15 km, x0.99916 inside Parabolic bracketing, 10 s then finer Visibility only Yes, plus sunrise/sunset
sunpy 7 astropy topocentric with light time 0.2725076 or 0.272281 None (user root-finds) None Obscuration only
Skyfield 15 Apparent topocentric User supplies User uses find_discrete Optional via altaz No
astropy 16 get_body at EarthLocation User supplies User None in separation No
PyEphem 4 Topocentric with refraction by default radius attribute User On unless pressure = 0 No
Horizons 5 Topocentric tables Angular diameter column User Optional model No
SPICE gfoclt 8 Any SPK observer, LT or CN Ellipsoid or DSK Built-in interval search None Intervals of FULL, ANNULAR, PARTIAL

What a developer should do

Build the topocentric method as the independent check of the Besselian reduction, not as a replacement. Use a JPL ephemeris through Skyfield or sunpy, with apparent topocentric positions that include light time and aberration and exclude refraction. Fix the radii at Rs=696000R_s = 696000 km and Rm=0.2725076ReR_m = 0.2725076\,R_e for exterior contacts and 0.272281Re0.272281\,R_e for interior contacts to match NASA. Find the contacts with a bracketed root finder on δ(rs±rm)\delta - (r_s \pm r_m) seeded from the minimum of δ\delta 1 2 7. Agreement with the Besselian result should be within 0.1 s when the same ephemeris and ΔT\Delta T feed both. Read swecl.c eclipse_when_loc for the search structure and eclipse_how for the obscuration code, and gfoclt_c's documentation for the DSK route to a limb-aware contact.

What this changes

Nothing in the pipeline's data flow. It adds a second, cheaper validation path. Any site-level number from the Besselian path can be reproduced from the raw ephemeris in a few dozen lines. That is the test to run whenever the element generator changes.

Open questions

  • Obtain the JPL ephemeris version and ΔT\Delta T source behind the USNO Solar Eclipse Computer, which the service page does not state 1.
  • Run gfoclt_c with a pinpoint-defined surface site, ELLIPSOID for the Sun and a DSK Moon from LOLA, and record how its FULL interval compares with a limb-corrected C2 to C3 from Occult or Solar Eclipse Maestro.
  • Locate the astropy or astroplan issue tracker entry, if any, that proposes solar-eclipse contact support. None surfaced in this note's searches.

References

  1. 1primary USNO Astronomical Applications Department, "Solar Eclipse Computer" (data service description) Read. States the direct topocentric method: iterate topocentric Sun and Moon positions to find maximum eclipse, then search backwards and forwards for the contacts, with IAU radii Sun 696000 km and Moon 1737.4 km, and altitude corrected for standard refraction.
  2. 2company Swiss Ephemeris, swecl.c (functions eclipse_how, eclipse_when_loc, swe_sol_eclipse_how, swe_sol_eclipse_when_loc) Read (var/downloads/swecl.c). Direct topocentric implementation: DSUN = 1392000 km, DMOON = 3476.3 km, angular separation from unit vectors, two-circle lens obscuration, rmoon scaled by 0.99916 for second and third contacts, bracketing search with find_zero.
  3. 3company Skyfield documentation, "Examples" Read. Contains the separation_from() and angular-diameter examples (arcsin(radius/distance) times 2). No solar eclipse example and no contact-time function.
  4. 4company PyEphem documentation, "Quick Reference" Read. Bodies expose radius and size, separation() gives the angle between two positions, Observer has elevation, pressure and temperature for refraction. No eclipse function.
  5. 5primary JPL Solar System Dynamics, "Horizons manual" Read. No eclipse product. Observer tables give apparent RA/Dec, angular diameter and elongation from a topocentric site with an optional refraction model, from which an eclipse can be assembled.
  6. 6primary NAIF, CSPICE gfsep_c documentation Read. Angular separation search with shapes SPHERE and POINT and relations =, <, >, LOCMIN, LOCMAX, ABSMIN, ABSMAX.
  7. 7company sunpy documentation, sunpy.coordinates.sun.eclipse_amount Read. Obscuration from an observer SkyCoord with a constant lunar radius, moon_radius='IAU' (0.2725076) or 'minimum' (0.272281), light-time included, JPL ephemeris recommended.
  8. 8primary NAIF, CSPICE gfoclt_c documentation Read. Occultation types FULL, ANNULAR, PARTIAL, ANY; shapes ELLIPSOID, POINT, DSK; aberration corrections NONE, LT, CN, XLT, XCN; step size must be shorter than the shortest event; SPICE_GF_CNVTOL convergence; example finds the December 2001 solar eclipse from the geocentre.
  9. 9company astropy documentation, "Common mistakes" (coordinates) Read via search summary only. Warns that separation() is evaluated in the frame of the first coordinate, so a barycentric ICRS separation is not the topocentric separation.
  10. 10company Stellarium, src/core/modules/SolarEclipseComputer.cpp Read (var/downloads/stellarium_SolarEclipseComputer.cpp). Computes Besselian elements on the fly from the geocentric apparent Sun and Moon with k = 0.2725076, s = 0.272281, Sun/Earth radius ratio 109.12278, and derivatives by +/-5 minute finite differences. Cites the 1961 Explanatory Supplement.
  11. 11peer-reviewed Explanatory Supplement to the Astronomical Almanac (Seidelmann ed., 1992), chapter 8, section 8.36 "Local circumstances" Official almanac chapter. Read from the archive.org OCR text (var/downloads/es1992_djvu.txt, lines 47555-47720). Restates the 1961 method in vector form, recommends inverse interpolation on u2+v2-L^2, and gives the magnitude and obscuration derivations (8.3621 to 8.3623).
  12. 12company Astrodienst, "Swiss Ephemeris Programming Interface", sections on swe_sol_eclipse_how and swe_sol_eclipse_when_loc Read. Documents attr[0] fraction of diameter, attr[1] ratio of diameters, attr[2] obscuration, attr[8] NASA-style magnitude, and tret[0..6] maximum, contacts one to four, sunrise and sunset.
  13. 13primary Fred Espenak and Jean Meeus, NASA TP 2009-214174 "Five Millennium Catalog of Solar Eclipses: -1999 to +3000", introductory text Read from the extracted PDF text (var/downloads/TP2009-214174.txt). Gives the centre-of-figure convention (+0.50 arcsec longitude, -0.25 arcsec latitude, not applied by the authors), the k history and the statement that ephemeris truncation errors are of order 1/40 s in eclipse phase times.
  14. 14company Fred Espenak, "Solar Eclipse Predictions and the Mean Lunar Radius" (EclipseWise) Read. Gives k = 0.2724880 (USNO 1968-1980, penumbral and annular), k = 0.272281 (umbral contacts of total eclipses), k = 0.2725076 (IAU 1982), and the 2017 Illinois example 2m40.3s versus 2m44.3s.
  15. 15unsourced python-skyfield issue #807 "An idea(?) for finding a solar eclipse" Read. A user proposes separation_from() between apparent topocentric Sun and Moon from wgs84.latlon as the eclipse detector. Feature request, no library function existed.
  16. 16unsourced Erik Bernhardsson, "Predicting solar eclipses with Python" (2024) Read. Uses astropy get_body for Sun and Moon at an EarthLocation and minimises moon.separation(sun) with scipy Nelder-Mead. No contact times or obscuration.
  17. 17company Stellarium, src/gui/AstroCalcDialog.cpp, function localSolarEclipse Read (var/downloads/stellarium_AstroCalcDialog.cpp, lines 3379-3445). Observer-level circumstances from the on-the-fly elements: xi, eta, zeta, u, v, the auxiliary angle, the time correction dt = L cos(psi)/n - (u u' + v v')/n^2, magnitude and altitude.
  18. 18unsourced Stellarium GitHub discussion #2370 "Eclipse Finder" Read. Maintainer worachate001 says (2022) that lunar-eclipse contact times and solar eclipse maps were future enhancements. No method detail.