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.