Eclipses and occultations
Eclipses finds solar and lunar eclipses between two instants and what one place sees of them. Occultations is the same geometry with a planet or a star standing in for the Sun. CentralPath walks the band a total or annular eclipse leaves across the ground. Saros gives the series number NASA prints next to every eclipse in its canon.
None of this is a formula copied from a canon. An eclipse is shadow geometry: the axis that joins the centre of the Sun and the centre of the Moon, and whether that line touches the Earth. Eclipses::shadow() builds that axis in kilometres from the two positions the engine already computes and cuts it against the ellipsoid, with the flattening handled by stretching the z coordinate so the ellipsoid becomes a sphere, which is the trick behind the Besselian elements and the one this class uses too.
The search goes by syzygy, not by scanning every day. A solar eclipse can only happen at new Moon and a lunar one only at full Moon, twelve or thirteen a year, and each candidate is discarded before any shadow geometry is built unless the Moon's latitude is under two degrees. A decade is some thirty candidates, not four thousand days.
Solar eclipses between two instants
use Astronomy\Eclipses; use Astronomy\Time; $from = Time::julianDay(new DateTimeImmutable('2026-01-01', new DateTimeZone('UTC'))); $to = Time::julianDay(new DateTimeImmutable('2027-01-01', new DateTimeZone('UTC'))); foreach (Eclipses::solar($from, $to) as $eclipse) { echo $eclipse->maximum->date->format('Y-m-d H:i:s'), ' UT ', $eclipse->type->name(), ' magnitude ', round($eclipse->magnitude, 4), ' greatest at ', round($eclipse->maximumLatitude, 2), ' / ', round($eclipse->maximumLongitude, 2), PHP_EOL; }
2026-02-17 12:12:13 UT Annular magnitude 0.9638 greatest at -64.58 / 86.65 2026-08-12 17:46:09 UT Total magnitude 1.0395 greatest at 65.11 / -25.19
Eclipses::lunar() has the same shape. The two julian days go in Universal Time, and so does everything that comes back: SolarEclipse and LunarEclipse carry their instants as UtInstant, a julian day and a clock reading travelling together, because converting one into the other is exactly the step where an hour too many or too few slips in.
gamma is the distance from the shadow axis to the centre of the Earth at maximum, in Earth radii and signed: positive north. It is the number a canon defines an eclipse by. type is one of EclipseType::Partial, Annular, Total, Hybrid or Penumbral (lunar only), and hasCentralPhase() says whether it has a second and a third contact: whether somewhere on Earth the disc ends up covered whole or ringed. A hybrid is a total eclipse whose path turns annular at one end, because the curvature of the Earth brings the ground closer by exactly what was missing.
magnitude is the one NASA publishes: for a partial eclipse, the fraction of the Sun's diameter covered at the point of greatest eclipse; for a total or annular one, the ratio of the apparent Moon and Sun diameters, which goes past one. eclipticLongitude is the Sun's longitude at maximum, which is what a reading of the eclipse by zodiac sign uses; sign() and degreesInSign() read it directly.
What one place sees
use Astronomy\Eclipses; use Astronomy\Place; use Astronomy\Time; $jdUt = Time::julianDay(new DateTimeImmutable('2026-08-12', new DateTimeZone('UTC'))); $eclipse = Eclipses::solar($jdUt, $jdUt + 1)[0]; $madrid = new Place('Madrid', null, 'Spain', 'ES', 40.4165, -3.7026, 'Europe/Madrid'); $local = Eclipses::localSolar($eclipse, $madrid); echo $local->type->name(), ', ', round($local->magnitude * 100, 1), ' per cent of the diameter', PHP_EOL; echo $local->contact1->date->format('H:i:s T'), ' to ', $local->contact4->date->format('H:i:s T'), PHP_EOL; echo 'Sun ', round($local->altitude, 1), ' degrees up at maximum', PHP_EOL; foreach (['contact1', 'contact2', 'contact3', 'contact4'] as $phase) { echo $phase, ' visible: ', ($local->visible[$phase] ?? false) ? 'yes' : 'no', PHP_EOL; }
Partial, 99.9 per cent of the diameter 19:36:46 CEST to 21:24:31 CEST Sun 7.2 degrees up at maximum contact1 visible: yes contact2 visible: no contact3 visible: no contact4 visible: no
A total eclipse a thousand kilometres to the side of its own path is partial, which is why localSolar() carries its own type rather than the global one. Everything is computed even where the body is below the horizon, and visible says phase by phase whether anybody could actually watch it: here the eclipse is still going on when the Sun sets in Madrid, so the fourth contact is a real instant with nothing seen at it. isVisible() is true if any phase is. magnitude here is capped at one and is the fraction of the DIAMETER covered; nasaMagnitude() gives the diameter ratio instead for a total or annular type, which is the number NASA actually publishes for those. obscuration is a different fraction again, of the disc's AREA rather than its diameter: at fifty per cent magnitude only forty per cent of the surface is covered, because the two discs overlap as a lens and not as a straight cut.
Occultations::local() and the local lunar eclipse circumstances share the same shape and the same visible array, because underneath they are Eclipses::localOccultation() with a different body in front.
The central path
Eclipses gives the point of maximum, which is one point. CentralPath gives the band: where the central line runs, how wide it is at every stretch and how long totality lasts for someone standing still on it. It is what NASA's eclipse catalogue publishes in its path tables.
use Astronomy\CentralPath; use Astronomy\Eclipses; use Astronomy\Time; $jdUt = Time::julianDay(new DateTimeImmutable('2026-08-12', new DateTimeZone('UTC'))); $path = CentralPath::of(Eclipses::solar($jdUt, $jdUt + 1)[0]); foreach ([$path->start(), $path->maximum, $path->end()] as $point) { echo $point->observation->date->format('H:i'), ' ', round($point->latitude, 2), ' / ', round($point->longitude, 2), ' Sun ', round($point->sunAltitude, 1), ' degrees up ', $point->hasEdges() ? round($point->widthKm).' km wide, totality '.round($point->durationSeconds, 1).' s' : 'no band: the shadow is running off the globe', PHP_EOL; } echo 'points sampled: ', count($path->points), PHP_EOL; echo 'path duration: ', round($path->pathDurationSeconds() / 60, 1), ' minutes', PHP_EOL;
17:00 75.34 / 113.56 Sun 0.3 degrees up no band: the shadow is running off the globe 17:46 65.11 / -25.19 Sun 25.8 degrees up 294 km wide, totality 138.1 s 18:32 38.77 / 5.08 Sun 0.3 degrees up no band: the shadow is running off the globe points sampled: 49 path duration: 92.1 minutes
PathPoint::widthKm and durationSeconds come back null, not zero, at the two ends of the path, and that is deliberate: there the shadow arrives grazing, the Sun is on the horizon, and the shadow stretches across the ground until it runs off the globe entirely, which is where a band looks widest, not narrowest. A zero there would read as the opposite of what is happening. hasEdges() is the check for that.
The edge of the band is not the point furthest from the shadow axis at one instant, and that was the first version. The band is what the shadow leaves swept as it moves, so its edge is the envelope of every instantaneous shadow: where the shadow grazes a point of ground and moves on, which is exactly where totality lasts zero seconds there. With the instantaneous extremes instead of the envelope, the 2026 eclipse went 425 km off NASA's own tables in the stretch where the path turns quickly.
And the width is not the straight-line distance between the north and south edges either, because the two edges are touched at different instants: when the shadow is running sideways they end up stretched lengthwise. In that same stretch of the 2026 path there are 424 km from one edge to the other measured directly and NASA publishes 318 km of band, which is the projection onto the direction of travel that CentralPath actually returns.
northLatitude and southLatitude are not the more-northerly and more-southerly edges either: they are the left and the right of the direction of travel, because the shadow always runs west to east and the left of that is what a chart calls north. The path of 12 August 2026 crosses over the North Pole and comes back down, and there its "north" edge sits several degrees further south than its "south" edge. Naming them by latitude would have got that path wrong on the one day it mattered.
Measured against NASA's published tables for three whole total-eclipse paths, comparing at NASA's own instants and keeping the worst point of each:
| Eclipse | Points | Central line | Width | Duration |
|---|---|---|---|---|
| 2017-08-21 (USA) | 96 | 3.01 km | 0.67 km | 0.19 s |
| 2024-04-08 (Mexico) | 96 | 10.20 km | 0.68 km | 0.28 s |
| 2026-08-12 (Iceland and Spain) | 45 | 9.45 km | 32.48 km | 0.19 s |
The central-line kilometres are delta T, not geometry: NASA computed each of these with the delta T it estimated at the time, and this engine uses the observed value instead. Feeding it NASA's own declared delta T for each eclipse brings the worst point of the 2017 path from 9.75 km down to 0.66, of 2024 from 18.09 to 3.49, and of 2026 from 32.48 to 7.79; 2017 improves the most because it is the one of the three NASA itself computed from a JPL integration rather than from VSOP87 and ELP2000-85, and there the bulk of the path falls under a tenth of a kilometre once the same delta T is used. Width and duration barely move with delta T at all, because they depend on the size and speed of the shadow, not on exactly where it lands.
Occultations
The Moon passing in front of a planet or a star is the same shadow geometry with the covered body swapped in, so Occultations calls straight into Eclipses::shadow() and Eclipses::localOccultation() rather than repeating them. What changes is where to look: an eclipse can only happen at new Moon, but an occultation happens at every conjunction of the Moon with the body, roughly once a month for a planet and on a date that depends on the planet's own motion.
use Astronomy\Body; use Astronomy\Occultations; use Astronomy\Place; use Astronomy\Time; $jdUt = Time::julianDay(new DateTimeImmutable('2026-09-01', new DateTimeZone('UTC'))); $occultation = Occultations::next(Body::Venus, $jdUt); $madrid = new Place('Madrid', null, 'Spain', 'ES', 40.4165, -3.7026, 'Europe/Madrid'); $local = Occultations::local($occultation, $madrid); echo $occultation->name, ' ', $occultation->maximum->date->format('Y-m-d H:i:s'), ' UT, ', $occultation->type->name(), PHP_EOL; echo 'from Madrid it comes back out at ', $local->contact3->date->format('H:i:s T'), ', ', round($local->altitude, 1), ' degrees up', PHP_EOL; echo 'disappearance above the horizon: ', $local->visible['contact2'] ? 'yes' : 'no', PHP_EOL;
Venus 2026-09-14 11:34:38 UT, Total from Madrid it comes back out at 12:21:32 CEST, 3.2 degrees up disappearance above the horizon: no
Occultations::local() returns null when the occultation is not seen from that place at all, and that is the usual answer, not an edge case: a global occultation touches a band of the Earth, most places are outside it, and the same shadow that misses one place lands on another.
use Astronomy\Body; use Astronomy\Occultations; use Astronomy\Place; use Astronomy\Time; $jdUt = Time::julianDay(new DateTimeImmutable('2026-01-01', new DateTimeZone('UTC'))); $occultation = Occultations::next(Body::Mars, $jdUt); $madrid = new Place('Madrid', null, 'Spain', 'ES', 40.4165, -3.7026, 'Europe/Madrid'); $oslo = new Place('Oslo', null, 'Norway', 'NO', 59.9139, 10.7522, 'Europe/Oslo'); $ushuaia = new Place('Ushuaia', null, 'Argentina', 'AR', -54.8, -68.3, 'America/Argentina/Ushuaia'); $singapore = new Place('Singapore', null, 'Singapore', 'SG', 1.3521, 103.8198, 'Asia/Singapore'); echo $occultation->maximum->date->format('Y-m-d H:i:s'), ' UT, ', $occultation->type->name(), PHP_EOL; foreach (['Madrid' => $madrid, 'Oslo' => $oslo, 'Ushuaia' => $ushuaia, 'Singapore' => $singapore] as $name => $place) { $seen = Occultations::local($occultation, $place) !== null; echo ' ', $name, ': ', $seen ? 'seen' : 'not seen from there', PHP_EOL; }
2026-02-16 18:17:15 UT, Total Madrid: not seen from there Oslo: not seen from there Ushuaia: seen Singapore: not seen from there
A star coming from the catalogue has no disc: local() handles that by treating its two inner contacts as its two outer ones, because a point source is covered all at once rather than through a gradual annular or total phase. A star more than seven degrees from the ecliptic is discarded before any geometry is built at all, because the Moon never gets more than 5.3 degrees away and the star could not be reached; without that discard, a decade of Vega's occultations is a hundred and thirty conjunctions built to return nothing.
Both local() methods lean on rise and set for their own rise and set fields: an eclipse or an occultation that is still going on when the body sets is exactly the case that needs that other class to say so.
The saros series number
Saros::forSolar() and forLunar() give the number a canon prints next to every eclipse: the series it belongs to, on van den Bergh's (1955) numbering.
use Astronomy\Eclipses; use Astronomy\Saros; use Astronomy\Time; $from = Time::julianDay(new DateTimeImmutable('2026-01-01', new DateTimeZone('UTC'))); $to = Time::julianDay(new DateTimeImmutable('2027-01-01', new DateTimeZone('UTC'))); foreach (Eclipses::solar($from, $to) as $eclipse) { $series = Saros::forSolar(Time::tt($eclipse->maximum->jdUt)); echo $eclipse->maximum->date->format('Y-m-d'), ' ', $eclipse->type->name(), ' saros ', $series['series'], ' member ', $series['member'] ?? 'null', PHP_EOL; }
2026-02-17 Annular saros 121 member null 2026-08-12 Total saros 126 member null
This is not geometry that can be derived from a position: it is a numbering convention with a chosen origin, so the anchor has to be taken from the published record and checked against it rather than picked by eye. The rule itself is simple, one lunation further on the series goes up by 38 modulo 223, and that single constant is what has to reproduce NASA's own table of related cycles (a semester is 6 lunations and jumps the series by 5, an inex is 358 lunations and jumps it by 1, and so on for all eight rows), which is the proof it is the right constant and not one tuned to fit a handful of cases.
member, which of the eighty-odd eclipses in a series this one is, comes back null on purpose. A series does not start on a round number: where it begins depends on geometry and on which grazing eclipse NASA counts as the first of the series, and the only way to get that right is to type in the starting lunation of all 181 series by hand, at which point a wrong one reads exactly as well as a right one. Better a null than a plausible-looking guess.