Phenomena
Phenomena::of() answers what a body looks like from here at one instant, not where it is. Five numbers come out of one triangle, the Sun, the body and the observer, formed with Ephemeris::phaseGeometry(): the phase angle, how much of the disc is lit, the elongation, the apparent diameter and the visual magnitude. MoonPhase is the same triangle read for the Moon specifically, with the two lunations either side of an instant. HeliacalPhenomena and ArcusVisionis answer a different question again, not what a body looks like but on what calendar day it can be seen at all after hiding behind the Sun, which is Babylonian and Hellenistic astronomy's own technique.
The triangle is measured by Ephemeris, not by Phenomena itself, because the one delicate part, at which instant each side of the triangle is measured (a body is seen where it was when its own light left, and the Sun is not), lives there and only there. Phenomena does the geometry that follows from having the three sides.
Phase, elongation, diameter and magnitude
use Astronomy\Body; use Astronomy\Phenomena; use Astronomy\Time; [$jdTT] = Time::fromClock(new DateTimeImmutable('2026-09-19 12:00:00', new DateTimeZone('UTC'))); foreach ([Body::Venus, Body::Mars, Body::Jupiter, Body::Saturn] as $body) { $p = Phenomena::of($body, $jdTT); printf( "%-8s phase %6.2f deg lit %5.1f%% elong %6.2f deg diam %7.2f\" mag %s\n", $body->name(), $p->phaseAngle, $p->illuminatedFraction * 100, $p->elongation, $p->diameterInArcseconds(), $p->magnitude === null ? 'null' : sprintf('%.2f', $p->magnitude) ); }
Venus phase 119.84 deg lit 25.1% elong 38.94 deg diam 39.81" mag -4.79 Mars phase 34.96 deg lit 91.0% elong 61.72 deg diam 5.38" mag 1.18 Jupiter phase 6.87 deg lit 99.6% elong 39.13 deg diam 32.62" mag -1.84 Saturn phase 1.69 deg lit 100.0% elong 163.90 deg diam 19.62" mag 0.38
phaseAngle is Sun-body-Earth: zero is opposite the Sun and fully lit, 180 is between the Sun and the observer and dark. It is symmetric, so it cannot by itself tell a first quarter from a last quarter, both sit at ninety degrees with half the disc lit. What separates them is which side of the Sun the body is on, and that is Phenomena::orientedElongation(), measured from 0 to 360 rather than folded to the short arc; Phenomenon::isWaxing() reads it. elongation is the apparent Sun-Earth-body angle, measured between the two APPARENT directions rather than out of the raw triangle, because light time, aberration and latitude all belong in the number that decides whether a planet can actually be looked at.
The Sun has no phase: asking Phenomena::of() for it returns a phase angle of zero, a disc fully lit and zero elongation, without dividing by the zero-length side its own triangle would otherwise have.
[$jdTT] = Time::fromClock(new DateTimeImmutable('2026-09-19 12:00:00', new DateTimeZone('UTC'))); $sun = Phenomena::of(Body::Sun, $jdTT); printf("Sun: phase %.2f lit %.2f%% elongation %.2f diameter %.4f arcsec\n", $sun->phaseAngle, $sun->illuminatedFraction * 100, $sun->elongation, $sun->diameterInArcseconds());
Sun: phase 0.00 lit 100.00% elongation 0.00 diameter 1909.7455 arcsec
The illuminated fraction does not come from the elongation
The formula that circulates, (1 - cos elongation) / 2, gives an almost-right answer for a reason worth being precise about: the elongation is the angle seen from HERE, and what actually decides how much of the disc looks lit is the angle seen from THE BODY, which is a different one. On the Moon, close enough that the two do not add up to a straight line, they can differ by an eighth of a degree.
use Astronomy\Body; use Astronomy\MoonPhase; use Astronomy\Phenomena; use Astronomy\Time; // Sweep a lunation and compare the real illuminated fraction (measured at the Moon) // against the formula that circulates, measured from HERE. [$jdTT] = Time::fromClock(new DateTimeImmutable('2026-06-15 00:00:00', new DateTimeZone('UTC'))); $worst = 0.0; for ($i = 0; $i <= 295; $i++) { $t = $jdTT + $i * 0.1; $elongation = MoonPhase::elongation($t); $real = Phenomena::of(Body::Moon, $t)->illuminatedFraction; $naive = (1.0 - cos(deg2rad($elongation))) / 2.0; $worst = max($worst, abs($real - $naive) * 100.0); } printf("worst departure over a lunation: %.2f percentage points\n", $worst); [$jdTT] = Time::fromClock(new DateTimeImmutable('2026-08-12 12:00:00', new DateTimeZone('UTC'))); $elongation = MoonPhase::elongation($jdTT); $real = Phenomena::of(Body::Moon, $jdTT)->illuminatedFraction; $naive = (1.0 - cos(deg2rad($elongation))) / 2.0; printf("elongation %.2f deg: real %.4f%% naive formula %.4f%%\n", $elongation, $real * 100, $naive * 100);
worst departure over a lunation: 0.21 percentage points elongation 356.84 deg: real 0.0876% naive formula 0.0759%
A fifth of a percentage point sounds small until it is read as a proportion near new Moon, where it moves the answer by more than ten per cent of itself. Phenomena::of() measures it at the Moon, through Ephemeris::phaseGeometry(), and it costs a little over a millisecond more than the shortcut.
The apparent diameter uses the equatorial radius
use Astronomy\Body; use Astronomy\Phenomena; use Astronomy\Time; [$jdTT] = Time::fromClock(new DateTimeImmutable('2026-09-19 12:00:00', new DateTimeZone('UTC'))); $saturn = Phenomena::of(Body::Saturn, $jdTT); printf("Saturn's apparent diameter: %.3f arcsec\n", $saturn->diameterInArcseconds()); printf("equatorial radius %.1f km, mean radius %.1f km, %.2f%% wider across the equator\n", Body::Saturn->equatorialRadiusKm(), Body::Saturn->radiusKm(), (Body::Saturn->equatorialRadiusKm() / Body::Saturn->radiusKm() - 1.0) * 100.0);
Saturn's apparent diameter: 19.624 arcsec equatorial radius 60268.0 km, mean radius 58232.0 km, 3.50% wider across the equator
The giants are flattened, and apparentDiameter uses Body::equatorialRadiusKm() rather than the mean radius radiusKm() on purpose: that mean radius is the one eclipses and occultations need, where the geometry of a shadow or of a graze is what is being measured, while a disc's width as it is actually seen is set by the widest cut across it. Leaving the equatorial radius out here would put Saturn's disc three and a half per cent too small.
The magnitude model
V = 5 · log10(r · Δ) + f(α)
The first two terms are pure geometry, how much a body dims for being far from the Sun and far from the observer, and there is nothing to fit in them. Everything particular to a body, how much it reflects, how it darkens seen edge-on, lives in f(α), a Chebyshev fit over the phase angle, and that fit is not copied from a table: astronomy magnitudes asks JPL Horizons for the magnitude it publishes across decades, strips the geometry out, and fits what is left. Magnitudes::residual() is the number that check leaves behind for each body.
use Astronomy\Body; use Astronomy\Magnitudes; foreach ([Body::Mercury, Body::Venus, Body::Mars, Body::Jupiter, Body::Saturn, Body::Uranus, Body::Neptune, Body::Pluto] as $b) { printf("%-8s residual %s magnitudes\n", $b->name(), Magnitudes::residual($b)); }
Mercury residual 0.00034 magnitudes Venus residual 0.002142 magnitudes Mars residual 0.03605 magnitudes Jupiter residual 0.000252 magnitudes Saturn residual 0.020959 magnitudes Uranus residual 0.0173 magnitudes Neptune residual 0.040393 magnitudes Pluto residual 0.000273 magnitudes
Fifteen bodies carry a model: the Sun, the Moon, the eight planets, Pluto, Chiron, Pholus and the four asteroids the engine knows. The six smallest go through the H-G system, which is also a function of the phase angle and needed no separate case. Outside the range of phase angles a body was fitted with, of() returns null rather than extrapolating: Mercury and Venus reach almost 180 degrees of phase, and a polynomial extrapolated that far shoots off to a number that reads as real.
Saturn's rings need a second input
The rings carry a good part of Saturn's light, and every fifteen years they turn edge-on and all but disappear, so a model of the phase angle alone leaves Saturn with an error bigger than the brightness of a lot of stars. f for Saturn takes a second argument, the ring opening, which is the latitude of the observer as seen from Saturn and comes out of the planet's own pole.
use Astronomy\Body; use Astronomy\Phenomena; use Astronomy\Time; foreach (['2025-03-23', '2026-09-19', '2032-10-01'] as $date) { [$jdTT] = Time::fromClock(new DateTimeImmutable($date . ' 00:00:00', new DateTimeZone('UTC'))); $p = Phenomena::of(Body::Saturn, $jdTT); printf("%s magnitude %6.2f\n", $date, $p->magnitude); }
2025-03-23 magnitude 1.13 2026-09-19 magnitude 0.38 2032-10-01 magnitude -0.02
Near the 2025 ring-plane crossing, when the rings were seen edge-on, Saturn is more than a magnitude fainter than in 2032, when they stand open: the rings alone are worth as much brightness as the difference between Jupiter and Mars.
The Moon's phase and its lunations
use Astronomy\MoonPhase; use Astronomy\Time; [$jdTT] = Time::fromClock(new DateTimeImmutable('2026-06-21 12:00:00', new DateTimeZone('UTC'))); $phase = MoonPhase::at($jdTT); printf("%s, elongation %.2f deg, %.1f%% lit\n", $phase->name, $phase->elongationForDisplay(), $phase->illumination * 100); printf("disc %.2f arcminutes, %+.2f%% of its usual size\n", $phase->apparentDiameter * 60, $phase->relativeToMeanSize()); foreach (MoonPhase::lunations($jdTT) as $when => $lunation) { printf("%-8s %s on %s\n", $when, $lunation['type'], Time::toClock($lunation['jd'])->format('Y-m-d H:i')); }
first-quarter, elongation 85.07 deg, 45.8% lit disc 30.95 arcminutes, -0.43% of its usual size previous new-moon on 2026-06-15 02:54 next full-moon on 2026-06-29 23:56
lunations() finds the crossing before and the one after by walking away from the instant a day at a time and bisecting the first sign change on each side, rather than sweeping a fixed window and refining every crossing inside it: about thirty elongations instead of two hundred, and roughly half of what drawing a whole chart costs.
The four phases with a name, new moon, first quarter, full moon, last quarter, are instants, the exact moment the elongation equals 0, 90, 180 or 270, and name carries a six-degree margin either side of each one so it does not read as contradicting itself. Splitting the month into eight equal stretches instead, which is the shortcut, turns "new moon" into 3.7 days of the label and can cut a full moon in half. The closest full Moon in seventy years is exactly that case:
[$jdTT] = Time::fromClock(new DateTimeImmutable('2016-11-14 13:52:00', new DateTimeZone('UTC'))); $super = MoonPhase::at($jdTT); printf("%s, elongation (display) %.2f deg, %.2f%% lit\n", $super->name, $super->elongationForDisplay(), $super->illumination * 100); printf("disc %.2f arcminutes, %+.1f%% of its usual size\n", $super->apparentDiameter * 60, $super->relativeToMeanSize());
full-moon, elongation (display) 180.00 deg, 99.83% lit disc 33.52 arcminutes, +7.8% of its usual size
relativeToMeanSize() is what sits behind the word "supermoon", not an astronomical term but a measurable one: the Moon's orbit is elliptical enough that its disc grows and shrinks by about fourteen per cent between perigee and apogee, and this Moon, near perigee, was seen almost eight per cent larger than its mean size.
Heliacal phenomena
HeliacalPhenomena and ArcusVisionis are archaeoastronomy: when a body disappears behind the Sun's glare and when it becomes visible again, low on the horizon just before dawn or just after dusk, which is the technique Babylonian and Hellenistic astronomy dated its calendars with. The criterion here is the classical one, Schoch's arcus visionis (1924): a single angle, the Sun's depression below the horizon at the instant the body crosses it, published per body and per event from real Babylonian observations rather than modelled from an assumed observer.
use Astronomy\HeliacalEvent; use Astronomy\HeliacalPhenomena; use Astronomy\ArcusVisionis; use Astronomy\Place; use Astronomy\Stars; use Astronomy\Time; $sirius = Stars::find('Sirius'); printf("Sirius, heliacal rising: %.1f deg\n", ArcusVisionis::of($sirius, HeliacalEvent::HeliacalRising)); printf("a star of Sirius's own magnitude (%.2f), off the general star table: %.1f deg\n", $sirius->magnitude, ArcusVisionis::forMagnitude($sirius->magnitude, HeliacalEvent::HeliacalRising)); $babylon = new Place('Babylon', null, 'Iraq', 'IQ', 32.5364, 44.4208, 'Asia/Baghdad'); $jdUt = Time::julianDay(new DateTimeImmutable('2026-01-01', $babylon->timeZone())); $rising = HeliacalPhenomena::find($sirius, $babylon, $jdUt, HeliacalEvent::HeliacalRising); printf( "%s of %s in Babylon: %s, Sun %.2f deg below the horizon, object %.2f deg up\n", $rising->event->name(), $rising->name, $rising->observation->date->format('Y-m-d H:i'), $rising->arcusVisionisRequired, $rising->objectAltitude ); $dayOf = $rising->pass->date; $dayBefore = $dayOf->modify('-1 day'); foreach ([$dayBefore, $dayOf] as $day) { $visible = HeliacalPhenomena::isVisible($sirius, $babylon, $day, HeliacalEvent::HeliacalRising); $arc = HeliacalPhenomena::arcOfTheDay($sirius, $babylon, $day, HeliacalEvent::HeliacalRising); printf("%s arc %.3f deg visible: %s\n", $day->format('Y-m-d'), $arc[0], $visible ? 'yes' : 'no'); }
Sirius, heliacal rising: 7.8 deg a star of Sirius's own magnitude (-1.46), off the general star table: 8.8 deg Heliacal rising of Sirius in Babylon: 2026-08-04 04:42, Sun 7.80 deg below the horizon, object 0.01 deg up 2026-08-03 arc 6.926 deg visible: no 2026-08-04 arc 7.814 deg visible: yes
Sirius is given its own entry in Schoch's table rather than being read off the general one, and the difference above is why: the general table is for stars near the ecliptic, and Sirius sits forty degrees of ecliptic latitude away from it, which changes the azimuth between it and the Sun enough to move the required arc by a whole degree, worth about a day on the date. arcOfTheDay() is the bare measurement with no criterion attached, which is what lets isVisible() be checked against it independently: the day before the rising the arc falls short of 7.8 degrees, and on the day itself it clears it, with the object just above the horizon at the moment the sky is dark enough.
The alternative criterion, Schaefer's contrast model (1993, 2000), is a full sky-brightness reckoning and is deliberately not implemented here: it takes the atmospheric extinction, the humidity and the observer's own age and eyesight as parameters, and left at their defaults those describe an invented observer while looking like a measurement. What can be stated honestly is ArcusVisionis::limitMagnitude(), Schoch's table read backwards, down to what magnitude the sky lets through at a given arc, with the four conditions it holds under written into its own docblock. Checked against Schoch's own worked examples in his 1924 paper, the Babylonian dates of Sirius's heliacal rising across five thousand years of the record come out right on six of eight and within a day on the other two.