diff --git a/docs/TODO-v0.10.0.md b/docs/TODO-v0.10.0.md deleted file mode 100644 index 8633541..0000000 --- a/docs/TODO-v0.10.0.md +++ /dev/null @@ -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 diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 50104af..918ba54 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -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" }, ], }, { diff --git a/docs/src/content/docs/guides/constellation-identification.mdx b/docs/src/content/docs/guides/constellation-identification.mdx new file mode 100644 index 0000000..01c980a --- /dev/null +++ b/docs/src/content/docs/guides/constellation-identification.mdx @@ -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 + + + +- **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. diff --git a/docs/src/content/docs/guides/rise-set-prediction.mdx b/docs/src/content/docs/guides/rise-set-prediction.mdx new file mode 100644 index 0000000..3ab20a8 --- /dev/null +++ b/docs/src/content/docs/guides/rise-set-prediction.mdx @@ -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 + + + +- **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: + + + + ```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. + + + ```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. + + + ```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. + + + +### 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. + + + +### 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.