Add rise/set and constellation guides, remove stale TODO

Two new workflow guides covering v0.13.0–v0.15.0 features:
- Rise/set prediction: geometric vs refracted, NULL contract, extreme latitudes
- Constellation identification: composability chains, catalog enrichment, zodiac

Remove docs/TODO-v0.10.0.md (5 versions stale, all Tier 1–2 items shipped).
This commit is contained in:
Ryan Malloy 2026-02-26 12:29:45 -07:00
parent e4589715bd
commit e670ed7ed1
4 changed files with 517 additions and 138 deletions

View File

@ -1,138 +0,0 @@
# pg_orrery — Post-v0.10.0 Roadmap
## Current State
**Version:** v0.10.0 (tagged 2026-02-22)
**Branch:** `phase/spgist-orbital-trie` merged to `main`
**Functions:** 114 SQL functions, 9 custom types, 19 test suites, 1 new operator
**Docs:** https://pg-orrery.warehack.ing
### What v0.10.0 shipped
- Annual stellar aberration in all `_apparent()` functions (~20 arcsec)
- 6 new `_apparent_de()` variants with VSOP87 fallback
- `eq_angular_distance()` + `eq_within_cone()` + `<->` operator on equatorial
- Stellar annual parallax in `star_observe_pm()` / `star_equatorial_pm()`
### Astrolock integration status
Thread: `docs/agent-threads/v090-astrolock-upgrade/`
- v0.9.0 fully deployed to both local and production servers
- v0.10.0 upgrade path communicated (message 003)
- Pending their upgrade — aberration improvement is automatic
## Remaining Housekeeping
- [x] Merge `phase/spgist-orbital-trie` to `main`
- [ ] Clean up `bench/` — gitignore the untracked TLE catalog files (alpha5, celestrak, satnogs, spacetrack, supgp, tle_api, merged, mega)
- [ ] Update "From Skyfield" workflow page for v0.9.0/v0.10.0 RA/Dec + aberration parity
- [ ] Add timing numbers for equatorial, refraction, aberration functions to benchmarks page
- [ ] Update CLAUDE.md function count: 106 -> 114, test suites: 18 -> 19
- [ ] Update docs llms.txt and llms-full.txt for v0.10.0 functions
## Feature Candidates — Next Version
### Tier 1 — High value, low effort
#### A. `make_orbital_elements()` constructor
**Requested by:** astrolock-api (message 002, question 1)
SQL constructor from 9 floats. Lets users compose orbital_elements from individual table columns without `format()`/cast workaround.
```sql
make_orbital_elements(epoch_jd, q_au, e, inc_rad, omega_rad, node_rad, tp_jd, h_mag, g_slope)
-> orbital_elements
```
Complexity: ~30 lines in `orbital_elements_type.c`. One new function.
#### B. `galilean_equatorial()` and moon family equatorial functions
**Requested by:** astrolock-api (message 002, question 2)
Geocentric RA/Dec for planetary moons. Follows `planet_equatorial()` pattern — convert geocentric ecliptic position to equatorial J2000, precess to date.
New functions (~4):
- `galilean_equatorial(int4, timestamptz) -> equatorial`
- `saturn_moon_equatorial(int4, timestamptz) -> equatorial`
- `uranus_moon_equatorial(int4, timestamptz) -> equatorial`
- `mars_moon_equatorial(int4, timestamptz) -> equatorial`
Plus DE variants (~4 more).
Complexity: ~100 lines. Follows established pattern.
#### C. GiST/SP-GiST index on equatorial type
The `<->` operator and `eq_within_cone()` exist but have no index support. For cone-search queries over large catalogs, an index would enable:
```sql
-- Indexed: "what's within 10 deg of Jupiter?"
SELECT * FROM star_catalog
WHERE position <-> planet_equatorial(5, NOW()) < 10.0;
```
Approach: GiST with bounding-box approximation in RA/Dec space, or SP-GiST with HEALPix-style recursive decomposition.
Complexity: Medium (~300-500 lines). The SP-GiST infrastructure from TLE index is reusable.
### Tier 2 — Medium value, medium risk
#### D. Nutation correction (~9 arcsec)
IAU 1980 nutation (106 terms) or simplified IAU 2000B.
Currently: TEME uses 4 of 106 terms. Equatorial output uses IAU 1976 precession only (no nutation).
Value: ~9 arcsec correction in equatorial coordinates. Matters for sub-arcminute accuracy — telescope GoTo mounts and catalog cross-matching.
Scope: New `nutation.c` + modify `precess_j2000_to_date()` to include nutation matrix.
Risk: Touches the precession pipeline used by every equatorial function.
#### E. Delta T (TDB - UTC)
The "affects everything" change. Currently all time is treated as UTC with no TT/TDB distinction.
Requires IERS lookup table or polynomial approximation (Espenak & Meeus 2006).
Scope: Touch `sidereal_time.h`, propagation pipelines, all observation functions.
Risk: High — affects every time conversion. Needs careful regression testing.
Value: Improves accuracy for historical epochs (pre-2000) and future predictions (post-2030).
Already noted as deferred at `sidereal_time.h:22-26`.
#### F. Rise/set prediction for solar system objects
Like `predict_passes()` but for planets, Sun, and Moon. Binary search for horizon crossings.
Use cases: sunrise/sunset, moonrise/moonset, planet visibility windows.
Complexity: Medium. The pass prediction binary search machinery exists but needs adaptation for much slower angular rates.
### Tier 3 — Future / deferred
- **Perturbed asteroid propagation** — secular perturbation terms for orbital_elements (currently two-body Keplerian)
- **Eclipse prediction** — Moon shadow cone intersection with observer
- **Satellite sunlit visibility** — extend `pass_visible()` with Earth shadow geometry
- **Constellation identification** — equatorial position to IAU constellation boundary lookup
- **Coordinate frame transforms** — ICRS/FK5/galactic/ecliptic conversion functions
## Suggested Next Phase
```
Housekeeping (bench cleanup, docs, CLAUDE.md)
|
v
Feature A: make_orbital_elements() — 30 lines, unblocks Craft comets
Feature B: moon family equatorial — 100 lines, unblocks Craft Galilean moons
|
v
Feature C: equatorial GiST index — enables indexed cone search
Feature D: nutation — closes largest remaining accuracy gap (~9 arcsec)
|
v
Feature E: Delta T — high risk, needs its own careful phase
Feature F: rise/set — new domain, independent
```
A + B could ship as v0.11.0. C + D as v0.12.0.
## Verification
- All 19 existing regression suites must continue to pass
- New test suites for each feature
- PG 14-18 matrix (`make test-matrix`)
- Cross-check against JPL Horizons for nutation accuracy
- Astrolock integration smoke test after each db upgrade

View File

@ -72,6 +72,8 @@ export default defineConfig({
{ label: "Orbit Determination", slug: "guides/orbit-determination" },
{ label: "Satellite Pass Prediction", slug: "guides/pass-prediction" },
{ label: "Building TLE Catalogs", slug: "guides/catalog-management" },
{ label: "Rise/Set Prediction", slug: "guides/rise-set-prediction" },
{ label: "Constellation Identification", slug: "guides/constellation-identification" },
],
},
{

View File

@ -0,0 +1,206 @@
---
title: Constellation Identification
sidebar:
order: 13
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
pg_orrery identifies which of the 88 IAU constellations contains a given sky position. You pass an equatorial coordinate (or raw RA and Dec values) and get back a three-letter IAU abbreviation. A companion function expands abbreviations to full names. The boundary lookup uses the Roman (1987) definitive boundary table, with internal precession from J2000 to the B1875.0 epoch in which the boundaries were originally defined.
## How you do it today
Determining which constellation an object is in usually involves:
- **Stellarium**: Click on an object, read the constellation from the info panel. One object at a time, not scriptable.
- **Astropy + regions**: Load the IAU constellation boundary polygons, precess coordinates to B1875.0, and run a point-in-polygon test. Correct, but requires assembling coordinate transforms and boundary data yourself.
- **SIMBAD/CDS**: Query by object name and the constellation is in the metadata. Only works for cataloged objects, not arbitrary coordinates.
- **Manual lookup**: Find the RA/Dec on a star chart and visually identify the constellation. Error-prone near boundaries.
The constraint is always the same: the boundary data and the precession step live outside your database. If your star catalog, observation log, or scheduling system is in PostgreSQL, you export coordinates, look up constellations externally, and import the labels.
## What changes with pg_orrery
Three functions handle constellation identification:
| Function | Returns | What it does |
|---|---|---|
| `constellation(equatorial)` | `text` (3-letter IAU code) | Identifies constellation from an equatorial coordinate |
| `constellation(ra_hours, dec_deg)` | `text` (3-letter IAU code) | Convenience overload for raw J2000 RA (hours) and Dec (degrees) |
| `constellation_full_name(abbrev)` | `text` (full name or NULL) | Expands a 3-letter abbreviation to the full IAU name |
The first overload accepts the `equatorial` type returned by any of pg_orrery's equatorial functions -- `planet_equatorial()`, `sun_equatorial()`, `star_equatorial_pm()`, and so on. This makes the constellation lookup composable with the rest of the observation pipeline.
The second overload takes raw RA in hours [0, 24) and Dec in degrees [-90, 90]. Use it for catalog data stored as individual columns.
Both `constellation()` overloads precess the input J2000 coordinates to B1875.0 internally before testing against the ~357 boundary segments from the Roman (1987) table (CDS catalog VI/42). This precession is necessary because the IAU constellation boundaries were defined at epoch B1875.0.
All three functions are `IMMUTABLE` -- the Roman boundary data is compiled into the extension, so there are no external dependencies. This means they are safe for use in indexes, generated columns, and materialized views.
## What pg_orrery does not replace
<Aside type="caution" title="Boundary precision">
The Roman boundary table defines constellation boundaries as line segments in RA and Dec at epoch B1875.0. For positions very close to a boundary (within a few arcseconds), the linear scan may disagree with higher-precision boundary implementations that account for the curved nature of precession over short segments.
</Aside>
- **Not a substitute for detailed boundary analysis.** Objects within a few arcseconds of a constellation boundary may land on different sides depending on the precession model used. For critical applications (e.g., variable star designations), consult the CDS boundary data directly.
- **No constellation figures or asterisms.** The function returns the IAU region that contains the coordinate, not any information about the traditional figure or its stars.
- **88 constellations only.** The function returns the standard IAU three-letter abbreviation. Historical constellations (Argo Navis, Quadrans Muralis) are not represented.
## Try it
### Which constellation is Jupiter in?
The simplest case -- combine `planet_equatorial()` with `constellation()`:
```sql
SELECT constellation(planet_equatorial(5, '2025-06-15 04:00:00+00')) AS jupiter_constellation;
```
This returns a three-letter IAU abbreviation like `Tau` or `Gem`, depending on where Jupiter is at the specified time.
### Full composability chain
Chain the equatorial, constellation, and full-name functions together to produce a readable result:
```sql
SELECT constellation_full_name(
constellation(
planet_equatorial(5, '2025-06-15 04:00:00+00')
)
) AS jupiter_in;
```
This returns the full name -- something like `Taurus` or `Gemini`. The three functions nest cleanly because each takes the output of the previous one.
### All planets and their constellations
Sweep all seven visible planets at a given time:
```sql
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,
constellation(planet_equatorial(body_id, '2025-06-15 04:00:00+00')) AS abbrev,
constellation_full_name(
constellation(planet_equatorial(body_id, '2025-06-15 04:00:00+00'))
) AS constellation
FROM generate_series(1, 8) AS body_id
WHERE body_id != 3 -- cannot observe Earth from Earth
ORDER BY body_id;
```
Seven rows, one query. Each planet gets its constellation abbreviation and full name.
### Star catalog enrichment
Add a constellation column to a catalog table using the raw-coordinate overload:
```sql
-- Existing star catalog
CREATE TABLE star_catalog (
hip_id integer PRIMARY KEY,
name text,
ra_hours float8 NOT NULL,
dec_deg float8 NOT NULL,
vmag float8
);
INSERT INTO star_catalog VALUES
(11767, 'Polaris', 2.5303, 89.264, 1.98),
(32349, 'Sirius', 6.7525, -16.716, -1.46),
(27989, 'Betelgeuse', 5.9195, 7.407, 0.42),
(91262, 'Vega', 18.6156, 38.784, 0.03);
-- Query with constellation identification
SELECT name,
vmag,
constellation(ra_hours, dec_deg) AS iau_abbrev,
constellation_full_name(constellation(ra_hours, dec_deg)) AS constellation
FROM star_catalog
ORDER BY vmag;
```
Sirius returns `CMa` (Canis Major), Betelgeuse returns `Ori` (Orion), Vega returns `Lyr` (Lyra), and Polaris returns `UMi` (Ursa Minor). Because `constellation()` is `IMMUTABLE`, you could also store the result in a generated column:
```sql
ALTER TABLE star_catalog
ADD COLUMN iau_constellation text
GENERATED ALWAYS AS (constellation(ra_hours, dec_deg)) STORED;
```
### Sun through the zodiac
The Sun's constellation changes roughly once a month as it moves along the ecliptic. Sample at monthly intervals to see the progression:
```sql
SELECT t::date AS date,
constellation(sun_equatorial(t)) AS abbrev,
constellation_full_name(constellation(sun_equatorial(t))) AS constellation
FROM generate_series(
'2025-01-15'::timestamptz,
'2025-12-15'::timestamptz,
interval '1 month'
) AS t;
```
The Sun passes through 13 constellations over the course of a year -- the 12 traditional zodiac constellations plus Ophiuchus, which the ecliptic crosses between Scorpius and Sagittarius. The IAU boundaries do not match the astrological 30-degree divisions, so the Sun spends significantly different amounts of time in each constellation.
### What constellation is Polaris in?
Using the `(ra_hours, dec_deg)` overload directly with known coordinates:
```sql
SELECT constellation(2.5303, 89.264) AS polaris_abbrev,
constellation_full_name(constellation(2.5303, 89.264)) AS polaris_constellation;
```
This returns `UMi` and `Ursa Minor`. The raw-coordinate overload is useful when you have RA and Dec values from an external source and do not need to construct an `equatorial` type first.
### Full name display for UI
A common pattern for user-facing output: show the abbreviation alongside the full name.
```sql
WITH stars(name, ra_h, dec_d) AS (VALUES
('Polaris', 2.5303, 89.264),
('Sirius', 6.7525, -16.716),
('Betelgeuse', 5.9195, 7.407),
('Vega', 18.6156, 38.784)
)
SELECT name,
constellation(ra_h, dec_d)
|| ' (' || constellation_full_name(constellation(ra_h, dec_d)) || ')'
AS constellation_display
FROM stars;
```
This produces strings like `CMa (Canis Major)` and `Ori (Orion)`. The `constellation_full_name()` function returns NULL for unrecognized abbreviations, so if you are working with external data, wrap the concatenation in a `COALESCE` or check for NULL:
```sql
SELECT COALESCE(
constellation_full_name('XYZ'),
'Unknown'
) AS result;
-- Returns: Unknown
```
### Moon and Sun constellations over a week
Track where the Moon and Sun are relative to the constellations:
```sql
SELECT t::date AS date,
constellation(sun_equatorial(t)) AS sun_in,
constellation(moon_equatorial(t)) AS moon_in
FROM generate_series(
'2025-03-01'::timestamptz,
'2025-03-07'::timestamptz,
interval '1 day'
) AS t;
```
The Moon moves roughly 13 degrees per day, crossing a constellation boundary every two to three days. The Sun barely moves -- it stays in the same constellation for the entire week.

View File

@ -0,0 +1,309 @@
---
title: Rise/Set Prediction
sidebar:
order: 12
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
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.
## How you do it today
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.
## What changes with pg_orrery
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:
| Tier | Functions | Threshold | Version |
|---|---|---|---|
| Geometric | `sun_next_rise`, `sun_next_set`, `moon_next_rise`, `moon_next_set`, `planet_next_rise`, `planet_next_set` | 0.0 deg | v0.13.0 |
| Refracted | `sun_next_rise_refracted`, `sun_next_set_refracted`, `moon_next_rise_refracted`, `moon_next_set_refracted`, `planet_next_rise_refracted`, `planet_next_set_refracted` | varies | v0.14.0 |
| Diagnostic | `sun_rise_set_status`, `moon_rise_set_status`, `planet_rise_set_status` | -- | v0.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.
### Refraction thresholds
Refracted functions use the same thresholds as the USNO and most almanacs:
| Body | Threshold | Why |
|---|---|---|
| Sun | -0.833 deg | 34 arcmin atmospheric refraction + 16 arcmin solar semidiameter |
| Moon | -0.833 deg | 34 arcmin refraction + ~15.5 arcmin mean lunar semidiameter |
| Planets | -0.569 deg | 34 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.
## What pg_orrery does not replace
<Aside type="caution" title="Analytic ephemeris, not almanac precision">
Rise/set times are computed from the VSOP87 and ELP2000-82B analytic theories (~1 arcsecond for planets, ~10 arcseconds for the Moon). The bisection precision is 0.1 seconds, but the underlying positional accuracy limits the practical accuracy to roughly 1-2 seconds for planets and up to 30 seconds for the Moon near extreme latitudes.
</Aside>
- **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.
## Try it
### Basic sunrise and sunset
The simplest rise/set query. Eagle, Idaho on the 2024 summer solstice:
```sql
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.
### Geometric vs refracted
Atmospheric refraction lifts the Sun's apparent position near the horizon. Refracted sunrise is earlier; refracted sunset is later:
<Tabs>
<TabItem label="Sun">
```sql
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.
</TabItem>
<TabItem label="Moon">
```sql
SELECT moon_next_rise('43.7N 116.4W 800m'::observer,
'2024-06-21 12:00:00+00') AS geometric_rise,
moon_next_rise_refracted('43.7N 116.4W 800m'::observer,
'2024-06-21 12:00:00+00') AS refracted_rise,
moon_next_rise('43.7N 116.4W 800m'::observer,
'2024-06-21 12:00:00+00')
- moon_next_rise_refracted('43.7N 116.4W 800m'::observer,
'2024-06-21 12:00:00+00') AS difference;
```
The Moon uses the same -0.833 deg threshold as the Sun because its mean semidiameter is nearly identical.
</TabItem>
<TabItem label="Jupiter">
```sql
SELECT planet_next_rise(5, '43.7N 116.4W 800m'::observer,
'2024-06-21 12:00:00+00') AS geometric_rise,
planet_next_rise_refracted(5, '43.7N 116.4W 800m'::observer,
'2024-06-21 12:00:00+00') AS refracted_rise,
planet_next_rise(5, '43.7N 116.4W 800m'::observer,
'2024-06-21 12:00:00+00')
- planet_next_rise_refracted(5, '43.7N 116.4W 800m'::observer,
'2024-06-21 12:00:00+00') AS difference;
```
Planets use a shallower threshold (-0.569 deg) because they are point sources -- no semidiameter to add.
</TabItem>
</Tabs>
### What is visible tonight?
Sweep all planets and check which ones rise before midnight local time:
```sql
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.
### Extreme latitude: midnight sun
At 70 degrees north during the June solstice, the Sun never sets. Both rise/set functions express this by returning NULL:
```sql
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.
<Aside type="note" title="The NULL contract">
All rise/set functions return NULL when the body does not cross the horizon within the 7-day search window. Three scenarios produce NULL:
- **Circumpolar** -- the body is always above the horizon (midnight sun, circumpolar stars).
- **Never rises** -- the body is always below the horizon (polar night at high latitudes in winter).
- **Slow-moving body near threshold** -- rare, but a planet near the celestial pole can hover within a degree of the horizon for days without cleanly crossing.
Use `sun_rise_set_status()`, `moon_rise_set_status()`, or `planet_rise_set_status()` to distinguish these cases. Each returns one of three strings: `'rises_and_sets'`, `'circumpolar'`, or `'never_rises'`.
</Aside>
### Polar night
The inverse case. At 70 degrees north during the December solstice, the Sun never rises:
```sql
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'`.
### Observation window planning
How long can you observe Jupiter tonight? Compute rise-to-set duration:
```sql
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.
### Moonrise for the next week
Generate a daily moonrise table using `generate_series`:
```sql
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.
### All rise/set events for one night
Combine Sun, Moon, and all visible planets into a single timeline:
```sql
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.
### Diagnosing NULL results across all bodies
When planning observations at extreme latitudes, check every body's status at once:
```sql
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.