← Astronomy 5 / 18

Time and calendars

Astronomy\Time is the one class in the engine with no dependencies of its own: no place, no body, no ephemeris file, only scales, delta T and the calendars. Everything else hangs off it, starting with Ephemeris, and that is deliberate rather than incidental, because the trap this class exists to prevent is easy to fall into and hard to notice once it has happened.

Three scales, and the trap of using the wrong one

A planet's position is computed in Terrestrial Time, TT: a uniform clock with nothing to do with how fast the Earth happens to be turning. The houses, the horizon, anything that asks where a place is pointing, need Universal Time, UT1, instead, because that is the rotation of the Earth itself. Between the two there are some seventy seconds today, and the gap moves: it is delta T, observed rather than constant.

use Astronomy\Time;

[$jdTT, $jdUt1] = Time::fromClock(new DateTimeImmutable('2026-09-19 12:00:00', new DateTimeZone('UTC')));

printf("jdTT   %.6f\n", $jdTT);
printf("jdUt1  %.6f\n", $jdUt1);
printf("TT - UT1 = %.3f seconds\n", ($jdTT - $jdUt1) * 86400);
jdTT   2461303.000801
jdUt1  2461303.000000
TT - UT1 = 69.198 seconds

Using the same scale for both moves an ascendant almost a minute of arc, and it does not warn: the wheel comes out looking exactly as normal with everything a little off.

There is a third scale underneath, UTC, which is what a wall clock actually reads: an atomic scale with a leap second inserted every so often so that it does not drift from UT1 by more than 0.9 seconds. fromClock() is where a birth time enters the engine, and it is the door that knows the difference: it takes a UTC instant and hands back both jdTT and jdUt1 already converted, in that order, so that the pair travels together and nothing downstream can quietly take one where it needed the other.

$back = Time::toClock($jdTT);

echo $back->format('Y-m-d H:i:s'), " UTC\n";
2026-09-19 12:00:00 UTC

toClock() is the way back, always in UTC and rounded to the second, which is how a chart shows the time it was cast from.

From separate numbers: utcToJulianDay(), ttToUtc(), ut1ToUtc()

fromClock() takes a DateTimeImmutable. utcToJulianDay() is the same conversion from six separate numbers, for whoever is reading a birth time off a form rather than off an object, and it returns the identical pair:

[$jdTT2, $jdUt1b] = Time::utcToJulianDay(2026, 9, 19, 12, 0, 0.0);

printf("utcToJulianDay jdTT = %.6f (matches fromClock: %s)\n", $jdTT2, $jdTT2 === $jdTT ? 'yes' : 'no');
utcToJulianDay jdTT = 2461303.000801 (matches fromClock: yes)

ttToUtc() and ut1ToUtc() are the way back from either scale to a UTC clock reading, and the second is the first with delta T added in front, because UT1 carries no leap seconds of its own, it is a rotation angle:

echo "ttToUtc:  ", implode(' ', Time::ttToUtc($jdTT)), "\n";
echo "ut1ToUtc: ", implode(' ', Time::ut1ToUtc($jdUt1)), "\n";
ttToUtc:  2026 9 19 12 0 0
ut1ToUtc: 2026 9 19 12 0 0

Both return [year, month, day, hour, minute, second], and both agree here because they started from the same instant seen through two different scales. Whether it is worth the trouble to correct for the 0.9 seconds this whole chain exists for is a question fromClock()'s own docblock answers with numbers rather than an opinion: over 3,312 combinations with the offset at its maximum, the median shift on the ascendant is 12.3 arcseconds and the worst case 478, eight minutes, near latitude 66. It is 5 arcseconds and 12 respectively in two real charts the test suite carries. Small next to a sign, not small next to a degree.

Delta T: observed, not computed

Time::deltaT() cannot be derived from a formula, because it measures how much the Earth's rotation has slowed down, and nobody predicts that in either direction. What there is instead is a table of observations, with a historical reconstruction stitched in front of it and an extrapolation stitched behind it:

printf("deltaT(2026-09-19) = %.4f seconds\n", Time::deltaT(Time::civilJulianDay(2026, 9, 19)));
printf("deltaT(1900-01-01) = %.4f seconds\n", Time::deltaT(Time::civilJulianDay(1900, 1, 1)));
printf("deltaT(1700-01-01) = %.4f seconds\n", Time::deltaT(Time::civilJulianDay(1700, 1, 1)));

[$y, $m, $d] = Time::civilDate(Time::deltaTObservedUntil());
printf("observed until: %04d-%02d-%02d\n", $y, $m, (int) $d);
deltaT(2026-09-19) = 69.1975 seconds
deltaT(1900-01-01) = -1.9908 seconds
deltaT(1700-01-01) = 14.2867 seconds
observed until: 2026-09-01

The table is built in four stretches, each downloaded rather than typed by hand:

Range Source
Before -720 A long term parabola, shifted so that it joins the spline exactly at -720
-720 to 1955 The Stephenson, Morrison and Hohenkerk 2016 cubic spline, 54 pieces, from eclipse records
1955 to 1973 Six monthly observed values, once atomic clocks exist to measure against
1973 to the last observed month One value per month, 32.184 + (TAI minus UTC) minus (UT1 minus UTC)
One year further The published prediction for the coming year
Past that An extrapolation anchored to the engine's own last point, not to a formula picked in isolation

The negative value in 1900 is real and not a bug: delta T crosses zero around there, because TT is anchored to line up with the old ephemeris time scale, and the Earth was running very slightly fast against that anchor at the turn of the century. deltaTObservedUntil() is the boundary between the measured part of the table and the part that is a prediction or an extrapolation, and it moves forward every time the table is refreshed, which is worth knowing before trusting a delta T value far in the future to the same digit as one from last month.

Leap seconds, and the 61st second that exists on 27 days

UTC is kept within 0.9 seconds of UT1 by inserting a whole extra second into a minute now and then, and taiMinusUtc() is the running counter of how many have been inserted since 1972:

printf("TAI-UTC on 2026-09-19 = %d\n", Time::taiMinusUtc(Time::civilJulianDay(2026, 9, 19)));
printf("TAI-UTC on 2016-12-31 = %d (the jump to 37 is the next day)\n", Time::taiMinusUtc(Time::civilJulianDay(2016, 12, 31)));
TAI-UTC on 2026-09-19 = 37
TAI-UTC on 2016-12-31 = 36 (the jump to 37 is the next day)

That counter is what makes 23:59:60 a real, acceptable second, but only in the specific minute that carried one:

[$leap] = Time::utcToJulianDay(2016, 12, 31, 23, 59, 60.0);

echo implode(' ', Time::ttToUtc($leap)), "\n";

try {
    Time::utcToJulianDay(2026, 9, 19, 23, 59, 60.0);
} catch (\InvalidArgumentException $e) {
    echo $e->getMessage(), "\n";
}
2016 12 31 23 59 60
2026-09-19 23:59:60.000 does not exist: no leap second was inserted into that minute.

The last minute of 2016 carried a leap second and the engine accepts 23:59:60 for it, round trip included. Anywhere else that second does not exist, and it throws rather than silently rolling over into the next minute with a value that would look plausible.

The Julian calendar, and a date that does not exist

civilJulianDay() takes gregorian as an explicit argument rather than switching automatically at the 1582 reform, because whether a date is Julian or Gregorian is a property of where it came from, not of when it happened: a date read off a parish register from before the reform is Julian because its source is, and treating it as Gregorian shifts it by days that were never counted:

$julian = Time::civilJulianDay(1582, 10, 4, gregorian: false);
$gregorian = Time::civilJulianDay(1582, 10, 15, gregorian: true);

printf("4 Oct 1582 Julian      = jd %.1f\n", $julian);
printf("4 Oct 1582 Julian + 1  = jd %.1f\n", $julian + 1);
printf("15 Oct 1582 Gregorian  = jd %.1f\n", $gregorian);
printf("same instant: %s\n", ($julian + 1) === $gregorian ? 'yes' : 'no');
4 Oct 1582 Julian      = jd 2299159.5
4 Oct 1582 Julian + 1  = jd 2299160.5
15 Oct 1582 Gregorian  = jd 2299160.5
same instant: yes

The day after 4 October 1582 Julian is 15 October 1582 Gregorian: the ten days in between were never on any calendar. And the two calendars do not even agree on which years are leap years, which is the case that actually bites:

printf("days in Feb 1900, Gregorian: %d\n", Time::daysInMonth(1900, 2, gregorian: true));
printf("days in Feb 1900, Julian:    %d\n", Time::daysInMonth(1900, 2, gregorian: false));
days in Feb 1900, Gregorian: 28
days in Feb 1900, Julian:    29

1900 is divisible by four but also by 100 and not by 400, so the Gregorian rule drops it and the Julian rule, which only checks divisibility by four, does not.

civilJulianDay() itself does not validate anything, on purpose: it is the inner door, and dates reaching it already came out of a DateTimeImmutable that exists. checkedJulianDay() is the outer one, for a date typed into a form, and it says what the real date is instead of silently accepting an impossible one:

$checked = Time::checkedJulianDay(2026, 2, 31);

var_export($checked);
array (
  'valid' => false,
  'jd' => 2461103.0,
  'year' => 2026,
  'month' => 3,
  'day' => 3,
  'hours' => 12.0,
)

31 February does not exist, and what comes back is 3 March, the date the underlying arithmetic actually lands on: a month of 13 rolls into January of the following year and a day of 0 rolls into the last day of the month before, with no rule written twice to make that happen, it falls out of the same subtraction that turns two julian days into a day count.

Sidereal time

meanSiderealTime() is how far Greenwich has turned since the equinox, ignoring the day's nutation; apparentSiderealTime() adds it back in, and it is the one the houses and the horizon actually use:

[, $jdUt] = Time::fromClock(new DateTimeImmutable('1981-05-11 07:15:00', new DateTimeZone('UTC')));

printf("mean sidereal time at Greenwich:     %.6f deg\n", Time::meanSiderealTime($jdUt));
printf("apparent sidereal time at Greenwich: %.6f deg\n", Time::apparentSiderealTime($jdUt));
printf("difference (equation of the equinoxes): %.4f arcsec\n",
    (Time::apparentSiderealTime($jdUt) - Time::meanSiderealTime($jdUt)) * 3600);
mean sidereal time at Greenwich:     337.740457 deg
apparent sidereal time at Greenwich: 337.736616 deg
difference (equation of the equinoxes): -13.8248 arcsec

The mean one is exposed because most of the published literature tabulates it and it makes for an easy cross check; nothing inside the engine uses it, and nothing should, because the true equinox is where the sky actually is.

EquationOfTime: the Sun against the clock

A sundial and a wristwatch do not agree, and the equation of time is by how much: apparent solar time minus mean solar time, up to a quarter of an hour either way. It comes from two things compounding, the Earth's elliptical orbit, which runs faster in January than in July, and the Sun's own motion along the tilted ecliptic rather than along the equator that the clock actually measures against.

use Astronomy\EquationOfTime;

$dates = ['2024-02-12', '2024-04-15', '2024-06-12', '2024-09-01', '2024-11-02', '2024-12-24'];

foreach ($dates as $d) {
    $jdUt = Time::julianDay(new DateTimeImmutable($d.' 12:00:00', new DateTimeZone('UTC')));
    printf("%s  %+.3f minutes\n", $d, EquationOfTime::minutes($jdUt));
}
2024-02-12  -14.193 minutes
2024-04-15  +0.086 minutes
2024-06-12  +0.013 minutes
2024-09-01  +0.159 minutes
2024-11-02  +16.454 minutes
2024-12-24  +0.217 minutes

Positive means the real Sun runs ahead of the clock; the four dates near zero are close to the four crossings the curve has every year, and 12 February and 2 November are close to its two extremes for 2024. There is no separate series fitted for this: EquationOfTime subtracts two things the engine already knows to arcsecond precision, the apparent sidereal time in UT minus the Sun's own right ascension in TT, rather than adding a hand typed four term approximation next to machinery already checked against a published ephemeris.

trueNoon() turns that into when the Sun actually culminates at a place, which is not the same twelve o'clock the time zone agrees on:

$jdUt = Time::julianDay(new DateTimeImmutable('2024-02-12', new DateTimeZone('UTC')));
$noon = EquationOfTime::trueNoon($jdUt, -3.7026);

$madrid = Time::toClock(Time::tt($noon))->setTimezone(new DateTimeZone('Europe/Madrid'));

echo $madrid->format('H:i:s T'), "\n";
13:29:00 CET

Madrid sits three and a half degrees west of the meridian its time zone is built on, which alone shifts noon by about fourteen minutes, and the equation of time adds fourteen more on this particular date: true noon there falls at half past one, not at midday.

Where to go next

Positions is what every jdTT computed here feeds into. Frames covers the one place besides the houses where UT1 matters again, the observer's own position on the ground. The data is where the delta T table and the leap second list actually come from and how to refresh them.