Skip to content

Rise/Set Prediction

pg_orrery computes rise and set times for the Sun, Moon, and all eight planets. You pass an observer and a starting timestamp, and get back a timestamptz for the next crossing of the horizon. Refracted variants account for atmospheric bending, matching what you actually see. Status diagnostics explain why a body might not cross the horizon at all.

Finding when things rise and set involves a few approaches:

  • Stellarium: Shows rise/set times in the object info panel. One object at a time, not scriptable or queryable from your database.
  • Skyfield: Computes rise/set events by searching for horizon crossings. Well-designed API, but finding events for many bodies across many days means writing nested loops.
  • Astropy + astroplan: The Observer class computes rise/set/transit times. Handles refraction and horizon altitude. Per-object Python calls; batch queries over a table of targets need iteration.
  • JPL Horizons: Outputs rise/transit/set tables as part of its ephemeris service. One request per body, rate-limited, results live outside your database.

The common pattern: compute rise/set times externally, then import the results into your scheduling table or observation log. If you want to answer “which planets are visible tonight?” in a single SQL query, you assemble the answer from pieces.

Rise and set times are SQL function calls. The functions search forward from a given timestamp using bisection (0.1-second precision) adapted from the satellite pass prediction algorithm. Three tiers cover different needs:

TierFunctionsThresholdVersion
Geometricsun_next_rise, sun_next_set, moon_next_rise, moon_next_set, planet_next_rise, planet_next_set0.0 degv0.13.0
Refractedsun_next_rise_refracted, sun_next_set_refracted, moon_next_rise_refracted, moon_next_set_refracted, planet_next_rise_refracted, planet_next_set_refractedvariesv0.14.0
Diagnosticsun_rise_set_status, moon_rise_set_status, planet_rise_set_statusv0.15.0

All functions are STABLE STRICT PARALLEL SAFE. The search window is 7 days. If the body does not cross the threshold within that window, the function returns NULL.

Refracted functions use the same thresholds as the USNO and most almanacs:

BodyThresholdWhy
Sun-0.833 deg34 arcmin atmospheric refraction + 16 arcmin solar semidiameter
Moon-0.833 deg34 arcmin refraction + ~15.5 arcmin mean lunar semidiameter
Planets-0.569 deg34 arcmin refraction only (point sources — even Jupiter at opposition subtends just 0.4 arcmin)

The difference between geometric and refracted sunrise is typically 2-5 minutes. At extreme latitudes near the solstices, the difference can be much larger because the Sun’s path intersects the horizon at a shallow angle.

  • No topographic horizon. All thresholds assume a flat, unobstructed horizon at sea level. Mountains, buildings, and terrain features are not considered.
  • No civil/nautical/astronomical twilight. The functions compute when the Sun’s center crosses the specified threshold, not when it reaches -6, -12, or -18 degrees. You can approximate these by sampling topo_elevation(sun_observe(...)) at the desired threshold.
  • No lunar limb correction. Moon rise/set uses a fixed mean semidiameter (15.5 arcmin). The actual semidiameter varies by about 1.5 arcmin between perigee and apogee, introducing up to ~15 seconds of timing error.
  • No UT1-UTC correction. Times are in UTC. The difference from UT1 (which governs the actual rotation of the Earth) is at most 0.9 seconds.

The simplest rise/set query. Eagle, Idaho on the 2024 summer solstice:

SELECT sun_next_rise('43.7N 116.4W 800m'::observer,
'2024-06-21 12:00:00+00') AS sunrise,
sun_next_set('43.7N 116.4W 800m'::observer,
'2024-06-21 12:00:00+00') AS sunset;

The observer format is lat lon altitude — latitude as degrees with N/S suffix, longitude with E/W suffix, altitude in meters. The start time should be before the expected event. Starting at noon UTC (6am MDT) catches the next sunrise and sunset for a Mountain Time observer.

Atmospheric refraction lifts the Sun’s apparent position near the horizon. Refracted sunrise is earlier; refracted sunset is later:

SELECT sun_next_rise('43.7N 116.4W 800m'::observer,
'2024-06-21 12:00:00+00') AS geometric_rise,
sun_next_rise_refracted('43.7N 116.4W 800m'::observer,
'2024-06-21 12:00:00+00') AS refracted_rise,
sun_next_rise('43.7N 116.4W 800m'::observer,
'2024-06-21 12:00:00+00')
- sun_next_rise_refracted('43.7N 116.4W 800m'::observer,
'2024-06-21 12:00:00+00') AS difference;

The difference is typically 2-5 minutes for mid-latitude locations. This is why newspaper sunrise times differ from the geometric horizon crossing.

Sweep all planets and check which ones rise before midnight local time:

WITH obs AS (SELECT '43.7N 116.4W 800m'::observer AS o),
t0 AS (SELECT '2024-06-21 12:00:00+00'::timestamptz AS t)
SELECT CASE body_id
WHEN 1 THEN 'Mercury' WHEN 2 THEN 'Venus'
WHEN 4 THEN 'Mars' WHEN 5 THEN 'Jupiter'
WHEN 6 THEN 'Saturn' WHEN 7 THEN 'Uranus'
WHEN 8 THEN 'Neptune'
END AS planet,
planet_next_rise_refracted(body_id, obs.o, t0.t) AS rises,
planet_next_set_refracted(body_id, obs.o, t0.t) AS sets
FROM generate_series(1, 8) AS body_id,
obs, t0
WHERE body_id != 3 -- cannot observe Earth from Earth
AND planet_next_rise_refracted(body_id, obs.o, t0.t) IS NOT NULL
ORDER BY planet_next_rise_refracted(body_id, obs.o, t0.t);

Body IDs follow the VSOP87 convention: 1=Mercury through 8=Neptune. Body 3 (Earth) and body 0 (Sun) are invalid for planet_next_rise and will raise an error.

At 70 degrees north during the June solstice, the Sun never sets. Both rise/set functions express this by returning NULL:

SELECT sun_next_rise('70.0N 25.0E 10m'::observer,
'2024-06-21 12:00:00+00') AS sunrise,
sun_next_set('70.0N 25.0E 10m'::observer,
'2024-06-21 12:00:00+00') AS sunset,
sun_rise_set_status('70.0N 25.0E 10m'::observer,
'2024-06-21 12:00:00+00') AS status;

The sunrise column will have a value (the Sun is up and will “rise” again after the brief polar dip near midnight), but sunset returns NULL. The status function returns 'circumpolar', explaining why.

The inverse case. At 70 degrees north during the December solstice, the Sun never rises:

SELECT sun_next_rise('70.0N 25.0E 10m'::observer,
'2024-12-21 12:00:00+00') AS sunrise,
sun_next_set('70.0N 25.0E 10m'::observer,
'2024-12-21 12:00:00+00') AS sunset,
sun_rise_set_status('70.0N 25.0E 10m'::observer,
'2024-12-21 12:00:00+00') AS status;

Here sunrise is NULL, sunset may also be NULL (Sun is already below the horizon), and status returns 'never_rises'.

How long can you observe Jupiter tonight? Compute rise-to-set duration:

WITH obs AS (SELECT '43.7N 116.4W 800m'::observer AS o),
t0 AS (SELECT '2024-06-21 12:00:00+00'::timestamptz AS t),
events AS (
SELECT planet_next_rise_refracted(5, obs.o, t0.t) AS jupiter_rise,
planet_next_set_refracted(5, obs.o, t0.t) AS jupiter_set
FROM obs, t0
)
SELECT jupiter_rise,
jupiter_set,
jupiter_set - jupiter_rise AS observation_window
FROM events
WHERE jupiter_rise IS NOT NULL
AND jupiter_set IS NOT NULL
AND jupiter_set > jupiter_rise;

The WHERE jupiter_set > jupiter_rise clause handles the case where the body is already above the horizon — the next set comes before the next rise. In that case, you would swap the logic: observe from now until the next set.

Generate a daily moonrise table using generate_series:

SELECT day::date AS date,
moon_next_rise_refracted('43.7N 116.4W 800m'::observer, day) AS moonrise
FROM generate_series(
'2024-06-21 12:00:00+00'::timestamptz,
'2024-06-28 12:00:00+00'::timestamptz,
interval '1 day'
) AS day;

The Moon’s orbital period is about 24 hours and 50 minutes, so moonrise drifts later by roughly 50 minutes each day. Some days may show NULL if the Moon does not rise during the search window starting from noon UTC.

Combine Sun, Moon, and all visible planets into a single timeline:

WITH obs AS (SELECT '43.7N 116.4W 800m'::observer AS o),
t0 AS (SELECT '2024-06-21 12:00:00+00'::timestamptz AS t),
events AS (
SELECT 'Sun' AS body, 'rise' AS event,
sun_next_rise_refracted(obs.o, t0.t) AS time
FROM obs, t0
UNION ALL
SELECT 'Sun', 'set',
sun_next_set_refracted(obs.o, t0.t)
FROM obs, t0
UNION ALL
SELECT 'Moon', 'rise',
moon_next_rise_refracted(obs.o, t0.t)
FROM obs, t0
UNION ALL
SELECT 'Moon', 'set',
moon_next_set_refracted(obs.o, t0.t)
FROM obs, t0
UNION ALL
SELECT CASE body_id
WHEN 1 THEN 'Mercury' WHEN 2 THEN 'Venus'
WHEN 4 THEN 'Mars' WHEN 5 THEN 'Jupiter'
WHEN 6 THEN 'Saturn' WHEN 7 THEN 'Uranus'
WHEN 8 THEN 'Neptune'
END,
'rise',
planet_next_rise_refracted(body_id, obs.o, t0.t)
FROM generate_series(1, 8) AS body_id, obs, t0
WHERE body_id != 3
UNION ALL
SELECT CASE body_id
WHEN 1 THEN 'Mercury' WHEN 2 THEN 'Venus'
WHEN 4 THEN 'Mars' WHEN 5 THEN 'Jupiter'
WHEN 6 THEN 'Saturn' WHEN 7 THEN 'Uranus'
WHEN 8 THEN 'Neptune'
END,
'set',
planet_next_set_refracted(body_id, obs.o, t0.t)
FROM generate_series(1, 8) AS body_id, obs, t0
WHERE body_id != 3
)
SELECT body, event, time
FROM events
WHERE time IS NOT NULL
ORDER BY time;

This produces a chronological timeline of every rise and set event for the Sun, Moon, and all seven visible planets from Eagle, Idaho. NULL events (circumpolar or never-rising bodies) are filtered out.

When planning observations at extreme latitudes, check every body’s status at once:

WITH obs AS (SELECT '70.0N 25.0E 10m'::observer AS o),
t0 AS (SELECT '2024-06-21 12:00:00+00'::timestamptz AS t)
SELECT 'Sun' AS body,
sun_rise_set_status(obs.o, t0.t) AS status
FROM obs, t0
UNION ALL
SELECT 'Moon',
moon_rise_set_status(obs.o, t0.t)
FROM obs, t0
UNION ALL
SELECT CASE body_id
WHEN 1 THEN 'Mercury' WHEN 2 THEN 'Venus'
WHEN 4 THEN 'Mars' WHEN 5 THEN 'Jupiter'
WHEN 6 THEN 'Saturn' WHEN 7 THEN 'Uranus'
WHEN 8 THEN 'Neptune'
END,
planet_rise_set_status(body_id, obs.o, t0.t)
FROM generate_series(1, 8) AS body_id, obs, t0
WHERE body_id != 3
ORDER BY body;

At 70 degrees north on the summer solstice, the Sun is circumpolar. Some planets may also be circumpolar or never-rising depending on their current declination. The status functions classify each body with a single 24-hour scan (48 samples at 30-minute spacing), so this query is lightweight.