Rise, set and twilight
RiseSet gives the rise, the set and the two meridian passes of a body on one civil day at one place, plus the three twilights for the Sun. Horizon is what sits underneath it: the observer, their local sidereal time, refraction, and the two directions between the sky and the ground, Place carries the coordinates and the time zone a rise or a set needs to mean anything.
Never one ephemeris per second. A rise has to be pinned to the second and a position of the Moon costs milliseconds, so bisecting against the raw ephemeris would be tens of thousands of evaluations for one datum. RiseSet samples the geocentric position every hour, which is a very smooth curve, and interpolates it with four-point Lagrange; what changes fast, the observer's own rotation under the sky, is cheap and gets computed exactly at every evaluation. The interpolation error on the Moon is below a tenth of an arcsecond.
The passes of one day
use Astronomy\Body; use Astronomy\Limb; use Astronomy\Place; use Astronomy\RiseSet; $madrid = new Place('Madrid', null, 'Spain', 'ES', 40.4165, -3.7026, 'Europe/Madrid'); $day = new DateTimeImmutable('2026-09-19', $madrid->timeZone()); $sun = RiseSet::ofTheDay(Body::Sun, $madrid, $day); echo $sun->rise->date->format('H:i:s T'), PHP_EOL; echo $sun->upperCulmination->date->format('H:i:s T'), PHP_EOL; echo $sun->set->date->format('H:i:s T'), PHP_EOL; echo $sun->rise->jdUt, PHP_EOL;
07:59:19 CEST 14:08:35 CEST 20:17:12 CEST 2461302.7495276
Every instant RiseSet and Horizon hand back is a UtInstant: a julian day in Universal Time and a clock reading in the place's own time zone, travelling together in the same object. That pairing exists because converting one into the other is exactly the step an hour slips into, and doing it once, in UtInstant::fromJd(), is the way not to repeat that mistake at every call site that wants to print a time.
ofTheDay() runs from local midnight to local midnight and returns all four passes in one call: rise, set, upperCulmination and lowerCulmination. For the Sun it also fills twilights, keyed by civil, nautical and astronomical, each with a sunrise and a dusk instant.
The limb, refraction, and the 0.833 degree convention
By default a rise or a set is the upper limb, with refraction: what an almanac publishes, the moment the top edge of the disc is seen touching the horizon.
use Astronomy\Body; use Astronomy\Limb; use Astronomy\Place; use Astronomy\RiseSet; $madrid = new Place('Madrid', null, 'Spain', 'ES', 40.4165, -3.7026, 'Europe/Madrid'); $day = new DateTimeImmutable('2026-09-19', $madrid->timeZone()); $sun = RiseSet::ofTheDay(Body::Sun, $madrid, $day); // The centre of the disc, with no atmosphere: the geometric instant. $geometric = RiseSet::ofTheDay(Body::Sun, $madrid, $day, Limb::Center, refraction: false); echo $sun->rise->secondsTo($geometric->rise), PHP_EOL;
265.05947560072
Four and a half minutes later, on this day at this latitude, than the geometric centre with no atmosphere. That gap is not one number: it is two, added together, and both come from the same convention as an almanac's. The Sun's upper limb needs its centre to sit 0.267 degrees below the horizon, which is one solar semidiameter (Horizon::semidiameter()); refraction at the horizon then lifts the apparent position by another 0.567 degrees (Horizon::refractionAtHorizon()). Added together that is the almanac's rule of thumb of 0.833 degrees below the horizon, Limb::Superior and refraction: true together are what apply it, and Limb::Center with refraction: false is what switches both off.
$semidiameter = \Astronomy\Horizon::semidiameter( \Astronomy\Horizon::equatorialOf(Body::Sun, \Astronomy\Time::tt($sun->rise->jdUt)), Body::Sun->radiusKm() ); $refraction = \Astronomy\Horizon::refractionAtHorizon(); printf("semidiameter %.4f + refraction %.4f = %.4f degrees\n", $semidiameter, $refraction, $semidiameter + $refraction);
semidiameter 0.2653 + refraction 0.5743 = 0.8396 degrees
Limb::Inferior is the third option: the lower limb, for the instant the whole disc has already cleared the horizon. For a planet or a star the three limbs coincide, because a point has no size to add.
Twilights do not move with the horizon
use Astronomy\Body; use Astronomy\Place; use Astronomy\RiseSet; $madrid = new Place('Madrid', null, 'Spain', 'ES', 40.4165, -3.7026, 'Europe/Madrid'); $day = new DateTimeImmutable('2026-09-19', $madrid->timeZone()); $flat = RiseSet::ofTheDay(Body::Sun, $madrid, $day); $behindARidge = RiseSet::ofTheDay(Body::Sun, $madrid, $day, horizonAltitude: 3.0); foreach (['civil', 'nautical', 'astronomical'] as $twilight) { printf( "%-13s flat horizon %s 3-degree ridge %s\n", $twilight, $flat->twilights[$twilight]['sunrise']->date->format('H:i:s'), $behindARidge->twilights[$twilight]['sunrise']->date->format('H:i:s') ); } printf("sunrise, flat horizon: %s behind the ridge: %s\n", $flat->rise->date->format('H:i:s'), $behindARidge->rise->date->format('H:i:s'));
civil flat horizon 07:32:05 3-degree ridge 07:32:05 nautical flat horizon 07:00:03 3-degree ridge 07:00:03 astronomical flat horizon 06:27:20 3-degree ridge 06:27:20 sunrise, flat horizon: 07:59:19 behind the ridge: 08:16:51
A ridge in front of the observer delays sunrise by seventeen minutes here and leaves every twilight exactly where it was. That is deliberate: a twilight is defined by how far the Sun has dropped below the astronomical horizon, which is a property of the sky, not of whatever happens to stand in front of the observer. The mountain hides the disc; it does not hide the light still painting the air above it.
A horizon that is not at zero
horizonAltitude takes degrees above the astronomical horizon and applies to the rise and the set (never to a twilight, for the reason above, and never to a meridian pass, which does not cross a horizon at all). Positive is a ridge in front of the observer; negative is standing above the surrounding ground, which is what Horizon::horizonDip() computes for a coastline seen from height.
use Astronomy\Body; use Astronomy\Horizon; use Astronomy\Pass; use Astronomy\Place; use Astronomy\RiseSet; use Astronomy\Time; $madrid = new Place('Madrid', null, 'Spain', 'ES', 40.4165, -3.7026, 'Europe/Madrid'); $jdUt = Time::julianDay(new DateTimeImmutable('2026-09-19 00:00:00', $madrid->timeZone())); $dip = Horizon::horizonDip(1000.0); printf("dip from 1000 m: %s\n", $dip); echo RiseSet::next(Body::Sun, $madrid, $jdUt, Pass::Rise)->date->format('H:i:s'), PHP_EOL; echo RiseSet::next(Body::Sun, $madrid, $jdUt, Pass::Rise, horizonAltitude: 3.0)->date->format('H:i:s'), PHP_EOL; echo RiseSet::next(Body::Sun, $madrid, $jdUt, Pass::Rise, horizonAltitude: $dip)->date->format('H:i:s'), PHP_EOL;
dip from 1000 m: -0.92119464062593 07:59:19 08:16:51 07:53:14
Horizon::horizonDip() comes back already negative, so it chains straight into horizonAltitude without anyone having to reason about the sign: an observer at a thousand metres sees the sea horizon 0.92 degrees below the astronomical one, and the Sun clears it six minutes sooner than it clears a flat horizon at sea level.
try { RiseSet::next(Body::Sun, $madrid, $jdUt, Pass::Rise, horizonAltitude: Horizon::horizonDip(4000.0)); } catch (\InvalidArgumentException $e) { echo $e->getMessage(), PHP_EOL; }
A horizon at -1.84 degrees cannot be corrected for refraction: the Bennett formula stops holding below -1.8, which is the dip of an observer at 3,100 metres. Ask for the pass without refraction.
That floor is not a guess: Bennett's refraction formula stops growing as the horizon dips past 1.8 degrees and then shrinks back towards zero near its own pole at -4.4, so below the floor it does not return a small refraction, it returns a wrong one. And 1.8 degrees of dip is what an observer at 3,100 metres sees, so the exception covers any mountain on Earth short of the very highest.
Turning a direction into where it is seen, and back
Horizon::equatorialOf() is the only place a Star becomes a direction, and it is what RiseSet, Occultations and Eclipses all call through: apparent right ascension and declination of date, with aberration and nutation applied, because the mean position of a star can sit twenty arcseconds from where it is actually seen. $horizon->at() carries a body, a star or a Position all the way to what is seen from one place at one instant; equatorialOf() plus ->topocentric() plus ->horizontal() is the same road taken one step at a time.
use Astronomy\Body; use Astronomy\Horizon; use Astronomy\Place; use Astronomy\Time; $madrid = new Place('Madrid', null, 'Spain', 'ES', 40.4165, -3.7026, 'Europe/Madrid'); $horizon = new Horizon($madrid); [$jdTT, $jdUt] = Time::fromClock(new DateTimeImmutable('2026-09-19 18:00:00', $madrid->timeZone())); $geocentric = Horizon::equatorialOf(Body::Venus, $jdTT); $topocentric = $horizon->topocentric($geocentric, $jdUt); $view = $horizon->horizontal($topocentric, $jdUt); printf("Venus: altitude %.4f apparent %.4f azimuth %.4f\n", $view->altitude, $view->apparentAltitude, $view->azimuth);
Venus: altitude 26.7793 apparent 26.8112 azimuth 205.5443
$horizon->equatorialFromHorizontal() is the way back, and it is written as the transpose of the rotation horizontal() does rather than as a formula copied from a manual, which guarantees the two cancel exactly instead of merely looking like they should. The altitude it takes is the true one, not the apparent one, and mixing them up costs half a degree right at the horizon:
$back = $horizon->equatorialFromHorizontal($view->azimuth, $view->altitude, $jdUt, $topocentric->distanceKm); printf( "round trip with the TRUE altitude: ra error %.2e deg, dec error %.2e deg\n", abs($back->rightAscension - $topocentric->rightAscension), abs($back->declination - $topocentric->declination) ); $wrong = $horizon->equatorialFromHorizontal($view->azimuth, $view->apparentAltitude, $jdUt, $topocentric->distanceKm); printf( "round trip with the APPARENT altitude by mistake: dec error %.2f arcsec\n", abs($wrong->declination - $topocentric->declination) * 3600.0 );
round trip with the TRUE altitude: ra error 2.84e-14 deg, dec error 7.11e-15 deg round trip with the APPARENT altitude by mistake: dec error 107.71 arcsec
Horizontal carries both altitudes under different names, altitude and apparentAltitude, precisely so this choice has to be made knowingly rather than by whichever field happens to be at hand.
Polar day and polar night
A body that never crosses the horizon on a given day has no rise and no set, and RiseSet says so with null rather than inventing a number. The two meridian passes always exist, because a meridian crossing does not depend on the horizon at all.
use Astronomy\Body; use Astronomy\Place; use Astronomy\RiseSet; $longyearbyen = new Place('Longyearbyen', null, 'Norway', 'NO', 78.2232, 15.6267, 'Arctic/Longyearbyen'); $midsummer = RiseSet::ofTheDay(Body::Sun, $longyearbyen, new DateTimeImmutable('2026-06-21', $longyearbyen->timeZone())); $midwinter = RiseSet::ofTheDay(Body::Sun, $longyearbyen, new DateTimeImmutable('2026-12-21', $longyearbyen->timeZone())); printf( "midsummer: rise %s, set %s, upper culmination %s\n", $midsummer->rise === null ? 'null (polar day)' : $midsummer->rise->date->format('H:i'), $midsummer->set === null ? 'null (polar day)' : $midsummer->set->date->format('H:i'), $midsummer->upperCulmination->date->format('H:i') ); printf( "midwinter: rise %s, set %s, upper culmination %s\n", $midwinter->rise === null ? 'null (polar night)' : $midwinter->rise->date->format('H:i'), $midwinter->set === null ? 'null (polar night)' : $midwinter->set->date->format('H:i'), $midwinter->upperCulmination->date->format('H:i') );
midsummer: rise null (polar day), set null (polar day), upper culmination 12:59 midwinter: rise null (polar night), set null (polar night), upper culmination 11:55
The same holds for any circumpolar star, or for one that never clears a given latitude at all: Vega never sets seen from Oslo and never rises seen from Ushuaia, and both come back null.
The solved alternative
RiseSet::solvedOfTheDay() and solvedPass() answer the same question by a different road: instead of tracking the whole day and walking the altitude for sign changes, they solve the closed form for a point with no disc, cos H = -tan φ · tan δ, and correct it by fixed point for the disc and the atmosphere. It costs about a third of ofTheDay(), because the saving is in how many ephemerides are asked for, twelve positions instead of thirty samples and a scan, and it agrees with the tracker to 0.038 seconds of clock time over 360 passes checked against it.
What it gives up is worth knowing before reaching for it: a day with two passes of the same kind still returns one, exactly like ofTheDay(), the twilights are not filled in, and a body whose right ascension would have to run as fast as the Earth turns throws instead of returning a number nobody could trust. It pays off where many passes are wanted and the geometry is enough, which is why the Gauquelin sectors in houses use it: three passes per sector, computed directly, rather than two whole tracked days.
RiseSet is also what eclipses and occultations reach for whenever the body they are following sets or rises partway through the event: the local circumstances of both borrow it rather than repeating a search for a horizon crossing.