Positions
Ephemeris::position() is the one call the rest of the engine hangs from: given a body and an
instant, where it is, seen from the Earth. Houses, eclipses, aspects, orbits, all of it is built
either on top of this or on the same machinery with the observer moved somewhere else.
use Astronomy\Ephemeris; use Astronomy\Body; use Astronomy\Time; [$jdTT] = Time::fromClock(new DateTimeImmutable('1981-05-11 07:15:00', new DateTimeZone('UTC'))); $mars = Ephemeris::position(Body::Mars, $jdTT); printf("longitude %.6f\n", $mars->longitude); printf("latitude %.6f\n", $mars->latitude); printf("distance %.6f AU\n", $mars->distance); printf("speed %.6f deg/day\n", $mars->speed); printf("latitudeSpeed %.6f deg/day\n", $mars->latitudeSpeed); printf("distanceSpeed %.6f AU/day\n", $mars->distanceSpeed); echo $mars->formatted(), "\n";
longitude 41.904962 latitude -0.256237 distance 2.433990 AU speed 0.737086 deg/day latitudeSpeed 0.010671 deg/day distanceSpeed 0.000334 AU/day 11° 54' 18" Taurus
The instant goes in as a julian day in Terrestrial Time, not as the DateTimeImmutable itself:
TT is the uniform clock the ephemerides are written in, and it is not the one on the wall.
Time and calendars is the whole of that distinction; here it is
enough that Time::fromClock() hands it back as the first element of a pair.
With no further arguments, position() is the chart's: apparent, in the true ecliptic of the
date. Both of those are choices, and both take an explicit argument, which is most of the rest
of this chapter.
Body: what is actually there
Body is a backed enum with forty two cases, and they do not all mean the same thing:
use Astronomy\Body; printf("cases %d\n", count(Body::cases())); printf("all() %d\n", count(Body::all())); printf("classical() %d\n", count(Body::classical())); printf("asteroids() %d\n", count(Body::asteroids())); printf("lunarPoints() %d\n", count(Body::lunarPoints())); printf("fictitious() %d\n", count(Body::fictitious()));
cases 42 all() 22 classical() 10 asteroids() 6 lunarPoints() 6 fictitious() 19
classical() is the Sun, the Moon and the eight planets out to Pluto. asteroids() is Chiron,
Pholus and the four major asteroids, Ceres, Pallas, Juno and Vesta: they go by a tabulated
position rather than by a series, because a small body's orbit is only known by numerical
integration, not by an analytical theory. lunarPoints() is the two nodes and the three
Liliths with Priapus opposite, which are elements of the Moon's orbit and not bodies of their
own; position() routes them through a separate path, because light time and aberration
correct where something is seen and a node is not seen. all() is the union of the three,
twenty two cases, and it is what a chart walks through by default.
The other nineteen, fictitious(), are bodies that do not exist: eight postulated by the
Hamburg school in the nineteen twenties and thirties, four with followers but no discovery,
four positions Le Verrier, Adams, Lowell and Pickering calculated before finding what they were
looking for, and three out of discarded or pseudoscientific literature down to Nibiru. They are
propagated from published orbital elements and come out a perfectly believable longitude, which
is exactly why each case says what it is rather than sitting next to Saturn with the same
confidence. They live in Body because the midpoints already lived here, not because they are
astronomy; see the reference for FictitiousBodies.
One case is neither in all() nor in fictitious(): Body::Earth. It only exists in the
heliocentric and barycentric frames, where it is a body like any other rather than the observer.
Asking position() for it throws, because a geocentric Earth is not zero, it does not exist.
That case is Frames.
Position: the object, and why it carries three speeds
$mars->speed above is degrees of longitude per day, signed: negative means retrograde, and
that sign is the only thing that decides it.
[$jdTT] = Time::fromClock(new DateTimeImmutable('2026-03-05', new DateTimeZone('UTC'))); $mercury = Ephemeris::position(Body::Mercury, $jdTT); printf("%s speed %.4f deg/day retrograde: %s\n", $mercury->formatted(), $mercury->speed, $mercury->isRetrograde() ? 'yes' : 'no'); printf("degrees in sign: %.4f\n", $mercury->degreesInSign());
19° 14' 32" Pisces R speed -0.8901 deg/day retrograde: yes degrees in sign: 19.2423
formatted() appends the R by itself, from the same sign isRetrograde() reads. There is no
separate flag to keep in step with the speed.
Longitude carries a speed because a chart reads a retrograde planet differently from a direct
one, so the speed is not an afterthought computed on demand, it comes back attached to the
position every time. And it is not the only speed: latitudeSpeed and distanceSpeed come out
of the same computation, degrees per day and astronomical units per day, without costing a
single extra ephemeris. All three are central differences around the instant, a quarter of a
day on each side by default; position() evaluates the body three times, not once, and the
longitude speed is the one difference divided by the step, the other two are the same thing on
the other two coordinates.
That is also why they can be missing. A Position assembled by hand, rather than returned by
Ephemeris, may only carry the longitude speed:
use Astronomy\Position; $byHand = new Position(Body::Mars, $mars->longitude, $mars->latitude, $mars->distance, $mars->speed); var_dump($byHand->latitudeSpeed);
NULL
A zero there would be a claim: a body that neither drifts in latitude nor approaches nor
recedes. Leaving it null is the honest answer, that it was not asked for, and it is what makes
rectangularVelocity() refuse to guess later in this chapter.
PositionType: which corrections the light carries
Light from a body goes through three things on its way to being seen: light time, because what
is seen is where the body was when the light left, not where it is now; deflection, because the
Sun's mass bends light passing close to it; and aberration, because the observer's own motion
tilts the direction it arrives from. PositionType is which of the three a position carries,
and it reads four separate switches of the classic C interface as a single enum:
use Astronomy\PositionType; foreach (PositionType::cases() as $t) { printf("%-19s %-15s lightTime %-4s deflection %-4s aberration %s\n", $t->name(), $t->key(), $t->lightTime() ? 'yes' : 'no', $t->deflection() ? 'yes' : 'no', $t->aberration() ? 'yes' : 'no'); }
Apparent apparent lightTime yes deflection yes aberration yes Without aberration no-aberration lightTime yes deflection yes aberration no Without deflection no-deflection lightTime yes deflection no aberration yes Astrometric astrometric lightTime yes deflection no aberration no Geometric geometric lightTime no deflection no aberration no
There is no case with aberration and without light time, and that is not an omission: measured,
PositionType::Geometric and a geometric position with only aberration removed give the exact
same longitude, because aberration and light time are two halves of the same computation, the
observer moving while the light is still on its way. Keeping one without the other is not the
position of anything.
Apparent is what a chart reads. Astrometric is the position a catalogue publishes, light
time only. Geometric is where the body actually is at that instant, with nothing applied.
The other two isolate one correction each, and that is what makes them useful for measuring, not
for reading a chart:
[$jdTT] = Time::fromClock(new DateTimeImmutable('1700-01-01', new DateTimeZone('UTC'))); $apparent = Ephemeris::position(Body::Jupiter, $jdTT, PositionType::Apparent); $noDeflection = Ephemeris::position(Body::Jupiter, $jdTT, PositionType::NoDeflection); $noAberration = Ephemeris::position(Body::Jupiter, $jdTT, PositionType::NoAberration); $geometric = Ephemeris::position(Body::Jupiter, $jdTT, PositionType::Geometric); printf("deflection alone: %.4f arcsec\n", ($apparent->longitude - $noDeflection->longitude) * 3600); printf("aberration alone: %.4f arcsec\n", ($apparent->longitude - $noAberration->longitude) * 3600); printf("both, on top of light time: %.4f arcsec\n", ($apparent->longitude - $geometric->longitude) * 3600);
deflection alone: -1.1450 arcsec aberration alone: -20.8361 arcsec both, on top of light time: -30.9861 arcsec
Jupiter sat 0.34 degrees from the Sun on that date, which is why deflection reaches a whole arcsecond here: the closer the light passes to the Sun, the more it bends. It is covered in full further down.
Two bodies step out of the general rule, and PositionType shows why with real numbers instead
of a footnote. The Sun is at the origin of the frame it is measured in, so light time does not
move it and its light cannot be bent by its own mass:
[$jdTT] = Time::fromClock(new DateTimeImmutable('2026-09-19 12:00:00', new DateTimeZone('UTC'))); foreach (PositionType::cases() as $t) { printf("%-18s %.8f\n", $t->name(), Ephemeris::position(Body::Sun, $jdTT, $t)->longitude); }
Apparent 176.57575733 Without aberration 176.58142301 Without deflection 176.57575733 Astrometric 176.58142301 Geometric 176.58142301
Apparent and NoDeflection match to the last digit, and so do Astrometric and Geometric:
the Sun's geometric and astrometric positions are the same thing, because there is no light
time to strip out of either. The Moon is the other exception, and for a different reason: it
travels with the Earth, so between the two there is no annual aberration to correct, and its
light delay already comes discounted from the lunar series itself.
ReferenceEcliptic: which plane, from which equinox
The second option changes where zero is measured from, not what corrections the light carries. The true ecliptic of date is the chart's: the plane and the equinox of that same day, with nutation applied, which is where the sky is actually seen. The mean one is the same plane without the day's wobble, and J2000 is a different, fixed plane, the one of 1 January 2000 at noon.
use Astronomy\ReferenceEcliptic; [$jdTT] = Time::fromClock(new DateTimeImmutable('1981-05-11 07:15:00', new DateTimeZone('UTC'))); foreach (ReferenceEcliptic::cases() as $ecliptic) { $p = Ephemeris::position(Body::Mars, $jdTT, ecliptic: $ecliptic); printf("%-22s %.6f deg\n", $ecliptic->name(), $p->longitude); } $trueOfDate = Ephemeris::position(Body::Mars, $jdTT, ecliptic: ReferenceEcliptic::TrueOfDate); $meanOfDate = Ephemeris::position(Body::Mars, $jdTT, ecliptic: ReferenceEcliptic::MeanOfDate); $j2000 = Ephemeris::position(Body::Mars, $jdTT, ecliptic: ReferenceEcliptic::J2000); printf("true minus mean = %.4f arcsec (nutation)\n", ($trueOfDate->longitude - $meanOfDate->longitude) * 3600); printf("mean minus J2000 = %.4f deg (precession, 1981 to 2000)\n", $meanOfDate->longitude - $j2000->longitude);
True ecliptic of date 41.904962 deg Mean ecliptic of date 41.909148 deg Ecliptic of J2000 42.169576 deg true minus mean = -15.0683 arcsec (nutation) mean minus J2000 = -0.2604 deg (precession, 1981 to 2000)
The true and mean planes differ by nutation alone, up to seventeen arcseconds; the mean and
J2000 ones differ by the whole of precession over the gap, a quarter of a degree over forty five
years here. In J2000 there is no nutation, on purpose and measured: ReferenceEcliptic::J2000
gives the exact same longitude whether or not nutation is switched off alongside it, because
J2000 is a fixed snapshot of one day and nutation is a wobble of the axis at the date, which a
fixed snapshot has none of.
Rectangular coordinates, and the velocity that is not a second position
Position::rectangular() turns longitude, latitude and distance into x, y, z, astronomical
units on the axes of whichever ecliptic the position was measured in:
[$jdTT] = Time::fromClock(new DateTimeImmutable('1981-05-11 07:15:00', new DateTimeZone('UTC'))); $mars = Ephemeris::position(Body::Mars, $jdTT); [$x, $y, $z] = $mars->rectangular(); printf("x %.6f y %.6f z %.6f AU\n", $x, $y, $z); printf("|r| = %.6f AU, distance = %.6f AU\n", sqrt($x ** 2 + $y ** 2 + $z ** 2), $mars->distance); [$vx, $vy, $vz] = $mars->rectangularVelocity(); printf("vx %.6f vy %.6f vz %.6f AU/day\n", $vx, $vy, $vz);
x 1.811488 y 1.625638 z -0.010885 AU |r| = 2.433990 AU, distance = 2.433990 AU vx -0.020663 vy 0.023528 vz 0.000452 AU/day
rectangularVelocity() is the derivative of that same conversion, not the velocity carried
across unchanged: differentiating x, y and z by the chain rule needs all three of longitude,
latitude and distance and all three of their speeds, which is exactly why latitudeSpeed and
distanceSpeed exist rather than being thrown away after computing the longitude one. Build a
Position by hand with only the longitude speed and this comes back null instead of a number
that quietly assumes the latitude and the distance hold still:
$byHand = new Position(Body::Mars, $mars->longitude, $mars->latitude, $mars->distance, $mars->speed); var_dump($byHand->rectangularVelocity());
NULL
A node or a Lilith come back at the origin, [0.0, 0.0, 0.0], because they have no distance:
they are a direction on the sky, not a point in space, and whoever wants the direction already
has the longitude.
Ephemeris::apparentLongitude(): the cheap path
Most of what asks for a position over and over, a sweep looking for when a planet crosses a
degree, a search for a station, only ever reads the longitude. Paying for the two extra
central differences on every one of those calls adds up, and apparentLongitude() is the door
that does not pay for them:
[$jdStart] = Time::fromClock(new DateTimeImmutable('2026-01-01', new DateTimeZone('UTC'))); $n = 3000; $t0 = microtime(true); for ($i = 0; $i < $n; $i++) { Ephemeris::apparentLongitude(Body::Saturn, $jdStart + $i * 0.1); } $t1 = microtime(true); for ($i = 0; $i < $n; $i++) { Ephemeris::position(Body::Saturn, $jdStart + $i * 0.1)->longitude; } $t2 = microtime(true); $cheap = $t1 - $t0; $full = $t2 - $t1; printf("ratio: %.0fx\n", round($full / $cheap));
ratio: 3x
The millisecond totals are left out of what is printed above on purpose: they are wall clock
time on whatever machine runs this, and change from one run to the next (on the machine this was
last measured on, apparentLongitude() ran the 3000 calls in a bit under two seconds and
position() in a bit under six). The ratio does not move the same way: position() evaluates
the body at the instant and at the two either side of it to derive all three speeds, and
apparentLongitude() evaluates it once. It returns a bare float and not a Position with the
speed left at zero on purpose, because a zero there reads as a datum, a planet standing still,
rather than as the absence of one; a float has no such field to misread. Where the two overlap
they agree exactly, down to the same floating point bits, because apparentLongitude() is not a
separate computation, it is the first element of the same one with the other two thrown away
before they are paid for.
The gravitational deflection of light
Deflection is the piece of PositionType that is not intuitive, so it earns its own numbers.
The Sun's mass bends light passing near it, the effect Eddington measured at the 1919 eclipse,
and a planet sitting close to the Sun as seen from the Earth is displaced outward from it by an
amount that grows the closer the two appear. The scan below walks a daily position for twelve
years to find real instances rather than construct them, which is why it takes about a minute
to run and not a fraction of a second:
function deflectionAt(Body $body, float $jdTT): array { $sun = Ephemeris::position(Body::Sun, $jdTT); $apparent = Ephemeris::position($body, $jdTT, PositionType::Apparent); $noDeflection = Ephemeris::position($body, $jdTT, PositionType::NoDeflection); $elongation = fmod($apparent->longitude - $sun->longitude + 360, 360); if ($elongation > 180) { $elongation = 360 - $elongation; } return [$elongation, abs($apparent->longitude - $noDeflection->longitude) * 3600]; } // Jupiter's own conjunctions with the Sun, scanned daily over twelve years: the closest // approach, and the closest match to four wider separations. [$jdStart] = Time::fromClock(new DateTimeImmutable('2015-01-01', new DateTimeZone('UTC'))); $closest = null; for ($d = 0; $d < 365 * 12; $d++) { $pair = deflectionAt(Body::Jupiter, $jdStart + $d); if ($closest === null || $pair[1] > $closest[1]) { $closest = $pair; } } printf("%6.2f deg from the Sun: %.4f arcsec\n", $closest[0], $closest[1]); foreach ([1.0, 5.0, 45.0, 90.0] as $target) { $best = null; for ($d = 0; $d < 365 * 12; $d++) { $pair = deflectionAt(Body::Jupiter, $jdStart + $d); if ($best === null || abs($pair[0] - $target) < abs($best[0] - $target)) { $best = $pair; } } printf("%6.2f deg from the Sun: %.4f arcsec\n", $best[0], $best[1]); }
0.26 deg from the Sun: 1.1381 arcsec 1.00 deg from the Sun: 0.2778 arcsec 5.02 deg from the Sun: 0.0754 arcsec 45.00 deg from the Sun: 0.0081 arcsec 90.00 deg from the Sun: 0.0033 arcsec
Those five values come from Jupiter's own real conjunctions and wider separations over a twelve year scan, not from constructed geometry, and the shape is the one relativity predicts: over an arcsecond grazing the disc, falling off fast, down to thousandths by ninety degrees. A body of the solar system gets less than the textbook figure for starlight grazing the limb, 1.75 arcseconds, because that classical number assumes a source infinitely far away and a planet only travels part of the curved path; the engine's own test converges a source out to a hundred thousand astronomical units to reproduce it.
Three things about how it is applied, each measured rather than assumed:
- Deflection comes before aberration, and the order is not free. The light arrives curved by the Sun first, and only then does the observer's motion tilt it; correcting aberration on a direction that has not been bent yet corrects the wrong thing.
- The Sun and the Moon never carry it. The Sun's own mass cannot bend its own light, and the Moon's light does not pass anywhere near the Sun on its way here, the same reason it carries no annual aberration.
- Directly behind the Sun's disc, there is nothing to correct. The formula goes to infinity there, because the light would have to pass through the star, and a body covered by the disc has no apparent direction: what is returned is the uncorrected vector rather than a number that grows without bound.
Where to go next
Time and calendars is the scale every instant in this chapter goes
in and the one Terrestrial Time is measured against. Frames is
what changes when the observer is not the Earth: Body::Earth, the parallax that makes the
Moon a special case, and why aberration belongs to whoever is looking. Precision
is how every number in this chapter is checked against JPL Horizons.