← Astronomy 4 / 18

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.