The sidereal zodiac
There are two ways to measure a longitude on the ecliptic. The tropical one, which every other chapter in this documentation uses by default, is anchored to the equinox: zero is wherever the Sun sits at the spring equinox, and it stays zero every year because the equinox itself is the reference. The sidereal one is anchored to the stars instead, and because the equinox drifts backwards through the stars at about fifty arcseconds a year, the precession, the two measures slide apart over time. They coincided around the year 285 and are close to twenty-four degrees apart today.
An ayanamsa is that gap, expressed in degrees at a given instant: subtract it from a tropical longitude and what is left is the sidereal one.
use Astronomy\Ayanamsa; use Astronomy\Body; use Astronomy\Ephemeris; use Astronomy\Time; $jdTT = Time::tt(Time::julianDay(new DateTimeImmutable('1981-05-11 07:15:00', new DateTimeZone('UTC')))); $sun = Ephemeris::position(Body::Sun, $jdTT); echo $sun->formatted(), "\n"; // tropical echo $sun->shifted(Ayanamsa::Lahiri->value($jdTT))->formatted(), "\n"; // sidereal echo Ayanamsa::Lahiri->value($jdTT), "\n"; // degrees subtracted
20° 30' 31" Taurus 26° 54' 58" Aries 23.592520152579
Ayanamsa is an enum with forty-three cases, because there is no agreement on where the fixed point is. Every school anchors its own zero, and that is where the forty-odd variants in circulation come from. Seen up close they fall into two families:
Anchored to an epoch: "at instant t0 the ayanamsa was worth a0", a definition about the equinox of t0 rather than about any star. Lahiri, the official Indian ayanamsa and the one nearly all present-day Vedic astrology uses, is one of these, fixed by the Calendar Reform Committee of India at 23°15'00.658" on 21 March 1956. Fagan-Bradley, the one of Western sidereal astrology, is another. Ayanamsa::epoch() returns t0 for these and null for anything anchored elsewhere; Ayanamsa::initialValue() returns a0.
Anchored to a star: "such and such a star sits always at such and such a sidereal longitude", which does not grow at exactly the rate of precession because the star itself has its own proper motion. True Citra fixes Spica at exactly 0° Libra, and it exists because Lahiri, the epoch-based ayanamsa nearest to it, leaves Spica about an arcminute off that mark since the star has moved since 1956 and the epoch definition does not follow it. A handful more are anchored the same way to the galactic centre or to the node of the galactic equator, which reduces to the same computation with a fixed direction standing in for a moving star.
Each of the forty-three carries its own citation in the source: who defined it, for which school, and from which publication its pair (t0, a0) or its star was read. Getting one of those numbers wrong drifts the resulting longitudes by arcseconds, and that is exactly what a wrongly copied coefficient looks like: a plausible answer that is quietly off.
value() carries nutation, mean() does not
value() returns the true ayanamsa, with the nutation in longitude folded in, because that is what has to be subtracted from a longitude of this engine, which is itself referred to the true equinox of date. mean() is the same quantity without nutation, which is the form almost every published table and every school's literature gives:
echo Ayanamsa::Lahiri->value($jdTT), "\n"; echo Ayanamsa::Lahiri->mean($jdTT), "\n";
23.592520152579 23.596705783168
sidereal() folds the subtraction and the wraparound into one call for a bare longitude, and is exactly equivalent to Position::shifted() on a full position:
printf("%.6f\n", Ayanamsa::Lahiri->sidereal($sun->longitude, $jdTT)); printf("%.6f\n", $sun->shifted(Ayanamsa::Lahiri->value($jdTT))->longitude);
26.916167 26.916167
projected(): measuring on the ecliptic of t0, not just subtracting
The ordinary sidereal longitude leaves the plane where it is and only rotates the zero point within it, which is the usual convention and what every example above does. Ayanamsa::projected() does something different: it carries the whole position, longitude and latitude both, onto the ecliptic as it stood at t0, and measures it there.
use Astronomy\Ayanamsa; use Astronomy\Body; use Astronomy\Ephemeris; use Astronomy\Time; [$jdTT] = Time::fromClock(new DateTimeImmutable('1985-06-10', new DateTimeZone('UTC'))); $pluto = Ephemeris::position(Body::Pluto, $jdTT); echo $pluto->formatted(), " tropical, latitude ", round($pluto->latitude, 3), "\n"; [$longitude, $latitude] = Ayanamsa::Lahiri->projected($pluto->longitude, $pluto->latitude, $jdTT); printf("on the ecliptic of t0: %.3f degrees, latitude %.3f\n", $longitude, $latitude); printf("difference from the ordinary sidereal longitude: %.3f degrees\n", $longitude - Ayanamsa::Lahiri->sidereal($pluto->longitude, $jdTT)); $sun = Ephemeris::position(Body::Sun, $jdTT); printf("the Sun barely moves: tropical latitude %.6f, projected latitude %.6f\n", $sun->latitude, Ayanamsa::Lahiri->projected($sun->longitude, $sun->latitude, $jdTT)[1]);
2° 12' 25" Scorpio R tropical, latitude 17.087 on the ecliptic of t0: 188.556 degrees, latitude 17.089 difference from the ordinary sidereal longitude: -0.001 degrees the Sun barely moves: tropical latitude -0.000193, projected latitude -0.003990
Because rotating onto another plane genuinely turns the body's latitude and not only its longitude, projected() returns both coordinates rather than a single offset the way sidereal() does. A body sitting almost on the ecliptic, like the Sun, barely notices the rotation; a body with real ecliptic latitude, like Pluto at seventeen degrees, does.
This is not decoration on top of the ordinary sidereal longitude, it is the piece that precession-corrected transits are built on: without it, a natal chart cast sidereal and compared against a tropical transit carries the whole ayanamsa as an error, close to a full sign.
Only epoch-anchored ayanamsas have a t0 ecliptic to project onto. Asking a star-anchored one throws rather than inventing an anchor:
use Astronomy\Ayanamsa; use Astronomy\Time; [$jdTT] = Time::fromClock(new DateTimeImmutable('2026-09-19', new DateTimeZone('UTC'))); try { Ayanamsa::TrueCitra->projected(120.0, 0.0, $jdTT); } catch (\LogicException $e) { echo $e->getMessage(), "\n"; }
True Citra is not anchored to an epoch but to the sky, so there is no t0 ecliptic to project onto.
CustomAyanamsa: any pair (t0, a0)
The forty-three cases in the enum are published pairs. CustomAyanamsa is the identical algorithm with whatever pair is handed to it, for a school with its own sidereal zero, for reproducing an older table, or for trying an anchor nobody has published. It shares its arithmetic with Ayanamsa rather than duplicating it, Ayanamsa::fromEpoch() is marked @internal and public for exactly this reason, so the two paths cannot quietly drift apart.
The check that asks nothing of anybody else: feeding it Lahiri's own pair reproduces Lahiri to the bit.
use Astronomy\Ayanamsa; use Astronomy\CustomAyanamsa; use Astronomy\Time; [$jdTT] = Time::fromClock(new DateTimeImmutable('1985-06-10', new DateTimeZone('UTC'))); $lahiri = new CustomAyanamsa(epoch: Ayanamsa::Lahiri->epoch(), initialValue: Ayanamsa::Lahiri->initialValue(), name: 'my Lahiri'); printf("Ayanamsa::Lahiri %.12f\n", Ayanamsa::Lahiri->value($jdTT)); printf("CustomAyanamsa %.12f\n", $lahiri->value($jdTT)); printf("difference: %.2e degrees\n", $lahiri->value($jdTT) - Ayanamsa::Lahiri->value($jdTT));
Ayanamsa::Lahiri 23.650245048913 CustomAyanamsa 23.650245048913 difference: 0.00e+0 degrees
And a genuinely custom pair, zero on 1 January 2000, twenty-seven years later:
[$t0] = Time::fromClock(new DateTimeImmutable('2000-01-01', new DateTimeZone('UTC'))); [$laterTT] = Time::fromClock(new DateTimeImmutable('2026-09-19', new DateTimeZone('UTC'))); $custom = new CustomAyanamsa(epoch: $t0, initialValue: 0.0, name: 'zero at 2000'); printf("at its own epoch: %.10f degrees (has to be the initial value)\n", $custom->mean($t0)); printf("27 years later: %.6f degrees\n", $custom->value($laterTT));
at its own epoch: 0.0000000000 degrees (has to be the initial value) 27 years later: 0.375700 degrees
0.3757 degrees over twenty-seven years is 1,353 arcseconds, fifty a year, right where precession puts it.
CustomAyanamsa deliberately carries its pair in Terrestrial Time only and takes no scale flag: the engine has one door for an instant, not two, and converting is Time::tt(). It also has no place in a saved chart code: a stored chart identifies its zodiac by a short key like lahiri, and a custom pair is two floating-point numbers that do not fit one. Passing a CustomAyanamsa where a chart is being serialised is meant to fail loudly rather than quietly opening the chart tropical the next time it is read.
Nakshatra: the twenty-seven lunar mansions
The sidereal zodiac cut into twenty-seven mansions of 13°20' each, the nakshatras, with four padas of 3°20' inside every one, is Vedic astrology's own division of the circle, and it exists only on the sidereal zodiac: on today's tropical longitude a nakshatra reader would land nearly two mansions further along, because the ayanamsa has passed twenty-four degrees. Nakshatra does not choose one for you: it takes a longitude that is already sidereal, and it is the caller's job to have picked an ayanamsa first.
use Astronomy\Ayanamsa; use Astronomy\Body; use Astronomy\Ephemeris; use Astronomy\Nakshatra; use Astronomy\Time; $jdTT = Time::tt(Time::julianDay(new DateTimeImmutable('1981-05-11 07:15:00', new DateTimeZone('UTC')))); $moon = Ephemeris::position(Body::Moon, $jdTT); $sidereal = $moon->shifted(Ayanamsa::Lahiri->value($jdTT))->longitude; $nakshatra = Nakshatra::fromLongitude($sidereal); printf("Moon, sidereal longitude %.4f degrees\n", $sidereal); printf("%s (%s), pada %d, ruled by %s\n", $nakshatra->name(), $nakshatra->iast(), Nakshatra::padaOf($sidereal), $nakshatra->ruler()); printf("runs from %.4f to %.4f degrees, %.4f degrees into it\n", $nakshatra->start(), $nakshatra->end(), Nakshatra::degreesInside($sidereal)); printf("key %s, ruler key %s, next %s\n", $nakshatra->key(), $nakshatra->rulerKey(), $nakshatra->next()->name());
Moon, sidereal longitude 121.3009 degrees Magha (Maghā), pada 1, ruled by Ketu runs from 120.0000 to 133.3333 degrees, 1.3009 degrees into it key magha, ruler key ketu, next Purva Phalguni
name() gives the plain transliteration, iast() the same name with its diacritics exactly as published. Neither the deities nor the traditional meanings behind each nakshatra are in here: they change from one source to the next, Vishakha's presiding deity is given as Indra, as Agni, or as both depending on who is read, and an invented meaning would read exactly as convincingly as a real one. ruler() and rulerKey(), the planet ruling each mansion in the Vimshottari system, are not written by hand either: nine rulers cover twenty-seven mansions in three exact turns, so each nakshatra's ruler is derived from the remainder of dividing its number by nine rather than typed twenty-seven times over.
See Positions for Position::shifted() and the rest of what a position carries, and Houses for how a sidereal zodiac interacts with a house system built on the tropical one underneath.