Crossings and retrogrades
A sign ingress, a lunation, a solar return and an exact transit look like four different problems. They are the same one: when does a body reach a given longitude. Crossings answers that question once, and everything else in the engine that used to solve it on its own now asks it instead.
It existed above all because it had been written three times before it existed at all, and none of those three could be called from outside the class that owned it: the Moon's phase solved it with regula falsi on the elongation, a solar return with a six-hour sweep and sixty bisections, and a transit with thirty bisections on the longitude. All three answered correctly and none of them was any use for "when does Saturn enter Pisces", which is the same question with a different body and a different target.
use Astronomy\Body; use Astronomy\Crossings; use Astronomy\Time; [$fromTT] = Time::fromClock(new DateTimeImmutable('2023-01-01', new DateTimeZone('UTC'))); [$toTT] = Time::fromClock(new DateTimeImmutable('2025-01-01', new DateTimeZone('UTC'))); foreach (Crossings::ingresses(Body::Pluto, $fromTT, $toTT) as $ingress) { echo Time::toClock($ingress['jd'])->format('Y-m-d H:i'), ' ', $ingress['sign']->name(), $ingress['retrograde'] ? ' retrograde' : '', PHP_EOL; }
2023-03-23 12:21 Aquarius 2023-06-11 09:36 Capricorn retrograde 2024-01-21 00:55 Aquarius 2024-09-01 23:59 Capricorn retrograde 2024-11-19 20:38 Aquarius
Pluto enters Aquarius three times in this window, not once, because it goes in, retrogrades back out, and returns months later. That is why a sign ingress you read about in passing often has three published dates rather than one, and why ingresses() returns each crossing with a retrograde flag rather than trying to collapse them into a single "entry".
ofLongitude(), ofTheSun(), ofTheMoon() and heliocentric() are the same search aimed at one target instead of at twelve sign boundaries in a row:
use Astronomy\Body; use Astronomy\Crossings; use Astronomy\Time; [$jdTT] = Time::fromClock(new DateTimeImmutable('2026-01-01', new DateTimeZone('UTC'))); // A sign ingress, a lunation and an exact transit are the same question with a // different body and a different target longitude. $aries = Crossings::ofTheSun(0.0, $jdTT); echo "Sun enters Aries: ", Time::toClock($aries)->format('Y-m-d H:i'), "\n"; $fullMoon = Crossings::ofTheMoon(180.0, $jdTT); echo "next full moon (elongation 180): ", Time::toClock($fullMoon)->format('Y-m-d H:i'), "\n"; $target = Crossings::ofLongitude(Body::Saturn, 12.0, $jdTT); echo "Saturn next reaches 12 degrees: ", Time::toClock($target)->format('Y-m-d H:i'), "\n";
Sun enters Aries: 2026-03-20 14:45 next full moon (elongation 180): 2026-01-09 00:05 Saturn next reaches 12 degrees: 2026-05-29 03:48
ofLongitude() and ingresses() both take an optional Ayanamsa|CustomAyanamsa (see The sidereal zodiac), evaluated at the instant of the crossing and not at the instant the search starts from. Evaluating it at the start would leave an ingress a year out drifting by the fifty arcseconds an ayanamsa moves in that year.
The step cannot do more than double at a time
The sweep that looks for a crossing advances by a step measured from how much the body has just moved, so that it always covers the same arc whether the body is racing or dragging its feet. The obvious rule, "if it has covered half of what it should have in this step, double the step for the next one", breaks exactly where it hurts most: at a station a planet's speed goes to zero, so the distance it covers tends to zero too, and that rule would order a step of hundreds of days. A step that long jumps clean over a whole retrograde period and both of the crossings that only exist inside it disappear, with no error at all.
That is caught with a real body and a real year, Mars in 2018:
use Astronomy\Body; use Astronomy\Crossings; use Astronomy\Time; [$fromTT] = Time::fromClock(new DateTimeImmutable('2018-01-01', new DateTimeZone('UTC'))); [$toTT] = Time::fromClock(new DateTimeImmutable('2019-01-01', new DateTimeZone('UTC'))); foreach (Crossings::ingresses(Body::Mars, $fromTT, $toTT) as $ingress) { echo Time::toClock($ingress['jd'])->format('Y-m-d'), ' ', $ingress['sign']->name(), $ingress['retrograde'] ? ' retrograde' : '', PHP_EOL; }
2018-01-26 Sagittarius 2018-03-17 Capricorn 2018-05-16 Aquarius 2018-08-13 Capricorn retrograde 2018-09-11 Aquarius 2018-11-15 Pisces
Mars crosses into Aquarius in May, backs out into Capricorn in August, and crosses into Aquarius again in September. The step is capped at doubling per step precisely so that a search which starts far from any boundary, and only slows down once the speed itself drops near a station, cannot leap over that whole episode.
A graze can escape a margin measured in degrees
Even with the doubling cap, a margin measured in how many degrees the body is allowed to travel per step is not quite enough, because what bounds a pair of crossings is not how far the body retrogrades but how far it strays from the boundary, and that can be arbitrarily small. A body can nose across a sign boundary, poke a couple of arcminutes past it, and turn back, and those are two real crossings with almost no distance covered between them.
Pluto does exactly that twice in its slow multi-decade dance across the Aries and Leo boundaries:
use Astronomy\Body; use Astronomy\Crossings; use Astronomy\Time; [$from] = Time::fromClock(new DateTimeImmutable('2065-01-01', new DateTimeZone('UTC'))); [$to] = Time::fromClock(new DateTimeImmutable('2068-01-01', new DateTimeZone('UTC'))); foreach (Crossings::ingresses(Body::Pluto, $from, $to) as $ingress) { echo Time::toClock($ingress['jd'])->format('Y-m-d'), ' ', $ingress['sign']->name(), $ingress['retrograde'] ? ' retrograde' : '', PHP_EOL; }
2066-06-17 Aries 2066-07-11 Pisces retrograde 2067-04-08 Aries 2067-09-27 Pisces retrograde
The first pair, 17 June to 11 July 2066, is the graze: Pluto crosses into Aries, gets no more than about a minute and a half of arc past the boundary, and slips back into Pisces twenty-four days later. A window on Leo in 2185 does the same thing over twenty-one days. Neither graze goes far enough in degrees for a margin-only rule to notice it is happening, which is why the sweep additionally watches position rather than speed: whenever a body sits within one margin of a boundary, it is sampled at least every five days regardless of how fast or slow it is moving, which is enough to catch any excursion lasting more than about ten days without paying that cost everywhere else.
root(): the shared solver
Every search in this class ends the same way, narrowing a bracket that is known to contain a zero with Crossings::root(), regula falsi with the Illinois correction rather than plain bisection. The functions being solved here are almost straight over the interval a body covers in one sweep step, so the secant method nails the zero in four or five evaluations where bisection needs thirty to sixty, and each evaluation is a full ephemeris call. In the milestones of a life, where this runs hundreds of times over one chart, the difference between five and thirty evaluations shows on the page.
The bracket is only ever narrowed, never collapsed by assumption: with regula falsi one endpoint can sit far from the root while the other converges onto it, so the midpoint of the bracket is not the zero, it is roughly halfway between the root and an endpoint that has not moved. If the loop runs out of iterations without the bracket actually shrinking, root() returns the best evaluation it saw rather than that midpoint, which is the only value known to be good.
The Moon's node is a latitude crossing, not a longitude one
Crossings::throughLunarNode() looks like the others but is not: what has to reach zero is the Moon's ecliptic latitude, because a node is where the orbit crosses the ecliptic plane, not where the Moon reaches some particular longitude.
use Astronomy\Crossings; use Astronomy\LunarPoints; use Astronomy\Time; [$jdTT] = Time::fromClock(new DateTimeImmutable('2026-09-19', new DateTimeZone('UTC'))); $passage = Crossings::throughLunarNode($jdTT); echo Time::toClock($passage['jd'])->format('Y-m-d H:i'), ' ', $passage['north'] ? 'ascending' : 'descending', ' at ', round($passage['longitude'], 3), ' degrees', PHP_EOL; // The true node of `LunarPoints`, checked at the very instant the Moon crossed the plane. printf("true node at that instant: %.3f degrees\n", LunarPoints::trueNode($passage['jd']));
2026-09-24 02:39 ascending at 329.611 degrees true node at that instant: 329.608 degrees
The longitude the crossing returns is where the Moon itself was at that instant, and it lands within a few thousandths of a degree of the true node computed independently from the angular momentum, LunarPoints::trueNode(), which is exactly the check a definition of a node has to pass.
Retrogrades::stations(): where the speed changes sign
A retrograde period is bounded by two stations, the instants where a planet's speed in longitude crosses zero. The speed already comes signed out of every position, so a station is not a new computation, it is a zero of a value the engine already had, found with the same Crossings::root().
use Astronomy\Body; use Astronomy\Retrogrades; use Astronomy\Time; [$fromTT] = Time::fromClock(new DateTimeImmutable('2026-01-01', new DateTimeZone('UTC'))); [$toTT] = Time::fromClock(new DateTimeImmutable('2027-01-01', new DateTimeZone('UTC'))); foreach (Retrogrades::stations(Body::Mercury, $fromTT, $toTT) as $station) { echo Time::toClock($station['jd'])->format('Y-m-d H:i'), ' ', $station['retrograde'] ? 'turns retrograde' : 'turns direct ', ' ', round($station['longitude'], 2), PHP_EOL; }
2026-02-26 06:48 turns retrograde 352.57 2026-03-20 19:33 turns direct 338.49 2026-06-29 17:35 turns retrograde 116.26 2026-07-23 22:57 turns direct 106.32 2026-10-24 07:11 turns retrograde 230.98 2026-11-13 15:54 turns direct 215.03
The sampling step for stations() is a flat two days, comfortably below the three weeks a Mercury retrograde period lasts, the shortest of them all, for the same reason ingresses() caps its own step: at close to that width, both stations of one retrograde period could land inside a single sample and the whole period would vanish with no error raised anywhere.
Asking stations() for the Sun or the Moon throws rather than returning an empty list, because seen from the Earth neither one ever goes retrograde, and an empty list here would read as "it did not go retrograde this year" instead of "it cannot":
try { Retrogrades::stations(Body::Sun, $fromTT, $toTT); } catch (\InvalidArgumentException $e) { echo $e->getMessage(), "\n"; }
The Sun and the Moon do not go retrograde seen from the Earth: Sun
LunarPoints: elements of the Moon's orbit, not bodies
The lunar nodes and Lilith are not bodies with their own series or table: they are elements of the Moon's orbit around the Earth, the same way a node and an apside are elements of any planet's orbit in NodesAndApsides. That is what makes them checkable against their own definition rather than against anything external: the true node has to equal the Moon's longitude at the exact instant its latitude crosses zero, which is precisely what the previous example demonstrated.
Each of the two points comes in a mean version, advancing at a constant rate, and a true version, computed from the Moon's instantaneous position and velocity and oscillating around the mean one:
use Astronomy\LunarPoints; use Astronomy\Time; [$jdTT] = Time::fromClock(new DateTimeImmutable('2026-09-19', new DateTimeZone('UTC'))); printf("mean node %.3f degrees\n", LunarPoints::meanNode($jdTT)); printf("true node %.3f degrees\n", LunarPoints::trueNode($jdTT)); printf("mean Lilith %.3f degrees\n", LunarPoints::meanLilith($jdTT)); printf("true Lilith %.3f degrees\n", LunarPoints::trueLilith($jdTT)); printf("interpolated Lilith %.3f degrees\n", LunarPoints::interpolatedLilith($jdTT)); printf("Priapus (interpolated perigee) %.3f degrees\n", LunarPoints::interpolatedPriapus($jdTT)); printf("eccentricity of the instantaneous orbit %.4f\n", LunarPoints::eccentricity($jdTT));
mean node 328.348 degrees true node 329.507 degrees mean Lilith 270.372 degrees true Lilith 269.201 degrees interpolated Lilith 269.029 degrees Priapus (interpolated perigee) 96.764 degrees eccentricity of the instantaneous orbit 0.0643
The mean node comes straight from ELP's own mean longitudes plus the general precession, the same treatment Moon gives every mean quantity. The true node comes from the angular momentum r × v of the Moon's instantaneous orbit, exactly the computation NodesAndApsides runs for a planet, only around the Earth instead of the Sun. True Lilith comes the same way from the eccentricity vector, and because the Sun perturbs the Moon's orbit constantly, it can swing tens of degrees and even move backwards for weeks: that is not a computation error, it is what the true apogee of a heavily perturbed orbit does, and it is why many readers prefer the mean one.
The third Lilith, interpolatedLilith(), with interpolatedPriapus() at its perigee, is neither of those: it follows the longitude the Moon really has at each of its actual passages through apogee and perigee, joined continuously between one passage and the next by shifting the Moon along its own orbit until the time derivative of its distance vanishes. It oscillates only about five degrees around the mean apogee, and twenty-five at the perigee, far less than the thirty degrees the fully osculating version reaches, because it is anchored to real passages rather than to an instantaneous, heavily perturbed state.
eccentricity() is exposed for the same reason NodesAndApsides exposes a body's own eccentricity: an apogee on a near-circular orbit is as poorly defined as an apside on one, and the number says how much to trust it.
See The sidereal zodiac for what an ayanamsa does to a crossing search, and Phenomena for the Moon's phase, which is built on the same kind of longitude crossing as a full moon here.