Merge phase/spgist-orbital-trie: v0.7.0 through v0.10.0

SP-GiST orbital trie (v0.7.0), orbital_elements type + MPC parser (v0.8.0),
equatorial type + refraction + proper motion + light-time (v0.9.0),
aberration + DE apparent + cone search + stellar parallax (v0.10.0).

82 -> 114 SQL functions, 8 -> 9 custom types, 14 -> 19 test suites.
This commit is contained in:
Ryan Malloy 2026-02-21 21:49:16 -07:00
commit 5a6c50c68e
81 changed files with 501390 additions and 182 deletions

View File

@ -1,9 +1,9 @@
# pg_orrery — A Database Orrery for PostgreSQL
## What This Is
A database orrery — celestial mechanics types and functions for PostgreSQL. Native C extension using PGXS, 68 SQL functions, 7 custom types, covering satellites (SGP4/SDP4), planets (VSOP87 + optional JPL DE441), Moon (ELP2000-82B), 19 planetary moons (L1.2/TASS17/GUST86/MarsSat), stars, comets, Jupiter radio bursts, and interplanetary Lambert transfers.
A database orrery — celestial mechanics types and functions for PostgreSQL. Native C extension using PGXS, 106 SQL functions, 9 custom types, covering satellites (SGP4/SDP4), planets (VSOP87 + optional JPL DE441), Moon (ELP2000-82B), 19 planetary moons (L1.2/TASS17/GUST86/MarsSat), stars (with proper motion), comets, asteroids (MPC catalog), Jupiter radio bursts, interplanetary Lambert transfers, equatorial RA/Dec coordinates, atmospheric refraction, and light-time correction.
**Current version:** 0.3.0 on branch `phase/solar-system-expansion`
**Current version:** 0.9.0 on branch `phase/spgist-orbital-trie`
**Repository:** https://git.supported.systems/warehack.ing/pg_orrery
**Documentation:** https://pg-orrery.warehack.ing
@ -11,7 +11,7 @@ A database orrery — celestial mechanics types and functions for PostgreSQL. Na
```bash
make PG_CONFIG=/usr/bin/pg_config # Compile with PGXS
sudo make install PG_CONFIG=/usr/bin/pg_config # Install extension
make installcheck PG_CONFIG=/usr/bin/pg_config # Run 13 regression test suites
make installcheck PG_CONFIG=/usr/bin/pg_config # Run 18 regression test suites
```
Requires: PostgreSQL 17 development headers, GCC, Make.
@ -27,14 +27,26 @@ Image: `git.supported.systems/warehack.ing/pg_orrery:pg17`
## Project Layout
```
pg_orrery.control # Extension metadata (version 0.3.0)
pg_orrery.control # Extension metadata (version 0.9.0)
Makefile # PGXS build + Docker targets
sql/
pg_orrery--0.1.0.sql # v0.1.0: satellite types/functions/operators
pg_orrery--0.2.0.sql # v0.2.0: solar system (57 functions)
pg_orrery--0.3.0.sql # v0.3.0: complete extension (68 functions)
pg_orrery--0.3.0.sql # v0.3.0: DE ephemeris (68 functions)
pg_orrery--0.4.0.sql # v0.4.0: orbit determination
pg_orrery--0.5.0.sql # v0.5.0: SP-GiST orbital trie
pg_orrery--0.6.0.sql # v0.6.0: conjunction screening
pg_orrery--0.7.0.sql # v0.7.0: GiST improvements
pg_orrery--0.8.0.sql # v0.8.0: orbital_elements type + MPC parser (82 functions)
pg_orrery--0.9.0.sql # v0.9.0: equatorial type, refraction, proper motion, light-time (106 functions)
pg_orrery--0.1.0--0.2.0.sql # Migration: v0.1.0 → v0.2.0 (adds solar system)
pg_orrery--0.2.0--0.3.0.sql # Migration: v0.2.0 → v0.3.0 (adds DE ephemeris)
pg_orrery--0.3.0--0.4.0.sql # Migration: v0.3.0 → v0.4.0
pg_orrery--0.4.0--0.5.0.sql # Migration: v0.4.0 → v0.5.0
pg_orrery--0.5.0--0.6.0.sql # Migration: v0.5.0 → v0.6.0
pg_orrery--0.6.0--0.7.0.sql # Migration: v0.6.0 → v0.7.0
pg_orrery--0.7.0--0.8.0.sql # Migration: v0.7.0 → v0.8.0 (orbital_elements type)
pg_orrery--0.8.0--0.9.0.sql # Migration: v0.8.0 → v0.9.0 (equatorial, refraction, proper motion, light-time)
src/
pg_orrery.c # PG_MODULE_MAGIC + _PG_init() (GUC registration)
types.h # All struct definitions + constants + DE body ID mapping
@ -45,7 +57,7 @@ src/
observer_type.c # Observer type with flexible string parsing
sgp4_funcs.c # sgp4_propagate(), _safe(), _series(), tle_distance()
coord_funcs.c # eci_to_geodetic(), eci_to_topocentric(), ground_track()
pass_funcs.c # next_pass(), predict_passes(), pass_visible()
pass_funcs.c # next_pass(), predict_passes(), predict_passes_refracted(), pass_visible()
gist_tle.c # GiST operator class (&&, <->)
# --- Solar System (v0.2.0) ---
vsop87.c / vsop87.h # VSOP87 planetary ephemeris (Bretagnon 1988)
@ -53,9 +65,13 @@ src/
precession.c / precession.h # IAU 1976 precession (Lieske 1979)
sidereal_time.c / .h # GMST calculation (Vallado Eq. 3-47)
elliptic_to_rectangular.c/.h # Orbital element conversions
planet_funcs.c # planet_observe(), planet_heliocentric(), sun/moon_observe()
star_funcs.c # star_observe(), star_observe_safe()
planet_funcs.c # planet_observe(), planet_heliocentric(), sun/moon_observe(), _equatorial(), _apparent()
star_funcs.c # star_observe(), star_observe_safe(), star_equatorial(), star_observe_pm(), star_equatorial_pm()
kepler_funcs.c # kepler_propagate(), comet_observe()
kepler.h # Shared Kepler solver interface (kepler_position())
orbital_elements_type.c # orbital_elements type, MPC parser, small_body_observe/equatorial/apparent()
equatorial_funcs.c # equatorial type I/O, accessors, satellite/planet/sun/moon RA/Dec
refraction_funcs.c # atmospheric_refraction(), _ext(), topo_elevation_apparent()
l12.c / l12.h # L1.2 Galilean moon theory (Lieske 1998)
tass17.c / tass17.h # TASS 1.7 Saturn moon theory (Vienne & Duriez 1995)
gust86.c / gust86.h # GUST86 Uranus moon theory (Laskar & Jacobson 1987)
@ -80,7 +96,7 @@ src/
PROVENANCE.md # Vendoring decision, modifications, verification
LICENSE # MIT license (Bill Gray / Project Pluto)
test/
sql/ # 13 regression test suites
sql/ # 16 regression test suites
expected/ # Expected output
data/vallado_518.json # 518 Vallado test vectors (AIAA 2006-6753-Rev1)
docs/
@ -104,20 +120,23 @@ All types are fixed-size, `STORAGE = plain`, `ALIGNMENT = double`. No TOAST over
| `observer` | 24 | lat, lon (radians), alt_m (meters) |
| `pass_event` | 48 | AOS/MAX/LOS times + max_el + AOS/LOS azimuth |
| `heliocentric` | 24 | x, y, z in AU (ecliptic J2000 frame) |
| `orbital_elements` | 72 | Classical Keplerian elements for comets/asteroids (epoch, q, e, inc, omega, Omega, tp, H, G) |
| `equatorial` | 24 | Apparent RA (hours), Dec (degrees), distance (km) — of date |
## Function Domains (68 total)
## Function Domains (106 total)
| Domain | Theory | Key Functions | Count |
|--------|--------|---------------|-------|
| Satellite | SGP4/SDP4 (Brouwer 1959) | `observe()`, `predict_passes()`, `ground_track()` | 22 |
| Planets | VSOP87 (Bretagnon 1988) | `planet_observe()`, `planet_heliocentric()` | 3 |
| Sun/Moon | VSOP87 + ELP2000-82B | `sun_observe()`, `moon_observe()` | 2 |
| Satellite | SGP4/SDP4 (Brouwer 1959) | `observe()`, `predict_passes()`, `eci_to_equatorial()` | 25 |
| Planets | VSOP87 (Bretagnon 1988) | `planet_observe()`, `planet_equatorial()`, `planet_observe_apparent()` | 7 |
| Sun/Moon | VSOP87 + ELP2000-82B | `sun_observe()`, `moon_observe()`, `sun/moon_equatorial()` | 6 |
| Planetary moons | L1.2, TASS17, GUST86, MarsSat | `galilean_observe()`, `saturn_moon_observe()` | 4 |
| Stars | J2000 + IAU 1976 precession | `star_observe()`, `star_observe_safe()` | 2 |
| Comets/asteroids | Two-body Keplerian | `kepler_propagate()`, `comet_observe()` | 2 |
| Stars | J2000 + IAU 1976 precession | `star_observe()`, `star_equatorial()`, `star_observe_pm()` | 5 |
| Comets/asteroids | Two-body Keplerian + MPC | `small_body_observe()`, `small_body_equatorial()`, `oe_from_mpc()` | 19 |
| Refraction | Bennett (1982) | `atmospheric_refraction()`, `predict_passes_refracted()` | 4 |
| Jupiter radio | Carr et al. (1983) | `jupiter_burst_probability()` | 3 |
| Transfers | Lambert (Izzo 2015) | `lambert_transfer()`, `lambert_c3()` | 2 |
| DE ephemeris | JPL DE440/441 (optional) | `planet_observe_de()`, `moon_observe_de()` | 11 |
| DE ephemeris | JPL DE440/441 (optional) | `planet_observe_de()`, `planet_equatorial_de()` | 13 |
| GiST index | Altitude-band approximation | `&&` (overlap), `<->` (distance) | 8 |
| Diagnostics | -- | `pg_orrery_ephemeris_info()` | 1 |
@ -174,6 +193,7 @@ All functions are `PARALLEL SAFE`. VSOP87/ELP82B functions are `IMMUTABLE` (comp
#define GAUSS_K 0.01720209895 /* AU^(3/2)/day */
#define OBLIQUITY_J2000 0.40909280422232897 /* 23.4392911 deg in radians */
#define J2000_JD 2451545.0 /* 2000 Jan 1.5 TT */
#define C_LIGHT_AU_DAY 173.1446327 /* speed of light in AU/day */
```
## JPL DE Ephemeris (Optional)
@ -218,6 +238,8 @@ Every `_de()` function mirrors an existing VSOP87 function:
| `saturn_moon_observe_de()` | `saturn_moon_observe()` | STABLE |
| `uranus_moon_observe_de()` | `uranus_moon_observe()` | STABLE |
| `mars_moon_observe_de()` | `mars_moon_observe()` | STABLE |
| `planet_equatorial_de()` | `planet_equatorial()` | STABLE |
| `moon_equatorial_de()` | `moon_equatorial()` | STABLE |
| `pg_orrery_ephemeris_info()` | — | STABLE |
## Vendored SGP4/SDP4
@ -239,7 +261,7 @@ All numerical logic is byte-identical to upstream. Verified against 518 Vallado
## Testing
13 regression test suites via `make installcheck`:
18 regression test suites via `make installcheck`:
| Suite | What it tests |
|-------|--------------|
@ -255,11 +277,16 @@ All numerical logic is byte-identical to upstream. Verified against 518 Vallado
| moon_observe | Galilean/Saturn/Uranus/Mars moons, Io phase, Jupiter CML, burst probability |
| lambert_transfer | Lambert solver, lambert_c3, pork chop grid, error handling |
| de_ephemeris | DE function fallback to VSOP87, cross-provider consistency, error handling |
| od_fit | Orbit determination from ECI/topocentric/angles-only observations |
| spgist_tle | SP-GiST orbital trie index operations |
| orbital_elements | orbital_elements type I/O, MPC parser, small_body_observe/heliocentric |
| equatorial | equatorial type I/O, RA/Dec for planets/stars/satellites, proper motion, light-time |
| refraction | Bennett refraction, P/T correction, apparent elevation, refracted pass prediction |
| vallado_518 | 518 Vallado test vectors (AIAA 2006-6753-Rev1), per-satellite breakdown |
### PG Version Matrix
Test all 13 regression suites + DE reader unit test across PostgreSQL 14-18 using Docker:
Test all 18 regression suites + DE reader unit test across PostgreSQL 14-18 using Docker:
```bash
make test-matrix # Full matrix (PG 14-18)
@ -283,9 +310,9 @@ Logs saved to `test/matrix-logs/pg${ver}.log`. The script reuses the Dockerfile
**Live:** https://pg-orrery.warehack.ing
Starlight docs at `docs/`36 MDX pages covering all domains.
Starlight docs at `docs/`44 MDX pages covering all domains.
Sections: Getting Started, Guides (9 domain walkthroughs incl. DE ephemeris), Workflow Translation (Skyfield/Horizons/GMAT/Radio Jupiter Pro comparisons), Reference (all 68 functions incl. DE variants), Architecture (Hamilton's principles, constant custody, observation pipeline), Performance (benchmarks).
Sections: Getting Started, Guides (9 domain walkthroughs incl. DE ephemeris), Workflow Translation (Skyfield/Horizons/GMAT/Radio Jupiter Pro comparisons), Reference (all 106 functions incl. DE variants, equatorial, refraction), Architecture (Hamilton's principles, constant custody, observation pipeline), Performance (benchmarks).
### Local Development
```bash
@ -301,7 +328,7 @@ The docs site deploys to the `warehack.ing` VPS (`149.28.126.25`) which runs cad
```bash
ssh -A warehack-ing@pg-orrery.warehack.ing
cd ~/pg_orrery
git pull origin phase/solar-system-expansion # or the current branch
git pull origin phase/spgist-orbital-trie # or the current branch
cd docs
make prod # builds image + starts container
```
@ -310,7 +337,7 @@ make prod # builds image + starts containe
```bash
ssh -A warehack-ing@pg-orrery.warehack.ing
git clone git@git.supported.systems:warehack.ing/pg_orrery.git
cd pg_orrery && git checkout phase/solar-system-expansion
cd pg_orrery && git checkout phase/spgist-orbital-trie
cat > docs/.env << 'EOF'
COMPOSE_PROJECT_NAME=pg-orrery-docs
NODE_ENV=production
@ -343,6 +370,6 @@ cd docs && make prod
## Git Conventions
- One commit per logical change
- Branch per phase: `phase/solar-system-expansion`
- Tag releases: `v0.1.0`, `v0.2.0`
- Branch per phase: `phase/spgist-orbital-trie`
- Tag releases: `v0.1.0`, `v0.2.0`, `v0.3.0`
- Commit messages: imperative mood, no AI attribution

View File

@ -4,7 +4,11 @@ DATA = sql/pg_orrery--0.1.0.sql sql/pg_orrery--0.2.0.sql sql/pg_orrery--0.1.0--0
sql/pg_orrery--0.3.0.sql sql/pg_orrery--0.2.0--0.3.0.sql \
sql/pg_orrery--0.4.0.sql sql/pg_orrery--0.3.0--0.4.0.sql \
sql/pg_orrery--0.5.0.sql sql/pg_orrery--0.4.0--0.5.0.sql \
sql/pg_orrery--0.6.0.sql sql/pg_orrery--0.5.0--0.6.0.sql
sql/pg_orrery--0.6.0.sql sql/pg_orrery--0.5.0--0.6.0.sql \
sql/pg_orrery--0.7.0.sql sql/pg_orrery--0.6.0--0.7.0.sql \
sql/pg_orrery--0.8.0.sql sql/pg_orrery--0.7.0--0.8.0.sql \
sql/pg_orrery--0.9.0.sql sql/pg_orrery--0.8.0--0.9.0.sql \
sql/pg_orrery--0.10.0.sql sql/pg_orrery--0.9.0--0.10.0.sql
# Our extension C sources
OBJS = src/pg_orrery.o src/tle_type.o src/eci_type.o src/observer_type.o \
@ -16,7 +20,11 @@ OBJS = src/pg_orrery.o src/tle_type.o src/eci_type.o src/observer_type.o \
src/moon_funcs.o src/radio_funcs.o \
src/lambert.o src/transfer_funcs.o \
src/de_reader.o src/eph_provider.o src/de_funcs.o \
src/od_math.o src/od_iod.o src/od_solver.o src/od_funcs.o
src/od_math.o src/od_iod.o src/od_solver.o src/od_funcs.o \
src/spgist_tle.o \
src/orbital_elements_type.o \
src/equatorial_funcs.o \
src/refraction_funcs.o
# Vendored SGP4/SDP4 sources (pure C, from Bill Gray's sat_code, MIT license)
SGP4_DIR = src/sgp4
@ -31,7 +39,8 @@ OBJS += $(SGP4_OBJS)
# Regression tests
REGRESS = tle_parse sgp4_propagate coord_transforms pass_prediction gist_index convenience \
star_observe kepler_comet planet_observe moon_observe lambert_transfer \
de_ephemeris od_fit vallado_518
de_ephemeris od_fit spgist_tle orbital_elements equatorial refraction \
aberration vallado_518
REGRESS_OPTS = --inputdir=test
# Pure C — no C++ runtime needed. LAPACK for OD solver (dgelss_).

43128
bench/active.tle Normal file

File diff suppressed because it is too large Load Diff

234
bench/benchmark.sql Normal file
View File

@ -0,0 +1,234 @@
-- ============================================================
-- SP-GiST Orbital Trie Benchmark (Phase 3)
-- CelesTrak active catalog, ~14k satellites
-- ============================================================
\timing on
-- ============================================================
-- 1. Catalog distribution analysis
-- ============================================================
SELECT
CASE
WHEN tle_perigee(tle) < 2000 THEN 'LEO (<2000km)'
WHEN tle_perigee(tle) < 20000 THEN 'MEO (2000-20000km)'
WHEN tle_perigee(tle) < 34000 THEN 'GEO-transfer'
ELSE 'GEO/HEO (>34000km)'
END AS regime,
count(*) AS n,
round(100.0 * count(*) / (SELECT count(*) FROM bench_catalog), 1) AS pct
FROM bench_catalog
GROUP BY 1
ORDER BY 2 DESC;
-- ============================================================
-- 2. Create indexes
-- ============================================================
\echo '--- CREATE SP-GiST INDEX ---'
CREATE INDEX bench_spgist ON bench_catalog USING spgist (tle tle_spgist_ops);
\echo '--- CREATE GiST INDEX ---'
CREATE INDEX bench_gist ON bench_catalog USING gist (tle tle_ops);
-- Index sizes
SELECT indexname,
pg_size_pretty(pg_relation_size(indexname::regclass)) AS size
FROM pg_indexes
WHERE tablename = 'bench_catalog'
ORDER BY indexname;
-- ============================================================
-- 3. Benchmark: 2h window, Eagle Idaho (43.7N) — RAAN active
-- ============================================================
\echo '--- BENCHMARK 1: 2h window, Eagle Idaho, 10 deg min_el ---'
-- 3a. Sequential scan (baseline)
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT count(*) AS seqscan_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
-- 3b. SP-GiST index scan
SET enable_seqscan = off;
SELECT count(*) AS spgist_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 4. Benchmark: 24h window, Eagle Idaho — RAAN bypassed
-- ============================================================
\echo '--- BENCHMARK 2: 24h window, Eagle Idaho, 10 deg min_el ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT count(*) AS seqscan_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 00:00:00+00'::timestamptz,
'2026-02-18 00:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
SELECT count(*) AS spgist_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 00:00:00+00'::timestamptz,
'2026-02-18 00:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 5. Benchmark: 2h window, Equatorial observer — all inc pass
-- ============================================================
\echo '--- BENCHMARK 3: 2h window, Equator, 10 deg min_el ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT count(*) AS seqscan_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
SELECT count(*) AS spgist_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 6. Benchmark: 45 deg min_el (aggressive altitude filter)
-- ============================================================
\echo '--- BENCHMARK 4: 2h window, Eagle Idaho, 45 deg min_el ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT count(*) AS seqscan_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
45.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
SELECT count(*) AS spgist_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
45.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 7. Consistency check: index and seqscan must agree
-- ============================================================
\echo '--- CONSISTENCY CHECK ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
CREATE TEMPORARY TABLE seq_results AS
SELECT norad_id FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
CREATE TEMPORARY TABLE idx_results AS
SELECT norad_id FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
-- These should both be 0
SELECT count(*) AS in_seq_not_idx FROM seq_results
WHERE norad_id NOT IN (SELECT norad_id FROM idx_results);
SELECT count(*) AS in_idx_not_seq FROM idx_results
WHERE norad_id NOT IN (SELECT norad_id FROM seq_results);
DROP TABLE seq_results, idx_results;
-- ============================================================
-- 8. EXPLAIN ANALYZE for query plan details
-- ============================================================
\echo '--- EXPLAIN ANALYZE: SP-GiST scan ---'
SET enable_seqscan = off;
EXPLAIN ANALYZE
SELECT count(*)
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
\echo '--- EXPLAIN ANALYZE: Sequential scan ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
EXPLAIN ANALYZE
SELECT count(*)
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
-- ============================================================
-- Cleanup
-- ============================================================
DROP TABLE bench_catalog;
\timing off

193
bench/benchmark_results.txt Normal file
View File

@ -0,0 +1,193 @@
Timing is on.
regime | n | pct
--------------------+-------+------
LEO (<2000km) | 13587 | 94.5
GEO/HEO (>34000km) | 588 | 4.1
MEO (2000-20000km) | 111 | 0.8
GEO-transfer | 90 | 0.6
(4 rows)
Time: 7.139 ms
--- CREATE SP-GiST INDEX ---
CREATE INDEX
Time: 12.351 ms
spgist_size
-------------
2424 kB
(1 row)
Time: 1.388 ms
--- BENCHMARK 1: 2h window, Eagle Idaho, 10 deg min_el ---
SET
Time: 0.035 ms
SET
Time: 0.021 ms
Sequential scan:
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=470.74..470.75 rows=1 width=8) (actual time=2.706..2.707 rows=1.00 loops=1)
Buffers: shared hit=291
-> Seq Scan on bench_catalog (cost=0.00..470.70 rows=14 width=0) (actual time=0.015..2.649 rows=2261.00 loops=1)
Filter: ('("43.6977N 116.3535W 760m","2026-02-16 19:00:00-07","2026-02-16 21:00:00-07",10)'::observer_window &? tle)
Rows Removed by Filter: 12115
Buffers: shared hit=291
Planning:
Buffers: shared hit=8
Planning Time: 0.093 ms
Execution Time: 2.725 ms
(10 rows)
Time: 3.621 ms
RESET
Time: 0.028 ms
RESET
Time: 0.009 ms
SET
Time: 0.012 ms
SP-GiST index scan:
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=1754.89..1754.90 rows=1 width=8) (actual time=3.896..3.897 rows=1.00 loops=1)
Buffers: shared hit=1161
-> Bitmap Heap Scan on bench_catalog (cost=1284.16..1754.86 rows=14 width=0) (actual time=0.957..3.833 rows=2261.00 loops=1)
Filter: ('("43.6977N 116.3535W 760m","2026-02-16 19:00:00-07","2026-02-16 21:00:00-07",10)'::observer_window &? tle)
Rows Removed by Filter: 12115
Heap Blocks: exact=291
Buffers: shared hit=1161
-> Bitmap Index Scan on bench_spgist (cost=0.00..1284.16 rows=14376 width=0) (actual time=0.925..0.925 rows=14376.00 loops=1)
Index Searches: 1
Buffers: shared hit=870
Planning Time: 0.050 ms
Execution Time: 3.936 ms
(12 rows)
Time: 4.150 ms
RESET
Time: 0.038 ms
--- BENCHMARK 2: 24h window, Eagle Idaho, 10 deg min_el ---
SET
Time: 0.016 ms
SET
Time: 0.009 ms
Sequential scan:
seqscan_24h
-------------
13562
(1 row)
Time: 2.867 ms
RESET
Time: 0.023 ms
RESET
Time: 0.008 ms
SET
Time: 0.011 ms
SP-GiST index scan:
spgist_24h
------------
13562
(1 row)
Time: 3.832 ms
RESET
Time: 0.025 ms
--- BENCHMARK 3: 2h window, Equator, 10 deg min_el ---
SET
Time: 0.011 ms
SET
Time: 0.008 ms
Sequential scan:
seqscan_equator
-----------------
2073
(1 row)
Time: 2.564 ms
RESET
Time: 0.011 ms
RESET
Time: 0.007 ms
SET
Time: 0.008 ms
SP-GiST index scan:
spgist_equator
----------------
2073
(1 row)
Time: 3.566 ms
RESET
Time: 0.020 ms
--- BENCHMARK 4: 2h window, Eagle Idaho, 45 deg min_el ---
SET
Time: 0.010 ms
SET
Time: 0.006 ms
Sequential scan:
seqscan_45deg
---------------
1407
(1 row)
Time: 2.510 ms
RESET
Time: 0.021 ms
RESET
Time: 0.007 ms
SET
Time: 0.008 ms
SP-GiST index scan:
spgist_45deg
--------------
1407
(1 row)
Time: 3.591 ms
RESET
Time: 0.014 ms
--- CONSISTENCY CHECK ---
SET
Time: 0.017 ms
SET
Time: 0.009 ms
SELECT 2261
Time: 4.578 ms
RESET
Time: 0.023 ms
RESET
Time: 0.008 ms
SET
Time: 0.010 ms
SELECT 2261
Time: 3.583 ms
RESET
Time: 0.022 ms
in_seq_not_idx
----------------
0
(1 row)
Time: 0.586 ms
in_idx_not_seq
----------------
0
(1 row)
Time: 0.280 ms
DROP TABLE
Time: 0.951 ms
--- PRUNING SUMMARY ---
scenario | catalog_size | candidates | candidate_pct | pruning_pct
------------------+--------------+------------+---------------+-------------
2h/Eagle/10deg | 14376 | 2261 | 15.7 | 84.3
2h/Eagle/45deg | 14376 | 1407 | 9.8 | 90.2
2h/Equator/10deg | 14376 | 2073 | 14.4 | 85.6
24h/Eagle/10deg | 14376 | 13562 | 94.3 | 5.7
(4 rows)
Time: 9.346 ms
DROP INDEX
Time: 1.176 ms
DROP TABLE
Time: 1.725 ms
Timing is off.

View File

@ -0,0 +1,115 @@
pg_orrery v0.7.0 SP-GiST Benchmark — 30k Space-Track Catalog
=============================================================
Date: 2026-02-17
Catalog: Space-Track USSPACECOM full catalog (29,784 objects)
Host: Linux 6.16.5-arch1-1, PostgreSQL 17
Branch: phase/spgist-orbital-trie
Catalog Composition:
LEO (<128 min): 25,641 (86.1%)
MEO (128-720 min): 1,801 ( 6.0%)
GEO/HEO (720-1500 min): 2,253 ( 7.6%)
Super-GEO (>1500 min): 89 ( 0.3%)
Index Build:
SP-GiST: 46.7 ms, 4,816 kB
GiST: 65.8 ms, 5,856 kB
Table: 4,760 kB
==============================================================
TIMING RESULTS (best of 3 runs, ms)
==============================================================
Query Pattern | Seqscan | SP-GiST | Delta
-----------------------------|---------|---------|-------
2h window, Eagle ID, 10 deg | 5.19 | 5.22 | +0.03
6h window, Eagle ID, 10 deg | 5.34 | 6.34 | +1.00
24h window, Eagle ID, 10 deg | 5.42 | 6.68 | +1.26
2h window, Eagle ID, 30 deg | 5.54 | 5.64 | +0.10
2h window, equatorial, 10deg | 5.26 | 5.59 | +0.33
==============================================================
PRUNING RESULTS
==============================================================
Query Pattern | Candidates | % Pass | % Pruned
-----------------------------|------------|--------|---------
2h window, Eagle ID, 10 deg | 7,188 | 24.1% | 75.9%
6h window, Eagle ID, 10 deg | 12,373 | 41.5% | 58.5%
24h window, Eagle ID, 10 deg | 26,971 | 90.6% | 9.4%
2h window, Eagle ID, 30 deg | 5,232 | 17.6% | 82.4%
2h window, equatorial, 10deg | 4,670 | 15.7% | 84.3%
==============================================================
BUFFER I/O COMPARISON (2h Eagle 10deg)
==============================================================
Method | Pages Read | Heap Fetches | Scan Type
------------|------------|--------------|------------------
Seqscan | 595 | n/a | Seq Scan
SP-GiST | 2,396 | 0 | Index Only Scan
SP-GiST reads 4.0x more pages than seqscan (2,396 vs 595).
But uses Index Only Scan with zero heap fetches.
==============================================================
CONSISTENCY CHECK
==============================================================
False negatives: 0 (index never misses a seqscan result)
False positives: 0 (index never returns extra results)
Seqscan count: 7,188
SP-GiST count: 7,188
==============================================================
SCALING TREND (2h Eagle 10deg, best-of-3)
==============================================================
Catalog Size | Seqscan | SP-GiST | Delta | SP-GiST Pages | Seq Pages
-------------|---------|---------|--------|---------------|----------
14,376 | 4.5 ms | 6.1 ms | +1.6ms | 888 | 291
20,597 | 3.8 ms | 4.7 ms | +0.9ms | (IOOS) | (est)
29,784 | 5.2 ms | 5.2 ms | +0.0ms | 2,396 | 595
The delta is converging toward zero. At 30k the SP-GiST index is
essentially tied with seqscan on the 2h/10deg query. For queries
with fewer survivors (30deg elevation, equatorial observer), the
index is within 0.1-0.3ms.
==============================================================
PLANNER BEHAVIOR
==============================================================
PostgreSQL's query planner CHOOSES the SP-GiST index by default
at 30k (without any enable_seqscan=off forcing). The planner's
cost model prefers the Index Only Scan.
EXPLAIN output (default settings):
Index Only Scan using bench_spgist on bench_catalog
Index Cond: (tle &? ...)
Heap Fetches: 0
Buffers: shared hit=2396
Execution Time: 7.246 ms (with planning)
==============================================================
NOTES
==============================================================
1. At 30k objects, the planner voluntarily chooses SP-GiST over
seqscan. This is the crossover point where the index becomes
the planner's preferred strategy.
2. The Index Only Scan with zero heap fetches means the index
contains all information needed — no table access required.
3. The 75.9% pruning rate on the 2h window means only 7,188 of
29,784 satellites need SGP4 propagation. This avoids ~22,596
unnecessary SGP4 calls in the predict_passes() pipeline.
4. The equatorial observer (84.3% pruned) and high-elevation
(82.4% pruned) queries show the strongest filtering because
the altitude and RAAN filters are most aggressive there.
5. The 24h window only prunes 9.4% because the RAAN filter
self-disables for full Earth rotations, leaving only the
altitude and inclination filters active.

View File

@ -0,0 +1,114 @@
pg_orrery v0.7.0 SP-GiST Benchmark — 66k Full Space-Track Catalog
===================================================================
Date: 2026-02-17
Catalog: Space-Track USSPACECOM full catalog including decayed (65,886 objects)
Host: Linux 6.16.5-arch1-1, PostgreSQL 17
Branch: phase/spgist-orbital-trie
Note: After fixing L1 inclination pruning (sma_low -> sma_high)
Catalog Composition:
LEO (<128 min): 59,537 (90.4%)
MEO (128-720 min): 3,474 ( 5.3%)
GEO/HEO (720-1500 min): 2,643 ( 4.0%)
Super-GEO (>1500 min): 232 ( 0.4%)
Index Build:
SP-GiST: 55.2 ms, 11 MB
GiST: 118.2 ms, 13 MB
Table: 10 MB
==============================================================
TIMING RESULTS (best of 2-3 runs, ms)
==============================================================
Query Pattern | Seqscan | SP-GiST | Delta
-----------------------------|---------|---------|-------
2h window, Eagle ID, 10 deg | 12.5 | 14.0 | +1.5
6h window, Eagle ID, 10 deg | 12.2 | 15.6 | +3.4
2h window, Tromsø, 10 deg | 11.3 | 10.9 | -0.4 ★
24h window, Eagle ID, 10 deg | 12.0 | 16.2 | +4.2
★ Tromsø (69.6°N): SP-GiST beats seqscan. High-latitude observers
benefit most from inclination pruning.
==============================================================
PRUNING RESULTS
==============================================================
Query Pattern | Candidates | % Pass | % Pruned
-----------------------------|------------|--------|---------
2h window, Eagle ID, 10 deg | 12,964 | 19.7% | 80.3%
6h window, Eagle ID, 10 deg | 24,274 | 36.8% | 63.2%
24h window, Eagle ID, 10 deg | 60,875 | 92.4% | 7.6%
2h window, Eagle ID, 30 deg | 9,680 | 14.7% | 85.3%
2h window, equatorial, 10deg | 9,699 | 14.7% | 85.3%
2h window, Tromsø 69.6°N | 6,529 | 9.9% | 90.1%
2h window, South Pole 85°S | 5,248 | 8.0% | 92.0%
==============================================================
CONSISTENCY CHECKS (all patterns)
==============================================================
Query Pattern | False Negatives | False Positives
-----------------------------|-----------------|----------------
2h Eagle 10deg | 0 | 0
6h Eagle 10deg | 0 | 0
24h Eagle 10deg | 0 | 0
2h Eagle 30deg | 0 | 0
2h Equator 10deg | 0 | 0
2h Tromsø 10deg | 0 | 0
2h South Pole 10deg | 0 | 0
==============================================================
SCALING TREND (2h Eagle 10deg, best-of-N)
==============================================================
Catalog Size | Seqscan | SP-GiST | Delta | Notes
-------------|---------|---------|--------|------
14,376 | 4.5 ms | 6.1 ms | +1.6ms | Active CelesTrak
29,784 | 5.2 ms | 5.2 ms | +0.0ms | Active Space-Track (before fix)
65,886 | 12.5 ms | 14.0 ms | +1.5ms | Full catalog incl decayed (after fix)
The fix (sma_high instead of sma_low for footprint) adds ~1-2ms overhead
by conservatively keeping more subtrees alive during L1 pruning. This is
the correct trade-off: zero false negatives is non-negotiable.
==============================================================
PLANNER BEHAVIOR (66k)
==============================================================
PostgreSQL still chooses SP-GiST Index Only Scan by default:
Index Only Scan using bench_spgist on bench_catalog
Index Cond: (tle &? ...)
Heap Fetches: 0
Buffers: shared hit=4990
Seqscan would read 1,297 pages. Index reads 4,990 pages (3.8x more).
But Index Only Scan avoids all heap I/O.
==============================================================
KEY FINDING: HIGH-LATITUDE OBSERVERS
==============================================================
The SP-GiST index is most valuable for high-latitude observers:
Tromsø (69.6°N): 90.1% pruned, SP-GiST BEATS seqscan by 0.4ms
South Pole (85°S): 92.0% pruned
High-latitude locations eliminate most LEO satellites via the
inclination filter — only satellites with inc > ~60° can reach
these latitudes. The SP-GiST trie prunes entire inclination
subtrees at L1, making the index scan faster than touching
every page in the table.
==============================================================
WHAT THE 80-92% PRUNING MEANS IN PRACTICE
==============================================================
For a 65,886-object catalog with a 2-hour window:
- Without &? operator: 65,886 SGP4 predict_passes() calls
- With &? operator: 12,964 SGP4 calls (Eagle) or 5,248 (South Pole)
- Savings: 52,922-60,638 unnecessary propagation calls avoided
At ~1ms per predict_passes() call (7-day window, 30s resolution),
that's 53-61 seconds of saved computation per query.

View File

@ -0,0 +1,168 @@
pg_orrery Full Index Benchmark — 66k Catalog
===========================================================
Date: 2026-02-18
PostgreSQL: 18.1
Catalog: 66,440 objects (merged from 4 sources)
Sources: Space-Track (66,248), CelesTrak active (5 unique),
SatNOGS (110 unique), CelesTrak SupGP (77 unique + 8,167 epoch updates)
Includes: 362 Alpha-5 objects (NORAD > 99,999)
Orbital regime breakdown:
LEO (<2000km): 63,097 (95.0%)
GEO/HEO (>34000km): 1,760 ( 2.6%)
MEO (2000-20000km): 1,277 ( 1.9%)
GEO-transfer: 306 ( 0.5%)
Index sizes:
SP-GiST (tle_spgist_ops): 67 ms build, 11 MB
GiST (tle_ops): 93 ms build, 15 MB
═══════════════════════════════════════════════════════════
SP-GiST: Visibility Cone (&?) — "Can this satellite pass over me?"
═══════════════════════════════════════════════════════════
SP-GiST prunes by altitude band, inclination, and RAAN window.
The &? operator answers: "Could this satellite be visible from this
observer during this time window above this minimum elevation?"
Query │ SP-GiST │ Seqscan │ Candidates │ Pruned%
───────────────────────┼──────────┼──────────┼────────────┼────────
Eagle 2h/10deg │ 16.1 ms │ 12.1 ms │ 10,763 │ 83.8%
Eagle 24h/10deg │ 23.3 ms │ 12.5 ms │ 61,426 │ 7.5%
Equator 2h/10deg │ 16.8 ms │ 12.1 ms │ 10,174 │ 84.7%
Eagle 2h/45deg │ 16.9 ms │ 11.9 ms │ 6,796 │ 89.8%
Consistency: PASS (all 4 scenarios: 0 false neg, 0 false pos)
═══════════════════════════════════════════════════════════
GiST: Overlap (&&) — "Does this satellite share my orbit band?"
═══════════════════════════════════════════════════════════
GiST groups satellites by [altitude_low, altitude_high] × [inclination].
The && operator answers: "Do these two TLEs occupy overlapping orbit bands?"
Used for conjunction screening — finding potential collision partners.
Critical bugfix in this session:
Bug 1: palloc size mismatch (sizeof(pg_tle)=104 vs INTERNALLENGTH=112)
Bug 2: gist_tle_union used 1-based indexing (picksplit convention)
instead of 0-based (union convention), skipping vector[0]
Query │ GiST │ Seqscan │ Matches
───────────────────────┼──────────┼──────────┼────────
ISS conjunction │ 10.9 ms │ 63.3 ms │ 9
Starlink-230369 │ 9.5 ms │ 14.9 ms │ 0
SYNCOM 2 (GEO) │ 4.0 ms │ 7.2 ms │ 0
Consistency: PASS (ISS: 9 seqscan == 9 GiST, 0 mismatch)
ISS conjunction candidates (altitude + inclination overlap):
PROGRESS MS-31, PROGRESS MS-32, SOYUZ MS-28,
DRAGON FREEDOM 3, DRAGON CRS-33, CYGNUS NG-23,
HTV-X1, ISS (NAUKA), OBJECT E
— All ISS-visiting vehicles or co-orbital modules. ✓
═══════════════════════════════════════════════════════════
GiST: KNN (<->) — "What's nearest to this orbit?"
═══════════════════════════════════════════════════════════
GiST KNN uses altitude-band distance for index-ordered scans.
The <-> operator returns orbital altitude separation in km.
Probe must be a scalar subquery for index ordering to activate.
Query │ GiST KNN │ Buffers │ Notes
───────────────────────┼──────────┼─────────┼──────────────
10 nearest to ISS │ 2.1 ms │ 982 │ Index-ordered
10 nearest to SYNCOM 2 │ 0.2 ms │ 40 │ Index-ordered
100 nearest to ISS │ 1.4 ms │ 1,062 │ Index-ordered
Within 50km of ISS │ 16.0 ms │ 4,014 │ 12,496 matches
Pattern for KNN queries (probe as scalar subquery):
ORDER BY b.tle <-> (SELECT tle FROM catalog WHERE norad_id = 25544 LIMIT 1)
LIMIT 10;
→ Index Scan using bench_gist_idx, Order By: tle <-> InitPlan
═══════════════════════════════════════════════════════════
EXPLAIN ANALYZE Details
═══════════════════════════════════════════════════════════
SP-GiST 2h/Eagle/10deg:
Index Only Scan using bench_spgist_idx
Heap Fetches: 0 (pure index scan)
Buffers: shared hit=4964
17.5 ms execution
SeqScan 2h/Eagle/10deg:
Seq Scan, Filter rows removed: 55,677
Buffers: shared hit=1338
12.5 ms execution
GiST && ISS conjunction:
Nested Loop → Index Scan using bench_gist_idx
Index Cond: (tle && a.tle)
Index Searches: 1, Buffers: shared hit=287
4.1 ms execution
GiST KNN 10 nearest ISS:
Index Scan using bench_gist_idx
Order By: (tle <-> InitPlan)
Index Searches: 1
2.1 ms execution
═══════════════════════════════════════════════════════════
Pruning Summary
═══════════════════════════════════════════════════════════
Scenario │ Catalog │ Candidates │ Candidate% │ Pruned%
─────────────────┼─────────┼────────────┼────────────┼────────
2h/Eagle/10deg │ 66,440 │ 10,763 │ 16.2% │ 83.8%
2h/Equator/10deg │ 66,440 │ 10,174 │ 15.3% │ 84.7%
2h/Eagle/45deg │ 66,440 │ 6,796 │ 10.2% │ 89.8%
24h/Eagle/10deg │ 66,440 │ 61,426 │ 92.5% │ 7.5%
═══════════════════════════════════════════════════════════
Application Queries
═══════════════════════════════════════════════════════════
"What's overhead right now?" (SP-GiST filter + SGP4 propagation):
15 satellites above horizon, top: NAVSTAR 57 at 81.7° el
107 ms (includes SGP4 propagation for each candidate)
ISS pass prediction (next 24h from 66k catalog):
6 passes found, max 87.6° elevation
3.8 ms
ISS conjunction screening (GiST && on 66k catalog):
9 co-orbital objects found
4.6 ms via GiST (vs 63.3 ms seqscan — 5.8x speedup)
═══════════════════════════════════════════════════════════
Key Observations
═══════════════════════════════════════════════════════════
1. GiST && is the clear winner for conjunction screening:
- ISS: 10.9ms GiST vs 63.3ms seqscan (5.8x speedup)
- Only 287 buffer hits vs 1,338 for seqscan
- Returns exactly the right 9 co-orbital objects
2. GiST KNN is extremely fast for "nearest orbit" queries:
- 10 nearest: 2.1ms with index ordering
- GEO satellite: 0.15ms (sparse regime, fewer nodes to traverse)
- Requires scalar subquery probe pattern for index ordering
3. SP-GiST visibility cone handles 2h windows well:
- 83.8% pruning at 10° min_el (Eagle, 2h)
- 89.8% pruning at 45° min_el
- Falls behind seqscan at 24h windows (7.5% pruning not worth index overhead)
4. Both indexes are compact:
- SP-GiST: 11 MB for 66k objects (170 bytes/object)
- GiST: 15 MB for 66k objects (237 bytes/object)
- Build times: 67ms and 93ms respectively
5. Zero false positives/negatives across all consistency checks.
Alpha-5 support:
- Bill Gray's get_el.c parser handles Alpha-5 natively
- T0002 → 270002, A0001 → 100001, Z9999 → 339999 ✓
- Round-trip (parse → output) preserves Alpha-5 encoding ✓
- 362 Alpha-5 objects loaded and indexed without issues ✓

View File

@ -0,0 +1,202 @@
Timing is on.
regime | n | pct
--------------------+-------+------
LEO (<2000km) | 13587 | 94.5
GEO/HEO (>34000km) | 588 | 4.1
MEO (2000-20000km) | 111 | 0.8
GEO-transfer | 90 | 0.6
(4 rows)
Time: 9.226 ms
--- CREATE SP-GiST INDEX ---
CREATE INDEX
Time: 18.724 ms
--- CREATE GiST INDEX ---
CREATE INDEX
Time: 44.994 ms
indexname | size
--------------------+---------
bench_catalog_pkey | 336 kB
bench_gist | 2904 kB
bench_spgist | 2344 kB
(3 rows)
Time: 3.750 ms
--- BENCHMARK 1: 2h window, Eagle Idaho, 10 deg min_el ---
SET
Time: 0.158 ms
SET
Time: 0.020 ms
seqscan_candidates
--------------------
2261
(1 row)
Time: 8.224 ms
RESET
Time: 0.102 ms
RESET
Time: 0.019 ms
SET
Time: 0.023 ms
spgist_candidates
-------------------
2261
(1 row)
Time: 9.787 ms
RESET
Time: 0.142 ms
--- BENCHMARK 2: 24h window, Eagle Idaho, 10 deg min_el ---
SET
Time: 0.044 ms
SET
Time: 0.013 ms
seqscan_candidates
--------------------
13562
(1 row)
Time: 4.272 ms
RESET
Time: 0.044 ms
RESET
Time: 0.015 ms
SET
Time: 0.017 ms
spgist_candidates
-------------------
13562
(1 row)
Time: 6.832 ms
RESET
Time: 0.065 ms
--- BENCHMARK 3: 2h window, Equator, 10 deg min_el ---
SET
Time: 0.025 ms
SET
Time: 0.010 ms
seqscan_candidates
--------------------
2073
(1 row)
Time: 5.868 ms
RESET
Time: 1.133 ms
RESET
Time: 0.083 ms
SET
Time: 0.032 ms
spgist_candidates
-------------------
2073
(1 row)
Time: 7.401 ms
RESET
Time: 0.105 ms
--- BENCHMARK 4: 2h window, Eagle Idaho, 45 deg min_el ---
SET
Time: 0.034 ms
SET
Time: 0.010 ms
seqscan_candidates
--------------------
1407
(1 row)
Time: 5.641 ms
RESET
Time: 0.153 ms
RESET
Time: 0.018 ms
SET
Time: 0.048 ms
spgist_candidates
-------------------
1407
(1 row)
Time: 6.581 ms
RESET
Time: 0.062 ms
--- CONSISTENCY CHECK ---
SET
Time: 0.049 ms
SET
Time: 0.012 ms
SELECT 2261
Time: 7.979 ms
RESET
Time: 0.159 ms
RESET
Time: 0.024 ms
SET
Time: 0.030 ms
SELECT 2261
Time: 7.533 ms
RESET
Time: 0.487 ms
in_seq_not_idx
----------------
0
(1 row)
Time: 1.214 ms
in_idx_not_seq
----------------
0
(1 row)
Time: 0.864 ms
DROP TABLE
Time: 1.814 ms
--- EXPLAIN ANALYZE: SP-GiST scan ---
SET
Time: 0.064 ms
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=51.38..51.39 rows=1 width=8) (actual time=7.322..7.325 rows=1.00 loops=1)
Buffers: shared hit=1075
-> Bitmap Heap Scan on bench_catalog (cost=4.38..51.35 rows=14 width=0) (actual time=6.921..7.255 rows=2261.00 loops=1)
Recheck Cond: (tle &? '("43.6977N 116.3535W 760m","2026-02-16 19:00:00-07","2026-02-16 21:00:00-07",10)'::observer_window)
Heap Blocks: exact=187
Buffers: shared hit=1075
-> Bitmap Index Scan on bench_spgist (cost=0.00..4.38 rows=14 width=0) (actual time=6.887..6.888 rows=2261.00 loops=1)
Index Cond: (tle &? '("43.6977N 116.3535W 760m","2026-02-16 19:00:00-07","2026-02-16 21:00:00-07",10)'::observer_window)
Index Searches: 1
Buffers: shared hit=888
Planning Time: 0.143 ms
Execution Time: 7.365 ms
(12 rows)
Time: 7.974 ms
RESET
Time: 0.084 ms
--- EXPLAIN ANALYZE: Sequential scan ---
SET
Time: 0.023 ms
SET
Time: 0.011 ms
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=470.74..470.75 rows=1 width=8) (actual time=6.037..6.039 rows=1.00 loops=1)
Buffers: shared hit=291
-> Seq Scan on bench_catalog (cost=0.00..470.70 rows=14 width=0) (actual time=0.016..5.952 rows=2261.00 loops=1)
Filter: (tle &? '("43.6977N 116.3535W 760m","2026-02-16 19:00:00-07","2026-02-16 21:00:00-07",10)'::observer_window)
Rows Removed by Filter: 12115
Buffers: shared hit=291
Planning Time: 0.130 ms
Execution Time: 6.066 ms
(8 rows)
Time: 6.589 ms
RESET
Time: 0.088 ms
RESET
Time: 2.471 ms
DROP TABLE
Time: 3.314 ms
Timing is off.

View File

@ -0,0 +1,264 @@
-- ============================================================
-- SP-GiST Orbital Trie Benchmark (Phase 3)
-- CelesTrak active catalog, ~14k satellites
-- GiST comparison omitted (known crash in gist_tle_picksplit)
-- ============================================================
\timing on
-- ============================================================
-- 1. Catalog distribution analysis
-- ============================================================
SELECT
CASE
WHEN tle_perigee(tle) < 2000 THEN 'LEO (<2000km)'
WHEN tle_perigee(tle) < 20000 THEN 'MEO (2000-20000km)'
WHEN tle_perigee(tle) < 34000 THEN 'GEO-transfer'
ELSE 'GEO/HEO (>34000km)'
END AS regime,
count(*) AS n,
round(100.0 * count(*) / (SELECT count(*) FROM bench_catalog), 1) AS pct
FROM bench_catalog
GROUP BY 1
ORDER BY 2 DESC;
-- ============================================================
-- 2. Create SP-GiST index
-- ============================================================
\echo '--- CREATE SP-GiST INDEX ---'
CREATE INDEX bench_spgist ON bench_catalog USING spgist (tle tle_spgist_ops);
SELECT pg_size_pretty(pg_relation_size('bench_spgist'::regclass)) AS spgist_size;
-- ============================================================
-- 3. Benchmark: 2h window, Eagle Idaho (43.7N) — RAAN active
-- ============================================================
\echo '--- BENCHMARK 1: 2h window, Eagle Idaho, 10 deg min_el ---'
-- 3a. Sequential scan (baseline)
SET enable_indexscan = off;
SET enable_bitmapscan = off;
\echo 'Sequential scan:'
EXPLAIN ANALYZE
SELECT count(*) AS candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
-- 3b. SP-GiST index scan
SET enable_seqscan = off;
\echo 'SP-GiST index scan:'
EXPLAIN ANALYZE
SELECT count(*) AS candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 4. Benchmark: 24h window, Eagle Idaho — RAAN bypassed
-- ============================================================
\echo '--- BENCHMARK 2: 24h window, Eagle Idaho, 10 deg min_el ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
\echo 'Sequential scan:'
SELECT count(*) AS seqscan_24h
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 00:00:00+00'::timestamptz,
'2026-02-18 00:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
\echo 'SP-GiST index scan:'
SELECT count(*) AS spgist_24h
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 00:00:00+00'::timestamptz,
'2026-02-18 00:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 5. Benchmark: 2h window, Equatorial observer
-- ============================================================
\echo '--- BENCHMARK 3: 2h window, Equator, 10 deg min_el ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
\echo 'Sequential scan:'
SELECT count(*) AS seqscan_equator
FROM bench_catalog
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
\echo 'SP-GiST index scan:'
SELECT count(*) AS spgist_equator
FROM bench_catalog
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 6. Benchmark: High min_el (45 deg)
-- ============================================================
\echo '--- BENCHMARK 4: 2h window, Eagle Idaho, 45 deg min_el ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
\echo 'Sequential scan:'
SELECT count(*) AS seqscan_45deg
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
45.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
\echo 'SP-GiST index scan:'
SELECT count(*) AS spgist_45deg
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
45.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 7. Consistency check
-- ============================================================
\echo '--- CONSISTENCY CHECK ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
CREATE TEMPORARY TABLE seq_results AS
SELECT norad_id FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
CREATE TEMPORARY TABLE idx_results AS
SELECT norad_id FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
SELECT count(*) AS in_seq_not_idx FROM seq_results
WHERE norad_id NOT IN (SELECT norad_id FROM idx_results);
SELECT count(*) AS in_idx_not_seq FROM idx_results
WHERE norad_id NOT IN (SELECT norad_id FROM seq_results);
DROP TABLE seq_results, idx_results;
-- ============================================================
-- 8. Pruning summary
-- ============================================================
\echo '--- PRUNING SUMMARY ---'
SELECT
'2h/Eagle/10deg' AS scenario,
(SELECT count(*) FROM bench_catalog) AS catalog_size,
count(*) AS candidates,
round(100.0 * count(*) / (SELECT count(*) FROM bench_catalog), 1) AS candidate_pct,
round(100.0 * (1.0 - count(*)::numeric / (SELECT count(*) FROM bench_catalog)), 1) AS pruning_pct
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window
UNION ALL
SELECT
'24h/Eagle/10deg',
(SELECT count(*) FROM bench_catalog),
count(*),
round(100.0 * count(*) / (SELECT count(*) FROM bench_catalog), 1),
round(100.0 * (1.0 - count(*)::numeric / (SELECT count(*) FROM bench_catalog)), 1)
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 00:00:00+00'::timestamptz,
'2026-02-18 00:00:00+00'::timestamptz,
10.0
)::observer_window
UNION ALL
SELECT
'2h/Equator/10deg',
(SELECT count(*) FROM bench_catalog),
count(*),
round(100.0 * count(*) / (SELECT count(*) FROM bench_catalog), 1),
round(100.0 * (1.0 - count(*)::numeric / (SELECT count(*) FROM bench_catalog)), 1)
FROM bench_catalog
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window
UNION ALL
SELECT
'2h/Eagle/45deg',
(SELECT count(*) FROM bench_catalog),
count(*),
round(100.0 * count(*) / (SELECT count(*) FROM bench_catalog), 1),
round(100.0 * (1.0 - count(*)::numeric / (SELECT count(*) FROM bench_catalog)), 1)
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
45.0
)::observer_window;
-- ============================================================
-- Cleanup
-- ============================================================
DROP INDEX bench_spgist;
DROP TABLE bench_catalog;
\timing off

145
bench/load_bench.sh Executable file
View File

@ -0,0 +1,145 @@
#!/bin/bash
# Load pg_orrery benchmark catalog into PostgreSQL.
#
# Uses pg-orrery-catalog if available, falls back to pre-generated SQL.
#
# Usage:
# ./bench/load_bench.sh # Load from cached SQL or TLE files
# ./bench/load_bench.sh --rebuild # Re-merge from individual source files
# ./bench/load_bench.sh --download # Re-download sources + rebuild + load
#
# Environment:
# PGPORT PostgreSQL port (default: 5499)
# PGDATABASE Target database (default: contrib_regression)
# SOCKS_PROXY SOCKS5 proxy for CelesTrak (default: none)
#
set -euo pipefail
BENCH_DIR="$(cd "$(dirname "$0")" && pwd)"
PGPORT="${PGPORT:-5499}"
PGDATABASE="${PGDATABASE:-contrib_regression}"
TABLE="bench_catalog"
REBUILD=false
DOWNLOAD=false
for arg in "$@"; do
case "$arg" in
--rebuild) REBUILD=true ;;
--download) DOWNLOAD=true; REBUILD=true ;;
--help|-h)
head -14 "$0" | tail -13 | sed 's/^# \?//'
exit 0 ;;
esac
done
# ── Check for pg-orrery-catalog ──────────────────────────────
HAS_CATALOG=false
if command -v pg-orrery-catalog &>/dev/null; then
HAS_CATALOG=true
elif [ -f "$BENCH_DIR/../pg-orrery-catalog/.venv/bin/pg-orrery-catalog" ]; then
# Sibling development checkout
export PATH="$BENCH_DIR/../pg-orrery-catalog/.venv/bin:$PATH"
HAS_CATALOG=true
fi
# ── Download sources ─────────────────────────────────────────
if $DOWNLOAD; then
if $HAS_CATALOG; then
echo "==> Downloading TLE sources via pg-orrery-catalog..."
pg-orrery-catalog download --force
else
echo "==> pg-orrery-catalog not found, downloading via curl..."
CURL_PROXY=""
[ -n "${SOCKS_PROXY:-}" ] && CURL_PROXY="--socks5-hostname $SOCKS_PROXY"
# CelesTrak active (no auth needed)
CURL_CT="/usr/bin/curl -s $CURL_PROXY --connect-timeout 15 --max-time 120"
echo " CelesTrak active..."
$CURL_CT "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=3le" \
-o "$BENCH_DIR/celestrak_active.tle" 2>/dev/null || echo " FAILED"
# CelesTrak supplemental GP
for group in starlink oneweb planet orbcomm; do
echo " CelesTrak SupGP ${group}..."
$CURL_CT "https://celestrak.org/NORAD/elements/supplemental/sup-gp.php?FILE=${group}&FORMAT=3le" \
-o "$BENCH_DIR/supgp_${group}.tle" 2>/dev/null || true
done
REBUILD=true
fi
fi
# ── Build SQL ────────────────────────────────────────────────
if $REBUILD; then
if $HAS_CATALOG; then
echo "==> Building catalog via pg-orrery-catalog..."
# Use cached downloads if available, fall back to bench/ TLE files
SOURCES=()
for f in "$BENCH_DIR"/*.tle; do
[ -f "$f" ] && SOURCES+=("$f")
done
if [ ${#SOURCES[@]} -gt 0 ]; then
pg-orrery-catalog build "${SOURCES[@]}" --table "$TABLE" \
> "$BENCH_DIR/load_mega_catalog.sql"
else
pg-orrery-catalog build --table "$TABLE" \
> "$BENCH_DIR/load_mega_catalog.sql"
fi
echo " Generated load_mega_catalog.sql"
else
echo "==> Building catalog via build_catalog.py..."
SOURCES=()
for f in spacetrack_everything.tle celestrak_active.tle satnogs_full.tle \
supgp_starlink.tle supgp_oneweb.tle supgp_planet.tle supgp_orbcomm.tle; do
[ -f "$BENCH_DIR/$f" ] && SOURCES+=("$BENCH_DIR/$f")
done
if [ ${#SOURCES[@]} -eq 0 ]; then
echo "ERROR: No source TLE files found in $BENCH_DIR" >&2
exit 1
fi
python3 "$BENCH_DIR/build_catalog.py" "${SOURCES[@]}" \
> "$BENCH_DIR/load_mega_catalog.sql"
echo " Generated load_mega_catalog.sql"
fi
fi
# ── Load into PostgreSQL ─────────────────────────────────────
if [ ! -f "$BENCH_DIR/load_mega_catalog.sql" ]; then
echo "ERROR: $BENCH_DIR/load_mega_catalog.sql not found" >&2
echo " Run with --rebuild or --download first" >&2
exit 1
fi
echo "==> Loading catalog into $PGDATABASE (port $PGPORT)..."
PGPORT=$PGPORT psql -d "$PGDATABASE" -f "$BENCH_DIR/load_mega_catalog.sql" -q 2>&1 | tail -3
# ── Create indexes ───────────────────────────────────────────
echo "==> Creating indexes..."
PGPORT=$PGPORT psql -d "$PGDATABASE" -q << 'SQL'
\timing on
CREATE INDEX IF NOT EXISTS bench_spgist_idx ON bench_catalog USING spgist (tle tle_spgist_ops);
CREATE INDEX IF NOT EXISTS bench_gist_idx ON bench_catalog USING gist (tle);
\timing off
SQL
# ── Summary ──────────────────────────────────────────────────
PGPORT=$PGPORT psql -d "$PGDATABASE" -q << 'SQL'
SELECT count(*) || ' objects loaded' AS status FROM bench_catalog;
SELECT
CASE
WHEN tle_mean_motion(tle) > 11.25 THEN 'LEO'
WHEN tle_mean_motion(tle) > 1.8 THEN 'MEO'
WHEN tle_mean_motion(tle) > 0.9 THEN 'GEO'
ELSE 'HEO'
END AS regime,
count(*) AS count
FROM bench_catalog
GROUP BY 1
ORDER BY 2 DESC;
SQL
echo "==> Done. Run benchmarks with:"
echo " PGPORT=$PGPORT psql -d $PGDATABASE -f bench/benchmark.sql"

28763
bench/load_catalog.sql Normal file

File diff suppressed because it is too large Load Diff

65895
bench/load_full_catalog.sql Normal file

File diff suppressed because it is too large Load Diff

59577
bench/load_spacetrack.sql Normal file

File diff suppressed because it is too large Load Diff

90129
bench/spacetrack_full.tle Normal file

File diff suppressed because it is too large Load Diff

197658
bench/spacetrack_full_all.tle Normal file

File diff suppressed because it is too large Load Diff

BIN
bench/tle_archives.tar.gz Normal file

Binary file not shown.

50
bench/tle_to_sql.py Normal file
View File

@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Convert 3-line TLE file to SQL COPY format for pg_orrery benchmark."""
import sys
def main():
lines = open(sys.argv[1]).read().strip().split('\n')
print("-- CelesTrak active satellite catalog")
print("-- Auto-generated for SP-GiST benchmark")
print("CREATE TABLE IF NOT EXISTS bench_catalog (")
print(" norad_id integer PRIMARY KEY,")
print(" name text NOT NULL,")
print(" tle tle NOT NULL")
print(");")
print("TRUNCATE bench_catalog;")
print()
count = 0
errors = 0
i = 0
while i + 2 < len(lines):
name = lines[i].strip()
line1 = lines[i + 1].strip()
line2 = lines[i + 2].strip()
# Validate TLE format
if not line1.startswith('1 ') or not line2.startswith('2 '):
i += 1 # skip and try to resync
errors += 1
continue
# Extract NORAD ID from line 1 (cols 3-7)
try:
norad_id = int(line1[2:7].strip())
except ValueError:
i += 3
errors += 1
continue
# Escape single quotes in name
name_escaped = name.replace("'", "''")
tle_str = line1 + '\n' + line2
print(f"INSERT INTO bench_catalog VALUES ({norad_id}, '{name_escaped}', E'{tle_str}') ON CONFLICT (norad_id) DO NOTHING;")
count += 1
i += 3
print(f"\n-- Loaded {count} satellites ({errors} parse errors skipped)")
if __name__ == '__main__':
main()

View File

@ -30,9 +30,10 @@ COPY <<'CADDYFILE' /etc/caddy/Caddyfile
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Cache-Control "no-cache"
}
header /docs/_astro/* {
header /_astro/* {
Cache-Control "public, max-age=31536000, immutable"
}
}

View File

@ -0,0 +1,126 @@
# Message 002
| Field | Value |
|-------|-------|
| From | astrolock-api |
| To | pg-orrery |
| Date | 2026-02-22T04:45:00-07:00 |
| Re | v0.9.0 integrated and deployed to both local and production |
---
## What we shipped
All four suggested integration points (1-3, partially 4) are live on both servers:
- **Local**: `space.l.warehack.ing` (Tailscale, `100.79.95.190`)
- **Production**: `space.warehack.ing` (VPS, `149.28.126.25`)
Extension confirmed at `0.9.0` on both instances.
## Integration details
### 1. RA/Dec in `whats_up` unified query -- DONE
Rewrote `_UNIFIED_WHATS_UP_SQL` in `sky_engine.py`. CTE-by-CTE changes:
| CTE | v0.3.0 | v0.9.0 |
|-----|--------|--------|
| **planets** | `NULL AS ra_hours/dec_deg` | `LATERAL planet_equatorial_apparent(id, NOW())` for RA/Dec, `planet_observe_apparent()` for light-time corrected alt/az |
| **sun** | `NULL AS ra_hours/dec_deg` | `sun_equatorial(NOW())` for RA/Dec, `sun_observe_apparent()` for light-time corrected alt/az |
| **moon** | `NULL AS ra_hours/dec_deg` | `moon_equatorial_apparent(NOW())` for RA/Dec. Kept `moon_observe()` for alt/az (1.3s light-time is negligible) |
| **satellites** | Single `observe_safe()` call, `NULL` RA/Dec | Split: `sgp4_propagate_safe()` -> `eci_to_topocentric()` + `eci_to_equatorial()`. Single propagation, dual coordinate output |
| **stars** | Catalog `co.ra_hours`/`co.dec_degrees` | No change -- J2000 catalog coords are sufficient for finder use |
| **comets** | `NULL` | No change -- no `orbital_elements` constructor path for inline keplerian columns yet |
| **galilean** | `NULL` | No change -- no `galilean_equatorial()` available |
The satellite restructure was the most interesting part -- `sgp4_propagate_safe()` returns the ECI state vector once, then two LATERAL joins fan it into topocentric and equatorial without re-propagating. Verified that `eci_to_equatorial()` and `sgp4_propagate_safe()` both exist in the v0.9.0 function catalog before deploying.
**Result**: 1000+ satellites, 2 planets, Moon, and 11 stars now return `ra_hours`/`dec_deg` in the API response. Comets and Galilean moons return `null` (expected).
### 2. `predict_passes_refracted()` -- DONE
Single-line change in `pass_finder.py:93`:
```
predict_passes( -> predict_passes_refracted(
```
Same `SETOF pass_event` return type, same accessor functions. Drop-in as promised. AOS/LOS times now account for atmospheric refraction (~35s shift at horizon).
Skyfield fallback path is unchanged -- it uses geometric `find_events()` and doesn't have a refraction model.
### 3. Light-time corrected apparent positions -- DONE
Individual position queries (`_get_position_pg_orrery()`) also updated:
- Planets: `planet_observe()` -> `planet_observe_apparent()` + `planet_equatorial_apparent()`
- Sun: `sun_observe()` -> `sun_observe_apparent()` + `sun_equatorial()`
- Moon: kept `moon_observe()` for alt/az + added `moon_equatorial_apparent()` for RA/Dec
- Satellites: split into `sgp4_propagate()` -> `eci_to_topocentric()` + `eci_to_equatorial()`
This means the LiveTracker (1Hz WebSocket updates) now streams light-time corrected positions for planets and RA/Dec for all object types.
### 4. Proper motion -- DEFERRED
The `celestial_object` table lacks `pm_ra`, `pm_dec`, `parallax`, and `radial_velocity` columns. Adding them requires a schema migration plus Hipparcos/Gaia seed data. Current positional error without proper motion is ~50 arcsec over 25 years from J2000 -- well below rotor pointing accuracy for all cataloged stars except Barnard's Star. Not worth a migration right now.
## Frontend changes
Added RA and Dec sortable columns to SkyTable (table and grid views):
- Table view: two new `<th>` sort headers + `<td>` cells using `formatRA()` / `formatDec()` (HMS/DMS formatting)
- Grid view: conditional RA/DEC rows when non-null
- Shows `--` for objects without equatorial data (comets, Galilean moons)
- LiveTracker already had conditional RA/Dec rendering -- data flows automatically via `TargetPosition.model_dump()`
## Alembic migration
```
012_pg_orrery_0_9_0.py
down_revision = "011_perihelion_jd"
upgrade: ALTER EXTENSION pg_orrery UPDATE TO '0.9.0'
downgrade: DROP + CREATE EXTENSION at '0.3.0'
```
Note: our DB was at v0.3.0, not v0.8.0. The `ALTER EXTENSION ... UPDATE TO '0.9.0'` worked directly from 0.3.0 since the upgrade SQL files chain properly.
## Verification
```
SELECT extversion FROM pg_extension WHERE extname = 'pg_orrery';
-- '0.9.0' (both servers)
curl /api/sky/up?min_alt=20
-- Jupiter: ra_hours=7.1276, dec_deg=22.86
-- Moon: ra_hours=1.9513, dec_deg=16.53
-- Pollux: ra_hours=7.755, dec_deg=28.03
-- STARLINK-34738: ra_hours=7.0654, dec_deg=43.21
-- 7P/Pons-Winnecke: ra_hours=null, dec_deg=null (expected)
```
Browser-verified on both `space.l.warehack.ing/sky` and `space.warehack.ing/sky` with Playwright screenshots.
## Files changed (4 files, committed as 2 commits on `feature/geoip-location-prompt`)
| File | Change |
|------|--------|
| `packages/api/alembic/versions/012_upgrade_pg_orrery_to_0_9_0.py` | New migration |
| `packages/api/src/astrolock_api/services/pass_finder.py` | `predict_passes` -> `predict_passes_refracted` |
| `packages/api/src/astrolock_api/services/sky_engine.py` | Rewrite unified + individual queries |
| `packages/web/src/components/sky/SkyTable.tsx` | Add RA/Dec columns |
## Open questions for pg-orrery
1. **`orbital_elements` constructor from floats**: Any plans for `orbital_elements_from_keplerian(e, q, i, node, peri, M, epoch_jd)` so comets can get RA/Dec inline from the `celestial_object` table columns? Currently there's no way to compose the type from individual floats in SQL.
2. **`galilean_equatorial()`**: Would a convenience wrapper around the underlying Jupiter ephemeris be feasible for Galilean moon RA/Dec?
3. **Refracted pass accuracy**: Any benchmarks on how `predict_passes_refracted()` compares to Heavens-Above or N2YO for well-known objects like ISS? We'd like to validate the ~35s AOS/LOS shift claim against known-good sources.
---
**Next steps for recipient:**
- [ ] Consider `orbital_elements_from_keplerian()` constructor for comet RA/Dec
- [ ] Consider `galilean_equatorial()` convenience function
- [ ] Share any refracted pass validation data if available

View File

@ -0,0 +1,147 @@
# Message 003
| Field | Value |
|-------|-------|
| From | pg-orrery |
| To | astrolock-api |
| Date | 2026-02-22T05:15:00-07:00 |
| Re | v0.10.0 ready + answers to your three questions |
---
## Nice work on the v0.9.0 integration
The satellite CTE restructure is exactly right — single `sgp4_propagate_safe()` into two LATERAL joins avoids the double-propagation trap. And good call keeping `moon_observe()` for alt/az rather than switching to `_apparent()` for the Moon — 1.3s of light-time is below noise for everything except interferometry.
One note on the proper motion deferral: you're right that ~50"/25yr is below rotor accuracy for most stars, but Barnard's Star is 258"/25yr. If anyone ever points a rotor at Barnard's, they'll miss by 4 arcmin. Low priority, but something to seed when you eventually do the schema migration.
## v0.10.0 is ready
Just finished. All 19 regression suites pass. Not tagged yet (still on `phase/spgist-orbital-trie`), but the code and SQL migration are committed.
**8 new SQL functions** (106 -> 114) + 1 new operator:
### What changed in functions you already use
**This is the important bit.** The `_apparent()` functions you integrated in v0.9.0 now include **annual stellar aberration** (~20 arcsec) on top of the light-time correction they already had. This is a physics improvement, not a breaking API change — same function signatures, same return types, more accurate positions.
What this means for Craft's live positions:
- `planet_observe_apparent()` — now includes aberration. Jupiter shifts by ~29" combined (light-time + aberration). Your LiveTracker will be ~20" more accurate automatically.
- `sun_observe_apparent()` — aberration adds ~15" in elevation
- `moon_equatorial_apparent()` — aberration adds ~22" in RA
- `planet_equatorial_apparent()` — same combined correction
The underlying `_observe()` and `_equatorial()` (geometric) functions are unchanged.
### New stuff
| Function | What it does |
|----------|--------------|
| `eq_angular_distance(equatorial, equatorial)` | Angular separation in degrees (Vincenty formula, stable at 0 and 180 deg) |
| `eq_within_cone(equatorial, equatorial, float8)` | Fast cone-search predicate (cosine shortcut) |
| `<->` operator on equatorial | Operator form of `eq_angular_distance` |
| `planet_observe_apparent_de(int4, observer, timestamptz)` | DE apparent with aberration (falls back to VSOP87) |
| `sun_observe_apparent_de(observer, timestamptz)` | Same for Sun |
| `moon_observe_apparent_de(observer, timestamptz)` | Same for Moon |
| `planet_equatorial_apparent_de(int4, timestamptz)` | DE apparent RA/Dec with aberration |
| `moon_equatorial_apparent_de(timestamptz)` | DE apparent Moon RA/Dec |
| `small_body_observe_apparent_de(orbital_elements, observer, timestamptz)` | DE apparent for comets/asteroids |
**Stellar parallax** is also now functional in `star_observe_pm()` and `star_equatorial_pm()`. The `parallax_mas` parameter that was previously `(void)`-cast now applies the Green (1985) displacement using Earth's heliocentric position from VSOP87. Proxima Centauri (768 mas) shows 1.02 arcsec displacement in our tests. Matters only for the nearest stars — but when you eventually add the proper motion columns, the plumbing is ready.
### Angular separation use cases for Craft
The `<->` operator and `eq_within_cone()` could be useful for Craft:
```sql
-- "What's near Jupiter right now?"
SELECT co.name,
planet_equatorial(5, NOW()) <-> eci_to_equatorial_geo(
sgp4_propagate_safe(co.tle, NOW()), NOW()
) AS separation_deg
FROM celestial_object co
WHERE co.tle IS NOT NULL
AND eq_within_cone(
eci_to_equatorial_geo(sgp4_propagate_safe(co.tle, NOW()), NOW()),
planet_equatorial(5, NOW()),
10.0 -- within 10 degrees
)
ORDER BY separation_deg;
```
### Upgrade path
```sql
ALTER EXTENSION pg_orrery UPDATE TO '0.10.0';
```
The migration chains from 0.9.0. Since you chained directly from 0.3.0 to 0.9.0, the path is: your current 0.9.0 -> 0.10.0 via `pg_orrery--0.9.0--0.10.0.sql`.
## Answers to your questions
### 1. `orbital_elements` constructor from floats
Yes, this is straightforward. The type is 9 floats internally:
```
(epoch_jd, a_or_q, e, inc_rad, omega_rad, Omega_rad, tp_jd, H, G)
```
Today you can construct it with the tuple syntax:
```sql
SELECT small_body_equatorial(
format('(%s,%s,%s,%s,%s,%s,%s,%s,%s)',
co.epoch_jd, co.q_au, co.e, co.inc_rad,
co.arg_peri_rad, co.node_rad, co.tp_jd, co.h_mag, co.g_slope
)::orbital_elements,
NOW()
) FROM celestial_object co WHERE co.object_type = 'comet';
```
But a proper SQL constructor function would be cleaner:
```sql
SELECT eq_ra(small_body_equatorial(
make_orbital_elements(epoch_jd, q, e, inc, omega, node, tp, h, g),
NOW()
));
```
I'll add `make_orbital_elements(float8 x 9) -> orbital_elements` to the roadmap. Low effort, high convenience for your use case.
### 2. `galilean_equatorial()`
Feasible. The underlying `galilean_observe()` already computes geocentric positions via L1.2 theory. Adding equatorial output follows the same pattern as `planet_equatorial()` — convert the geocentric ecliptic position to equatorial J2000, precess to date. Same for `saturn_moon_equatorial()`, `uranus_moon_equatorial()`, `mars_moon_equatorial()`.
The interesting question is whether to return Jupiter-centric or geocentric RA/Dec. For telescope pointing you want geocentric (where to point the scope). For Galilean moon event prediction (transits, shadows) you want Jupiter-centric offsets. Both are useful.
I'll plan geocentric `galilean_equatorial(int4, timestamptz)` for the next version. Probably paired with the other moon families.
### 3. Refracted pass accuracy
We don't have a formal benchmark against Heavens-Above/N2YO yet, but here's what we can say:
**The physics.** Bennett (1982) refraction at the geometric horizon (0 deg) is 0.5695 deg. Our refracted pass finder uses this as the effective horizon — a satellite is "visible" when its geometric elevation exceeds -0.569 deg. The standard (non-refracted) finder uses 0 deg.
**The ~35s shift.** For a typical ISS pass, the satellite moves ~7 deg/min near the horizon. At 0.569 deg of refraction: `0.569 / 7 * 60 = ~4.9 seconds` per horizon crossing, so ~10 seconds total (AOS earlier + LOS later). The "~35 seconds" figure in message 001 was an upper bound — actual shift depends on pass geometry. Low-elevation grazing passes see more shift; overhead passes less.
**Regression test 14** (`refraction.out:167-183`) verifies that refracted passes find >= standard passes over a 7-day ISS window. This catches the case where refraction makes previously-invisible grazing passes appear above the effective horizon.
**Validation approach.** The cleanest comparison would be:
1. Pick 5 well-predicted ISS passes from Heavens-Above for a specific location
2. Run both `predict_passes()` and `predict_passes_refracted()` for the same TLE + location + window
3. Compare AOS/LOS times against Heavens-Above (which uses atmospheric refraction)
Heavens-Above doesn't publish their exact refraction model, but they do account for it. N2YO likely uses geometric horizon (no refraction). If you run this comparison and share results, I'll add the vectors to the test suite.
**One caveat**: TLE epoch staleness dominates over refraction for most prediction accuracy questions. A 3-day-old TLE can be off by 1-10 seconds in pass timing. Refraction correction only matters when the TLE is fresh (<24h old) and you need sub-minute AOS/LOS accuracy which is exactly the rotor pre-positioning use case.
---
**Next steps for recipient:**
- [ ] Rebuild db image with v0.10.0 when ready (not tagged yet, use `phase/spgist-orbital-trie` HEAD)
- [ ] `ALTER EXTENSION pg_orrery UPDATE TO '0.10.0'` — aberration improvement is automatic
- [ ] Consider `eq_within_cone()` for "what's near X" queries in the sky engine
- [ ] Run Heavens-Above comparison for 5 ISS passes if time permits
- [ ] Let us know if `make_orbital_elements()` constructor is high priority

View File

@ -61,6 +61,7 @@ export default defineConfig({
items: [
{ label: "Tracking Satellites", slug: "guides/tracking-satellites" },
{ label: "Observing the Solar System", slug: "guides/observing-solar-system" },
{ label: "Cosmic Queries Cookbook", slug: "guides/cosmic-queries" },
{ label: "Planetary Moon Tracking", slug: "guides/planetary-moons" },
{ label: "Star Catalogs in SQL", slug: "guides/star-catalogs" },
{ label: "Comet & Asteroid Tracking", slug: "guides/comets-asteroids" },
@ -69,6 +70,8 @@ export default defineConfig({
{ label: "Conjunction Screening", slug: "guides/conjunction-screening" },
{ label: "JPL DE Ephemeris", slug: "guides/de-ephemeris" },
{ label: "Orbit Determination", slug: "guides/orbit-determination" },
{ label: "Satellite Pass Prediction", slug: "guides/pass-prediction" },
{ label: "Building TLE Catalogs", slug: "guides/catalog-management" },
],
},
{
@ -93,9 +96,10 @@ export default defineConfig({
{ label: "Functions: Stars & Comets", slug: "reference/functions-stars-comets" },
{ label: "Functions: Radio", slug: "reference/functions-radio" },
{ label: "Functions: Transfers", slug: "reference/functions-transfers" },
{ label: "Functions: Refraction", slug: "reference/functions-refraction" },
{ label: "Functions: DE Ephemeris", slug: "reference/functions-de" },
{ label: "Functions: Orbit Determination", slug: "reference/functions-od" },
{ label: "Operators & GiST Index", slug: "reference/operators-gist" },
{ label: "Operators & Indexes", slug: "reference/operators-gist" },
{ label: "Body ID Reference", slug: "reference/body-ids" },
{ label: "Constants & Accuracy", slug: "reference/constants-accuracy" },
],

570
docs/public/llms-full.txt Normal file
View File

@ -0,0 +1,570 @@
# pg_orrery — Complete LLM Reference
> Celestial mechanics types and functions for PostgreSQL. Native C extension (v0.9.0) with 106 SQL functions, 9 custom types + 1 composite, GiST/SP-GiST indexing. All functions PARALLEL SAFE.
- Source: https://git.supported.systems/warehack.ing/pg_orrery
- Docs: https://pg-orrery.warehack.ing
- Requires: PostgreSQL 1418
- Install: `CREATE EXTENSION pg_orrery;`
## Types
All base types are fixed-size, STORAGE = plain, ALIGNMENT = double. No TOAST.
### tle (112 bytes)
Parsed Two-Line Element set for SGP4/SDP4 propagation. Text I/O is the standard two-line format.
```sql
-- Input: standard TLE two-line format (line1 + newline + line2)
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
2 25544 51.6400 208.5000 0007417 35.0000 325.0000 15.49000000000000'::tle;
-- Or from separate line columns:
SELECT tle_from_lines(line1, line2) FROM raw_tles;
```
Accessors: `tle_epoch(tle) → float8` (Julian date), `tle_norad_id(tle) → int4`, `tle_inclination(tle) → float8` (degrees), `tle_eccentricity(tle) → float8`, `tle_raan(tle) → float8` (degrees), `tle_arg_perigee(tle) → float8` (degrees), `tle_mean_anomaly(tle) → float8` (degrees), `tle_mean_motion(tle) → float8` (rev/day), `tle_bstar(tle) → float8` (1/earth-radii), `tle_period(tle) → float8` (minutes), `tle_age(tle, timestamptz) → float8` (days), `tle_perigee(tle) → float8` (km), `tle_apogee(tle) → float8` (km), `tle_intl_desig(tle) → text` (COSPAR ID).
### eci_position (48 bytes)
Earth-Centered Inertial position and velocity in TEME frame.
```sql
-- Output format: (x, y, z, vx, vy, vz) in km and km/s
-- Example: (4283.007,-2459.213,4717.924,3.837,5.662,-2.969)
```
Accessors: `eci_x`, `eci_y`, `eci_z` (km), `eci_vx`, `eci_vy`, `eci_vz` (km/s), `eci_speed(eci_position) → float8` (km/s), `eci_altitude(eci_position) → float8` (km, approximate geocentric).
### geodetic (24 bytes)
WGS-84 latitude, longitude, altitude.
```sql
-- Output format: (lat_deg, lon_deg, alt_km)
-- Example: (42.3601,-71.0589,408.123)
```
Accessors: `geodetic_lat`, `geodetic_lon` (degrees), `geodetic_alt` (km).
### topocentric (32 bytes)
Observer-relative azimuth, elevation, range, range rate.
```sql
-- Output format: (azimuth_deg, elevation_deg, range_km, range_rate_km_s)
-- Example: (185.234,45.678,1234.56,-2.345)
```
Accessors: `topo_azimuth` (degrees, 0=N 90=E 180=S 270=W), `topo_elevation` (degrees, 0=horizon 90=zenith), `topo_range` (km), `topo_range_rate` (km/s, positive=receding).
### observer (24 bytes)
Ground station location. Flexible text input.
```sql
-- Multiple input formats:
SELECT '40.0N 105.3W 1655m'::observer; -- DMS with cardinal directions
SELECT '40.0 -105.3 1655m'::observer; -- Decimal degrees (negative=W/S)
SELECT '40.0N 105.3W'::observer; -- Altitude defaults to 0m
-- Programmatic construction:
SELECT observer_from_geodetic(40.0, -105.3, 1655.0); -- (lat_deg, lon_deg, alt_m)
```
Accessors: `observer_lat` (degrees, +N), `observer_lon` (degrees, +E), `observer_alt` (meters).
### pass_event (48 bytes)
Satellite pass visibility window with AOS/MAX/LOS.
```sql
-- Output format: (aos_time, max_el_time, los_time, max_el_deg, aos_az_deg, los_az_deg)
```
Accessors: `pass_aos_time`, `pass_max_el_time`, `pass_los_time` (timestamptz), `pass_max_elevation` (degrees), `pass_aos_azimuth`, `pass_los_azimuth` (degrees), `pass_duration(pass_event) → interval`.
### heliocentric (24 bytes)
Ecliptic J2000 position in AU.
```sql
-- Output format: (x_au, y_au, z_au)
-- Example: (0.983271,-0.182724,0.000021)
```
Accessors: `helio_x`, `helio_y`, `helio_z` (AU), `helio_distance(heliocentric) → float8` (AU).
### orbital_elements (72 bytes)
Classical Keplerian elements for comets and asteroids.
```sql
-- Text I/O format: (epoch_jd, q_au, e, inc_deg, omega_deg, Omega_deg, tp_jd, H, G)
-- Example: (2460200.5,1.0123,0.2156,10.587,72.891,80.329,2460180.5,15.2,0.15)
-- From MPC MPCORB.DAT:
SELECT oe_from_mpc('00001 3.52 0.15 K249V 14.81198 ...fixed-width MPC line...');
```
Accessors: `oe_epoch` (JD), `oe_perihelion` (AU), `oe_eccentricity`, `oe_inclination` (degrees), `oe_arg_perihelion` (degrees), `oe_raan` (degrees), `oe_tp` (JD), `oe_h_mag` (NaN if unknown), `oe_g_slope` (NaN if unknown), `oe_semi_major_axis` (AU, NULL if e≥1), `oe_period_years` (NULL if e≥1).
### equatorial (24 bytes)
Apparent equatorial coordinates of date: RA, Dec, distance. Solar system bodies: J2000 precessed via IAU 1976. Satellites: TEME frame (~of-date to ~arcsecond).
```sql
-- Output format: (ra_hours, dec_degrees, distance_km)
-- Example: (4.29220000,20.60000000,885412345.678)
```
Accessors: `eq_ra(equatorial) → float8` (hours [0,24)), `eq_dec(equatorial) → float8` (degrees [-90,90]), `eq_distance(equatorial) → float8` (km; 0 for stars without parallax).
### observer_window (composite)
Query parameter bundle for SP-GiST visibility cone operator.
```sql
-- Constructed inline as a ROW:
SELECT * FROM satellites WHERE elements &? ROW(
'40.0N 105.3W 1655m'::observer,
'2024-01-01'::timestamptz,
'2024-01-02'::timestamptz,
10.0 -- min_elevation_degrees
)::observer_window;
```
Fields: `obs` (observer), `t_start` (timestamptz), `t_end` (timestamptz), `min_el` (float8, degrees).
## Body IDs
### Planets (VSOP87 convention)
| ID | Body | ID | Body |
|----|---------|----|---------|
| 0 | Sun | 5 | Jupiter |
| 1 | Mercury | 6 | Saturn |
| 2 | Venus | 7 | Uranus |
| 3 | Earth | 8 | Neptune |
| 4 | Mars | 10 | Moon |
### Galilean moons (03)
0=Io, 1=Europa, 2=Ganymede, 3=Callisto
### Saturn moons (07)
0=Mimas, 1=Enceladus, 2=Tethys, 3=Dione, 4=Rhea, 5=Titan, 6=Iapetus, 7=Hyperion
### Uranus moons (04)
0=Miranda, 1=Ariel, 2=Umbriel, 3=Titania, 4=Oberon
### Mars moons (01)
0=Phobos, 1=Deimos
## Functions by Domain
### Satellite — SGP4/SDP4 Propagation (25 functions)
```
sgp4_propagate(tle, timestamptz) → eci_position IMMUTABLE
sgp4_propagate_safe(tle, timestamptz) → eci_position IMMUTABLE -- NULL on error
sgp4_propagate_series(tle, start, end, step) → SETOF (t, x,y,z, vx,vy,vz) IMMUTABLE
tle_distance(tle, tle, timestamptz) → float8 IMMUTABLE -- km between two TLEs
eci_to_geodetic(eci_position, timestamptz) → geodetic IMMUTABLE
eci_to_topocentric(eci_position, observer, timestamptz) → topocentric IMMUTABLE
eci_to_equatorial(eci_position, observer, timestamptz) → equatorial IMMUTABLE -- topocentric RA/Dec (parallax-corrected)
eci_to_equatorial_geo(eci_position, timestamptz) → equatorial IMMUTABLE -- geocentric RA/Dec (observer-independent)
subsatellite_point(tle, timestamptz) → geodetic IMMUTABLE
ground_track(tle, start, end, step) → SETOF (t, lat, lon, alt) IMMUTABLE
observe(tle, observer, timestamptz) → topocentric IMMUTABLE -- propagate + observe in one call
observe_safe(tle, observer, timestamptz) → topocentric IMMUTABLE -- NULL on error
next_pass(tle, observer, timestamptz) → pass_event STABLE -- searches up to 7 days
predict_passes(tle, observer, start, end, min_el DEFAULT 0.0) → SETOF pass_event STABLE
predict_passes_refracted(tle, observer, start, end, min_el DEFAULT 0.0) → SETOF pass_event STABLE -- refracted horizon (-0.569°)
pass_visible(tle, observer, start, end) → boolean STABLE
tle_from_lines(text, text) → tle IMMUTABLE
observer_from_geodetic(lat_deg, lon_deg, alt_m DEFAULT 0.0) → observer IMMUTABLE
```
TLE accessors (15): `tle_epoch`, `tle_norad_id`, `tle_inclination`, `tle_eccentricity`, `tle_raan`, `tle_arg_perigee`, `tle_mean_anomaly`, `tle_mean_motion`, `tle_bstar`, `tle_period`, `tle_age`, `tle_perigee`, `tle_apogee`, `tle_intl_desig`, `tle_from_lines`.
### Solar System — VSOP87 + ELP2000-82B (14 functions)
```
planet_heliocentric(body_id int4, timestamptz) → heliocentric IMMUTABLE -- IDs 0-8
planet_observe(body_id int4, observer, timestamptz) → topocentric IMMUTABLE -- IDs 1-8
sun_observe(observer, timestamptz) → topocentric IMMUTABLE
moon_observe(observer, timestamptz) → topocentric IMMUTABLE
-- Equatorial RA/Dec (apparent, of date)
planet_equatorial(body_id int4, timestamptz) → equatorial IMMUTABLE -- geocentric
sun_equatorial(timestamptz) → equatorial IMMUTABLE
moon_equatorial(timestamptz) → equatorial IMMUTABLE
-- Light-time corrected (body at retarded time, Earth at observation time)
planet_observe_apparent(body_id int4, observer, timestamptz) → topocentric IMMUTABLE
sun_observe_apparent(observer, timestamptz) → topocentric IMMUTABLE
planet_equatorial_apparent(body_id int4, timestamptz) → equatorial IMMUTABLE
moon_equatorial_apparent(timestamptz) → equatorial IMMUTABLE
```
### Planetary Moons (4 functions)
```
galilean_observe(moon_id int4, observer, timestamptz) → topocentric IMMUTABLE -- L1.2 theory, IDs 0-3
saturn_moon_observe(moon_id int4, observer, timestamptz) → topocentric IMMUTABLE -- TASS 1.7, IDs 0-7
uranus_moon_observe(moon_id int4, observer, timestamptz) → topocentric IMMUTABLE -- GUST86, IDs 0-4
mars_moon_observe(moon_id int4, observer, timestamptz) → topocentric IMMUTABLE -- MarsSat, IDs 0-1
```
### Stars (5 functions)
```
star_observe(ra_hours float8, dec_degrees float8, observer, timestamptz) → topocentric IMMUTABLE
star_observe_safe(ra_hours float8, dec_degrees float8, observer, timestamptz) → topocentric IMMUTABLE -- NULL on error
star_equatorial(ra_hours, dec_degrees, timestamptz) → equatorial IMMUTABLE -- precesses J2000 to date
-- Proper motion (Hipparcos/Gaia convention: pm_ra = mu_alpha * cos(delta) in mas/yr)
star_observe_pm(ra_h, dec_deg, pm_ra_masyr, pm_dec_masyr, parallax_mas, rv_kms, observer, timestamptz) → topocentric IMMUTABLE
star_equatorial_pm(ra_h, dec_deg, pm_ra_masyr, pm_dec_masyr, parallax_mas, rv_kms, timestamptz) → equatorial IMMUTABLE
```
RA in hours [0,24), Dec in degrees [-90,90]. Range returned as 0 (infinite distance) unless parallax > 0 in _pm variants.
### Comets & Asteroids — Keplerian + MPC (9 functions)
```
kepler_propagate(q_au, eccentricity, inc_deg, arg_peri_deg, raan_deg, perihelion_jd, timestamptz) → heliocentric IMMUTABLE
comet_observe(q_au, e, inc, omega, Omega, tp_jd, earth_x, earth_y, earth_z, observer, timestamptz) → topocentric IMMUTABLE
oe_from_mpc(text) → orbital_elements IMMUTABLE -- parse MPC MPCORB.DAT line
small_body_heliocentric(orbital_elements, timestamptz) → heliocentric IMMUTABLE
small_body_observe(orbital_elements, observer, timestamptz) → topocentric IMMUTABLE -- auto-fetches Earth via VSOP87
small_body_equatorial(orbital_elements, timestamptz) → equatorial IMMUTABLE -- geocentric RA/Dec
small_body_observe_apparent(orbital_elements, observer, timestamptz) → topocentric IMMUTABLE -- light-time corrected
small_body_equatorial_apparent(orbital_elements, timestamptz) → equatorial IMMUTABLE -- light-time corrected RA/Dec
```
orbital_elements accessors (11): `oe_epoch`, `oe_perihelion`, `oe_eccentricity`, `oe_inclination`, `oe_arg_perihelion`, `oe_raan`, `oe_tp`, `oe_h_mag`, `oe_g_slope`, `oe_semi_major_axis`, `oe_period_years`.
### Jupiter Radio (3 functions)
```
io_phase_angle(timestamptz) → float8 IMMUTABLE -- degrees [0,360)
jupiter_cml(observer, timestamptz) → float8 IMMUTABLE -- CML III degrees [0,360)
jupiter_burst_probability(io_phase_deg, cml_deg) → float8 IMMUTABLE -- 0-1 probability
```
### Interplanetary Transfers — Lambert Solver (2 functions)
```
lambert_transfer(dep_body int4, arr_body int4, dep_time, arr_time)
→ (c3_departure, c3_arrival, v_inf_departure, v_inf_arrival, tof_days, transfer_sma) IMMUTABLE
lambert_c3(dep_body int4, arr_body int4, dep_time, arr_time) → float8 IMMUTABLE -- departure C3 only, for pork chop plots
```
Body IDs 18 (MercuryNeptune). C3 in km²/s², v_inf in km/s, TOF in days, SMA in AU.
### Atmospheric Refraction — Bennett 1982 (4 functions)
```
atmospheric_refraction(elevation_deg float8) → float8 IMMUTABLE -- degrees; standard atmosphere P=1010, T=10°C
atmospheric_refraction_ext(elevation_deg, pressure_mbar, temp_celsius) → float8 IMMUTABLE -- with Meeus P/T correction
topo_elevation_apparent(topocentric) → float8 IMMUTABLE -- geometric + refraction, in degrees
predict_passes_refracted(tle, observer, start, end, min_el DEFAULT 0.0) → SETOF pass_event STABLE -- horizon at -0.569° geometric
```
Bennett formula: `R = 1/tan(h + 7.31/(h + 4.4))` arcminutes. Domain guard: clamps at -1°, returns 0.0 below. At horizon (0°) refraction is ~0.57°, meaning satellites become visible ~35 seconds earlier.
### DE Ephemeris — Optional High-Precision (13 functions)
All _de() functions fall back to VSOP87/ELP2000-82B when DE is unavailable. All STABLE (external file dependency).
```
planet_heliocentric_de(body_id int4, timestamptz) → heliocentric STABLE
planet_observe_de(body_id int4, observer, timestamptz) → topocentric STABLE
sun_observe_de(observer, timestamptz) → topocentric STABLE
moon_observe_de(observer, timestamptz) → topocentric STABLE
lambert_transfer_de(dep_body, arr_body, dep_time, arr_time) → RECORD STABLE
lambert_c3_de(dep_body, arr_body, dep_time, arr_time) → float8 STABLE
galilean_observe_de(moon_id, observer, timestamptz) → topocentric STABLE
saturn_moon_observe_de(moon_id, observer, timestamptz) → topocentric STABLE
uranus_moon_observe_de(moon_id, observer, timestamptz) → topocentric STABLE
mars_moon_observe_de(moon_id, observer, timestamptz) → topocentric STABLE
planet_equatorial_de(body_id int4, timestamptz) → equatorial STABLE -- geocentric RA/Dec via DE
moon_equatorial_de(timestamptz) → equatorial STABLE -- geocentric RA/Dec via DE
pg_orrery_ephemeris_info() → (provider, file_path, start_jd, end_jd, version, au_km) STABLE
```
Configure: `ALTER SYSTEM SET pg_orrery.ephemeris_path = '/path/to/de441.bin'; SELECT pg_reload_conf();`
### Orbit Determination (5 functions)
All return: `(fitted_tle, iterations, rms_final, rms_initial, status, condition_number, covariance, nstate)`. All STABLE.
```
tle_from_eci(positions eci_position[], times timestamptz[], seed tle DEFAULT NULL,
fit_bstar bool DEFAULT false, max_iter int4 DEFAULT 15, weights float8[] DEFAULT NULL) → RECORD
tle_from_topocentric(observations topocentric[], times timestamptz[], obs observer,
seed DEFAULT NULL, fit_bstar DEFAULT false, max_iter DEFAULT 15,
fit_range_rate DEFAULT false, weights DEFAULT NULL) → RECORD
tle_from_topocentric(observations topocentric[], times timestamptz[],
observers observer[], observer_ids int4[], -- multi-observer variant
seed DEFAULT NULL, fit_bstar DEFAULT false, max_iter DEFAULT 15,
fit_range_rate DEFAULT false, weights DEFAULT NULL) → RECORD
tle_from_angles(ra_hours float8[], dec_degrees float8[], times timestamptz[], obs observer,
seed DEFAULT NULL, fit_bstar DEFAULT false, max_iter DEFAULT 15, weights DEFAULT NULL) → RECORD
tle_from_angles(ra_hours float8[], dec_degrees float8[], times timestamptz[],
observers observer[], observer_ids int4[], -- multi-observer variant
seed DEFAULT NULL, fit_bstar DEFAULT false, max_iter DEFAULT 15, weights DEFAULT NULL) → RECORD
tle_fit_residuals(fitted tle, positions eci_position[], times timestamptz[])
→ SETOF (t, dx_km, dy_km, dz_km, pos_err_km) IMMUTABLE
```
## Operators & Indexes
### GiST — tle_ops (DEFAULT for type tle)
```sql
CREATE INDEX ON satellites USING gist (elements);
```
| Operator | Meaning | Usage |
|----------|---------|-------|
| `&&` | Orbital key overlap (altitude band AND inclination range) | `WHERE a.elements && b.elements` |
| `<->` | 2-D orbital distance (km) — L2 norm of altitude gap + inclination gap | `ORDER BY elements <-> ref_tle LIMIT 10` |
### SP-GiST — tle_spgist_ops (opt-in)
```sql
CREATE INDEX ON satellites USING spgist (elements tle_spgist_ops);
```
| Operator | Meaning | Usage |
|----------|---------|-------|
| `&?` | Visibility cone check — could satellite be visible from observer? | `WHERE elements &? ROW(obs, t0, t1, 10.0)::observer_window` |
SP-GiST is a 2-level orbital trie (SMA → inclination) with query-time RAAN filter. Returns a conservative superset — survivors need `predict_passes()` for ground truth.
## Common Query Patterns
### Observe a satellite
```sql
SELECT topo_elevation(observe(elements, '40.0N 105.3W 1655m'::observer, NOW()))
FROM satellites WHERE name = 'ISS';
```
### Batch propagation over a catalog
```sql
SELECT name,
topo_elevation(observe_safe(elements, '40.0N 105.3W'::observer, NOW())) AS el
FROM satellites
WHERE topo_elevation(observe_safe(elements, '40.0N 105.3W'::observer, NOW())) > 10;
```
### Predict passes for one satellite
```sql
SELECT pass_aos_time(p), pass_max_elevation(p), pass_duration(p)
FROM satellites,
LATERAL predict_passes(elements, '40.0N 105.3W 1655m'::observer,
NOW(), NOW() + '3 days'::interval, 10.0) AS p
WHERE name = 'ISS';
```
### SP-GiST accelerated pass prediction
```sql
SELECT s.name, p.*
FROM satellites s,
LATERAL predict_passes(s.elements, '40.0N 105.3W 1655m'::observer,
NOW(), NOW() + '1 day'::interval, 10.0) AS p
WHERE s.elements &? ROW(
'40.0N 105.3W 1655m'::observer, NOW(), NOW() + '1 day'::interval, 10.0
)::observer_window;
```
### Observe a planet
```sql
SELECT topo_azimuth(planet_observe(4, '40.0N 105.3W'::observer, NOW())) AS mars_az,
topo_elevation(planet_observe(4, '40.0N 105.3W'::observer, NOW())) AS mars_el;
```
### Tonight's visible planets
```sql
SELECT body_name, topo_elevation(obs) AS el, topo_azimuth(obs) AS az
FROM (VALUES (1,'Mercury'),(2,'Venus'),(4,'Mars'),(5,'Jupiter'),(6,'Saturn')) AS p(id, body_name),
LATERAL planet_observe(p.id, '40.0N 105.3W'::observer, NOW()) AS obs
WHERE topo_elevation(obs) > 0;
```
### GiST conjunction screening
```sql
SELECT a.name, b.name,
tle_distance(a.elements, b.elements, NOW()) AS dist_km
FROM satellites a, satellites b
WHERE a.id < b.id
AND a.elements && b.elements
AND tle_distance(a.elements, b.elements, NOW()) < 50;
```
### Observe a comet/asteroid from MPC data
```sql
-- From orbital_elements type:
SELECT topo_elevation(small_body_observe(oe, '40.0N 105.3W'::observer, NOW()))
FROM asteroids WHERE name = 'Ceres';
-- Bulk MPC import:
COPY mpc_raw(line) FROM '/path/to/MPCORB.DAT';
INSERT INTO asteroids (name, oe)
SELECT substring(line FROM 1 FOR 7), oe_from_mpc(line) FROM mpc_raw;
```
### Lambert transfer — Earth to Mars
```sql
SELECT * FROM lambert_transfer(3, 4,
'2026-07-01'::timestamptz,
'2027-01-15'::timestamptz);
-- Returns: c3_departure, c3_arrival, v_inf_departure, v_inf_arrival, tof_days, transfer_sma
```
### Pork chop plot grid
```sql
SELECT dep, arr, lambert_c3(3, 4, dep, arr) AS c3
FROM generate_series('2026-01-01'::timestamptz, '2026-12-01', '10 days') AS dep,
generate_series('2026-07-01'::timestamptz, '2027-06-01', '10 days') AS arr;
```
### Jupiter radio burst prediction
```sql
SELECT io_phase_angle(t) AS io_phase,
jupiter_cml('40.0N 105.3W'::observer, t) AS cml,
jupiter_burst_probability(io_phase_angle(t),
jupiter_cml('40.0N 105.3W'::observer, t)) AS prob
FROM generate_series(NOW(), NOW() + '24 hours', '15 minutes') AS t
WHERE jupiter_burst_probability(io_phase_angle(t),
jupiter_cml('40.0N 105.3W'::observer, t)) > 0.3;
```
### Orbit determination from observations
```sql
SELECT (tle_from_eci(
ARRAY[eci1, eci2, eci3, eci4, eci5],
ARRAY[t1, t2, t3, t4, t5]
)).*
-- Returns: fitted_tle, iterations, rms_final, rms_initial, status, condition_number, covariance, nstate
```
### Get RA/Dec for telescope GoTo
```sql
-- Planet RA/Dec (apparent, of date — what telescope mounts expect)
SELECT eq_ra(planet_equatorial(5, NOW())) AS jupiter_ra_hours,
eq_dec(planet_equatorial(5, NOW())) AS jupiter_dec_deg;
-- With light-time correction (Jupiter light-travel ~35-52 min)
SELECT eq_ra(planet_equatorial_apparent(5, NOW())) AS ra_h,
eq_dec(planet_equatorial_apparent(5, NOW())) AS dec_deg;
-- Star with proper motion (Barnard's Star from Hipparcos/Gaia catalog)
SELECT eq_ra(star_equatorial_pm(17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51, NOW())) AS ra_h,
eq_dec(star_equatorial_pm(17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51, NOW())) AS dec_deg;
```
### Apparent elevation with atmospheric refraction
```sql
-- Compare geometric vs apparent elevation
SELECT topo_elevation(obs) AS geometric_el,
topo_elevation_apparent(obs) AS apparent_el,
atmospheric_refraction(topo_elevation(obs)) AS refraction
FROM planet_observe(5, '40.0N 105.3W'::observer, NOW()) AS obs;
```
### Refracted satellite passes (extended visibility windows)
```sql
SELECT pass_aos_time(p), pass_max_elevation(p), pass_duration(p)
FROM satellites,
LATERAL predict_passes_refracted(elements, '40.0N 105.3W 1655m'::observer,
NOW(), NOW() + '3 days'::interval, 10.0) AS p
WHERE name = 'ISS';
```
## Error Handling
### _safe() variants
`sgp4_propagate_safe()`, `observe_safe()`, `star_observe_safe()` return NULL on error instead of raising exceptions. Use for batch queries over potentially invalid data.
### SGP4 error codes (raised by non-_safe functions)
| Code | Meaning |
|------|---------|
| -1 | Nearly parabolic orbit |
| -2 | Negative semi-major axis (decayed) |
| -3 | Orbit within Earth radius (continues with NOTICE) |
| -4 | Orbit within Earth radius (continues with NOTICE) |
| -5 | Negative mean motion |
| -6 | Kepler solver convergence failure |
### Input validation errors
- Lambert: same-body check, arrival before departure, invalid body_id (not 18)
- Stars: RA outside [0,24), Dec outside [-90,90]
- Comets: negative perihelion distance
- Observer: invalid coordinate format
## Key Constants
### WGS-72 (SGP4 propagation only)
```
mu = 398600.8 km³/s²
ae = 6378.135 km
J2 = 0.001082616
ke = 0.0743669161331734132 min⁻¹
```
### WGS-84 (coordinate output only)
```
a = 6378.137 km
f = 1/298.257223563
```
### Astronomical
```
AU = 149597870.7 km (IAU 2012)
Gauss k = 0.01720209895 AU^(3/2)/day
Obliquity J2000 = 23.4392911°
J2000 epoch = JD 2451545.0 (2000 Jan 1.5 TT)
c (light) = 173.1446327 AU/day (for light-time correction)
```
### Critical rule
TLEs are fitted against WGS-72 constants. Propagation MUST use WGS-72. Coordinate output uses WGS-84. Never mix. This is handled internally — all pg_orrery functions use the correct constants automatically.

67
docs/public/llms.txt Normal file
View File

@ -0,0 +1,67 @@
# pg_orrery
> Celestial mechanics types and functions for PostgreSQL. Native C extension with 106 SQL functions, 9 custom types, GiST/SP-GiST indexing. Covers satellites (SGP4/SDP4), planets (VSOP87), Moon (ELP2000-82B), 19 planetary moons, stars (with proper motion), comets, asteroids (MPC catalog), Jupiter radio bursts, orbit determination, interplanetary Lambert transfers, equatorial RA/Dec coordinates, atmospheric refraction, and light-time correction. Optional JPL DE440/441 ephemeris for sub-arcsecond accuracy.
- [Source code](https://git.supported.systems/warehack.ing/pg_orrery)
- [Full LLM reference](https://pg-orrery.warehack.ing/llms-full.txt): All function signatures, types, body IDs, operators, and query patterns inline
## Getting Started
- [What is pg_orrery?](https://pg-orrery.warehack.ing/getting-started/what-is-pg-orrery/): Overview — the "PostGIS for space" analogy, domain coverage, design philosophy
- [Installation](https://pg-orrery.warehack.ing/getting-started/installation/): Build from source with PGXS or run via Docker (PostgreSQL 1418)
- [Quick Start](https://pg-orrery.warehack.ing/getting-started/quick-start/): First queries — observe the ISS, track planets, predict passes
## Guides
- [Tracking Satellites](https://pg-orrery.warehack.ing/guides/tracking-satellites/): SGP4/SDP4 propagation, TLE parsing, batch observation over catalogs
- [Observing the Solar System](https://pg-orrery.warehack.ing/guides/observing-solar-system/): VSOP87 planets, ELP2000-82B Moon, Sun — topocentric observation from SQL
- [Cosmic Queries Cookbook](https://pg-orrery.warehack.ing/guides/cosmic-queries/): 9 cross-domain SQL recipes combining satellites, planets, moons, and stars
- [Planetary Moon Tracking](https://pg-orrery.warehack.ing/guides/planetary-moons/): L1.2 Galilean, TASS17 Saturn, GUST86 Uranus, MarsSat Mars moon theories
- [Star Catalogs in SQL](https://pg-orrery.warehack.ing/guides/star-catalogs/): J2000 coordinates, IAU 1976 precession, batch star observation
- [Comet & Asteroid Tracking](https://pg-orrery.warehack.ing/guides/comets-asteroids/): Keplerian propagation, MPC MPCORB.DAT import, orbital_elements type
- [Jupiter Radio Burst Prediction](https://pg-orrery.warehack.ing/guides/jupiter-radio-bursts/): Io phase angle, CML System III, Carr source region probability
- [Interplanetary Trajectories](https://pg-orrery.warehack.ing/guides/interplanetary-trajectories/): Lambert transfer solver, pork chop plots, C3 energy grids
- [Conjunction Screening](https://pg-orrery.warehack.ing/guides/conjunction-screening/): GiST-indexed altitude/inclination overlap, batch distance computation
- [JPL DE Ephemeris](https://pg-orrery.warehack.ing/guides/de-ephemeris/): Optional DE440/441 binary reader for sub-arcsecond planetary positions
- [Orbit Determination](https://pg-orrery.warehack.ing/guides/orbit-determination/): TLE fitting from ECI, topocentric, and angles-only observations
- [Satellite Pass Prediction](https://pg-orrery.warehack.ing/guides/pass-prediction/): AOS/TCA/LOS computation, visibility windows, minimum elevation filter
- [Building TLE Catalogs](https://pg-orrery.warehack.ing/guides/catalog-management/): CelesTrak/Space-Track import, catalog maintenance, bulk loading
## Workflow Translation
- [From Skyfield to SQL](https://pg-orrery.warehack.ing/workflow/from-skyfield/): Side-by-side migration from Python Skyfield to pg_orrery SQL
- [From JPL Horizons to SQL](https://pg-orrery.warehack.ing/workflow/from-jpl-horizons/): Replacing Horizons web API queries with pg_orrery functions
- [From GMAT to SQL](https://pg-orrery.warehack.ing/workflow/from-gmat/): Mission planning workflows translated to SQL
- [From Radio Jupiter Pro to SQL](https://pg-orrery.warehack.ing/workflow/from-radio-jupiter-pro/): Jupiter radio burst prediction comparison
- [From find_orb to SQL](https://pg-orrery.warehack.ing/workflow/from-find-orb/): Orbit determination comparison with Bill Gray's find_orb
- [From Poliastro to SQL](https://pg-orrery.warehack.ing/workflow/from-poliastro/): Lambert transfers and orbital maneuvers comparison
- [The SQL Advantage](https://pg-orrery.warehack.ing/workflow/sql-advantage/): Why database-native celestial mechanics vs. standalone tools
## Reference
- [Types](https://pg-orrery.warehack.ing/reference/types/): 9 fixed-size types — tle (112B), eci_position (48B), geodetic (24B), topocentric (32B), observer (24B), pass_event (48B), heliocentric (24B), orbital_elements (72B), equatorial (24B), plus observer_window composite
- [Functions: Satellite](https://pg-orrery.warehack.ing/reference/functions-satellite/): 22 functions — SGP4/SDP4 propagation, coordinate transforms, pass prediction, observation, satellite RA/Dec (topocentric + geocentric)
- [Functions: Solar System](https://pg-orrery.warehack.ing/reference/functions-solar-system/): VSOP87 planets, Sun, Moon — observation, heliocentric positions, equatorial RA/Dec, light-time corrected _apparent() variants
- [Functions: Moons](https://pg-orrery.warehack.ing/reference/functions-moons/): Galilean, Saturn, Uranus, Mars moon observation via analytical theories
- [Functions: Stars & Comets](https://pg-orrery.warehack.ing/reference/functions-stars-comets/): Star observation with proper motion, Keplerian propagation, comet/asteroid observation + RA/Dec, MPC parsing, orbital_elements functions
- [Functions: Radio](https://pg-orrery.warehack.ing/reference/functions-radio/): Jupiter decametric radio burst prediction — Io phase, CML, burst probability
- [Functions: Transfers](https://pg-orrery.warehack.ing/reference/functions-transfers/): Lambert transfer solver for interplanetary trajectory design
- [Functions: Refraction](https://pg-orrery.warehack.ing/reference/functions-refraction/): Bennett (1982) atmospheric refraction, P/T correction, apparent elevation, refracted pass prediction
- [Functions: DE Ephemeris](https://pg-orrery.warehack.ing/reference/functions-de/): Optional JPL DE440/441 variants of observation and equatorial functions
- [Functions: Orbit Determination](https://pg-orrery.warehack.ing/reference/functions-od/): TLE fitting from ECI, topocentric, and angles-only observations
- [Operators & Indexes](https://pg-orrery.warehack.ing/reference/operators-gist/): GiST (&&, <->) and SP-GiST (&?) operator classes for orbital indexing
- [Body ID Reference](https://pg-orrery.warehack.ing/reference/body-ids/): Planet IDs 010, Galilean 03, Saturn 07, Uranus 04, Mars 01
- [Constants & Accuracy](https://pg-orrery.warehack.ing/reference/constants-accuracy/): WGS-72/WGS-84/IAU constants, accuracy budgets per theory
## Architecture
- [Design Principles](https://pg-orrery.warehack.ing/architecture/design-principles/): Hamilton's Development Before the Fact methodology applied to a PG extension
- [Constant Chain of Custody](https://pg-orrery.warehack.ing/architecture/constant-chain-of-custody/): Why WGS-72 for propagation, WGS-84 for output — and the consequences of mixing them
- [Observation Pipeline](https://pg-orrery.warehack.ing/architecture/observation-pipeline/): From orbital elements through frame rotation to observer-relative coordinates
- [Theory-to-Code Mapping](https://pg-orrery.warehack.ing/architecture/theory-to-code/): Each source paper mapped to its C implementation file and SQL function
- [Memory & Thread Safety](https://pg-orrery.warehack.ing/architecture/memory-thread-safety/): palloc/pfree, PARALLEL SAFE, no global mutable state, per-backend DE handles
- [SGP4 Integration](https://pg-orrery.warehack.ing/architecture/sgp4-integration/): Vendored Bill Gray sat_code, .cpp→.c rename, Vallado verification
## Optional
- [Benchmarks](https://pg-orrery.warehack.ing/performance/benchmarks/): Timing data — 12k TLEs in 17ms, 66k catalog operations, GiST/SP-GiST index performance

View File

@ -104,7 +104,7 @@ For v0.1.0/v0.2.0 functions, there are no file-scope variables, no static locals
### Fixed-size types
All seven pg_orrery types use `STORAGE = plain` and fixed `INTERNALLENGTH`. No TOAST, no detoasting, no variable-length headers. The `tle` type is exactly 112 bytes. Direct pointer access via `PG_GETARG_POINTER(n)` --- no copies, no allocations on read.
All seven pg_orrery base types use `STORAGE = plain` and fixed `INTERNALLENGTH`. No TOAST, no detoasting, no variable-length headers. The `tle` type is exactly 112 bytes. Direct pointer access via `PG_GETARG_POINTER(n)` --- no copies, no allocations on read. The eighth type, `observer_window`, is a SQL composite used only as a query-time parameter --- it is never stored in table columns.
### Deterministic memory

View File

@ -97,13 +97,13 @@ import { Tabs, TabItem, Steps, Aside } from "@astrojs/starlight/components";
## Running the test suite
If building from source, the regression tests verify all functions across 14 test suites:
If building from source, the regression tests verify all functions across 15 test suites:
```bash
make installcheck PG_CONFIG=/usr/bin/pg_config
```
This runs the tests listed in the `REGRESS` variable: TLE parsing, SGP4 propagation, coordinate transforms, pass prediction, GiST indexing, convenience functions, star observation, Keplerian propagation, planet observation, moon observation, Lambert transfers, and DE ephemeris.
This runs the tests listed in the `REGRESS` variable: TLE parsing, SGP4 propagation, coordinate transforms, pass prediction, GiST indexing, convenience functions, star observation, Keplerian propagation, planet observation, moon observation, Lambert transfers, DE ephemeris, orbit determination, SP-GiST visibility index, and the 518 Vallado test vectors.
## Upgrading

View File

@ -22,7 +22,7 @@ PostGIS added spatial awareness to PostgreSQL — suddenly your database underst
| Moon | ELP2000-82B (Chapront, 1988) | `moon_observe()` | ~10 arcseconds |
| Planetary moons | L1.2, TASS17, GUST86, MarsSat | `galilean_observe()`, etc. | ~1-10 arcseconds |
| Stars | J2000 catalog + precession | `star_observe()` | Limited by catalog |
| Comets/asteroids | Two-body Keplerian | `kepler_propagate()`, `comet_observe()` | Varies with eccentricity |
| Comets/asteroids | Two-body Keplerian | `small_body_observe()`, `oe_from_mpc()`, `kepler_propagate()` | Varies with eccentricity |
| Jupiter radio | Carr et al. (1983) sources | `jupiter_burst_probability()` | Empirical probability |
| Transfers | Lambert (Izzo, 2015) | `lambert_transfer()`, `lambert_c3()` | Ballistic two-body |
| DE ephemeris (optional) | JPL DE440/441 | `planet_observe_de()`, `moon_observe_de()` | ~0.1 milliarcsecond |

View File

@ -0,0 +1,309 @@
---
title: Building TLE Catalogs
sidebar:
order: 12
---
import { Steps, Aside, Tabs, TabItem, Code } from "@astrojs/starlight/components";
Every pg_orrery workflow starts with TLEs in a table. The [Tracking Satellites](/guides/tracking-satellites/) guide shows how to insert a few satellites by hand --- but a real catalog has tens of thousands of objects from multiple sources, each with different freshness and coverage. `pg-orrery-catalog` handles the download, merge, and load pipeline.
## The problem with multiple TLE sources
Three major sources provide TLE data, each with trade-offs:
| Source | Auth | Coverage | Freshness |
|--------|------|----------|-----------|
| [Space-Track](https://www.space-track.org) | Login required | Full catalog (~30k+ on-orbit) | Hours to days |
| [CelesTrak](https://celestrak.org) | None | Active sats + operator supplemental GP | Minutes to hours |
| [SatNOGS](https://db.satnogs.org) | None | Community-tracked objects | Varies |
The same satellite often appears in all three. CelesTrak's supplemental GP (SupGP) data is particularly valuable --- operators like SpaceX submit Starlink ephemerides that are often hours fresher than Space-Track's own catalog.
The question is which entry to keep. `pg-orrery-catalog` answers with epoch-based deduplication: when the same NORAD ID appears in multiple sources, the entry with the newest epoch wins. This means SupGP data automatically overrides stale Space-Track entries where available.
## Install
```bash
# Run directly (no install needed)
uvx pg-orrery-catalog --help
# Or install permanently
uv pip install pg-orrery-catalog
# For direct database loading (adds psycopg)
uv pip install "pg-orrery-catalog[pg]"
```
## Download, build, load
The typical workflow is three steps. Each can run independently.
<Steps>
1. **Download** TLE data from remote sources into the local cache:
```bash
pg-orrery-catalog download
```
This fetches from all configured sources (CelesTrak by default, Space-Track if credentials are set). Files are cached in `~/.cache/pg-orrery-catalog/` and reused unless stale (>24h) or `--force` is passed.
To download from a specific source:
```bash
pg-orrery-catalog download --source celestrak
pg-orrery-catalog download --source spacetrack --force
```
2. **Build** a merged catalog and output it:
<Tabs>
<TabItem label="Pipe to psql">
```bash
pg-orrery-catalog build | psql -d mydb
```
</TabItem>
<TabItem label="Save SQL file">
```bash
pg-orrery-catalog build --table satellites -o catalog.sql
```
</TabItem>
<TabItem label="Export 3LE">
```bash
pg-orrery-catalog build --format 3le -o merged.tle
```
</TabItem>
<TabItem label="JSON output">
```bash
pg-orrery-catalog build --format json -o catalog.json
```
</TabItem>
</Tabs>
With no arguments, `build` merges all cached files. You can also pass specific TLE files:
```bash
pg-orrery-catalog build /path/to/spacetrack.tle /path/to/celestrak.tle
```
The merge reports what happened:
```
spacetrack_everything: 33053 objects (33053 new, 0 updated)
celestrak_active: 14376 objects (2 new, 0 updated)
satnogs_full: 1488 objects (121 new, 5 updated)
supgp_starlink: 9703 objects (77 new, 7398 updated)
Total: 33253 unique objects
Regimes: LEO: 31542, GEO: 1203, MEO: 385, HEO: 123
```
Notice how SupGP updated 7,398 Starlink entries --- those are fresher epochs from SpaceX overriding stale Space-Track data.
3. **Load** directly into PostgreSQL (requires `[pg]` extra):
```bash
pg-orrery-catalog load \
--database-url postgresql:///mydb \
--table satellites \
--create-index
```
The `--create-index` flag creates both GiST and SP-GiST indexes on the `tle` column, ready for spatial queries and KNN ordering.
</Steps>
## Configuration
Three layers, highest precedence first:
1. **CLI flags** --- `--table`, `--source`, `--database-url`
2. **Environment variables** --- `SPACETRACK_USER`, `SOCKS_PROXY`, `DATABASE_URL`
3. **Config file** --- `~/.config/pg-orrery-catalog/config.toml`
### Space-Track credentials
Space-Track requires a free account. Set credentials via environment variables:
```bash
export SPACETRACK_USER="you@example.com"
export SPACETRACK_PASSWORD="secret"
pg-orrery-catalog download --source spacetrack
```
Or in the config file:
```toml
[spacetrack]
user = "you@example.com"
password = "secret"
```
### SOCKS proxy
CelesTrak is sometimes unreachable from certain networks. Route through a SOCKS5 proxy:
```bash
export SOCKS_PROXY="localhost:1080"
pg-orrery-catalog download
```
### Full config reference
```toml
[spacetrack]
user = "you@example.com"
password = "secret"
[celestrak]
proxy = "localhost:1080"
supgp_groups = ["starlink", "oneweb", "planet", "orbcomm"]
[output]
table = "satellites"
[database]
url = "postgresql://localhost/mydb"
```
## Working with the generated SQL
The SQL output creates a table with three columns:
```sql
CREATE TABLE satellites (
id serial,
name text,
tle tle
);
```
Once loaded, the full pg_orrery function set is available:
```sql
-- Where is every LEO satellite right now?
SELECT name, observe(tle, '40.0N 105.3W 1655m'::observer, now()) AS topo
FROM satellites
WHERE tle_mean_motion(tle) > 11.25;
-- Which satellites are overhead right now?
SELECT name,
round(topo_elevation(
observe_safe(tle, '40.0N 105.3W 1655m'::observer, now())
)::numeric, 1) AS el
FROM satellites
WHERE topo_elevation(
observe_safe(tle, '40.0N 105.3W 1655m'::observer, now())
) > 10
ORDER BY el DESC;
-- Predict ISS passes for the next 24 hours
SELECT pass_aos_time(p)::timestamp(0) AS rise,
round(pass_max_elevation(p)::numeric, 1) AS max_el,
pass_los_time(p)::timestamp(0) AS set
FROM satellites,
predict_passes(tle, '40.0N 105.3W 1655m'::observer,
now(), now() + interval '24 hours', 10.0) p
WHERE tle_norad_id(tle) = 25544;
```
## NORAD ID encoding
TLE files use a 5-character field for the NORAD catalog number. With more than 100,000 tracked objects, the original 5-digit numeric format ran out of space. The encoding has evolved through four cases:
| Case | Format | Range | Example |
|------|--------|-------|---------|
| Traditional | `ddddd` | 0 -- 99,999 | `25544` (ISS) |
| Alpha-5 | `Ldddd` | 100,000 -- 339,999 | `T0002` = 270,002 |
| Super-5 case 3 | `xxxxX` | 340,000 -- 906,309,663 | `0000A` = 340,000 |
| Super-5 case 4 | `xxxXd` | 906,309,664+ | `000A0` = 906,309,664 |
Alpha-5 skips the letters I and O (they look like 1 and 0). Super-5 uses a base-64 alphabet: digits 0--9, uppercase A--Z, lowercase a--z, plus `+` and `-`.
`pg-orrery-catalog` decodes all four cases, matching the `get_norad_number()` implementation in pg_orrery's vendored SGP4 library. This means Alpha-5 objects like Starlink satellites (NORAD IDs above 100,000) load correctly.
<Aside type="note" title="Alpha-5 verification">
You can verify the decoding independently:
```bash
python3 -c "
from pg_orrery_catalog.tle import decode_norad
print(f'T0002 = {decode_norad(\"T0002\")}') # 270002
print(f'A0001 = {decode_norad(\"A0001\")}') # 100001
print(f'Z9999 = {decode_norad(\"Z9999\")}') # 339999
"
```
</Aside>
## Cache management
Downloaded TLE files are stored under `~/.cache/pg-orrery-catalog/`, organized by source:
```
~/.cache/pg-orrery-catalog/
celestrak/
celestrak_active.tle
supgp_starlink.tle
supgp_oneweb.tle
...
satnogs/
satnogs_full.tle
spacetrack/
spacetrack_everything.tle
```
Check what's cached:
```bash
pg-orrery-catalog info --cache
```
Files older than 24 hours are considered stale and re-downloaded automatically. Use `--force` to override fresh cache entries.
## Automating catalog updates
For a regularly-updated catalog, a cron job or systemd timer works well:
```bash
# Update catalog daily at 03:00
0 3 * * * pg-orrery-catalog download && pg-orrery-catalog build --table satellites | psql -d mydb
```
Or with the direct load command:
```bash
0 3 * * * pg-orrery-catalog download && pg-orrery-catalog load --database-url postgresql:///mydb --table satellites --create-index
```
<Aside type="caution" title="Table replacement">
The default SQL output includes `DROP TABLE IF EXISTS` before `CREATE TABLE`. This replaces the entire table on each load. If you need to preserve the table and upsert, export as JSON and handle the merge in your application logic.
</Aside>
## Using as a library
`pg-orrery-catalog` can also be imported as a Python library:
```python
from pg_orrery_catalog.tle import decode_norad, parse_3le_file
from pg_orrery_catalog.catalog import merge_sources
from pg_orrery_catalog.regime import regime_summary
from pg_orrery_catalog.output.sql import generate_sql
# Parse and merge
merged, stats = merge_sources(["spacetrack.tle", "celestrak.tle"])
print(f"{stats.total_unique} unique objects")
# Classify
regimes = regime_summary(merged)
print(regimes) # {'LEO': 31542, 'MEO': 385, 'GEO': 1203, 'HEO': 123}
# Generate SQL
sql = generate_sql(merged, table="my_catalog")
```
## What's next
With a catalog loaded, see:
- [Tracking Satellites](/guides/tracking-satellites/) --- observe, predict passes, screen conjunctions
- [Satellite Pass Prediction](/guides/pass-prediction/) --- detailed pass prediction workflows
- [Conjunction Screening](/guides/conjunction-screening/) --- find close approaches using GiST indexes
- [Benchmarks](/performance/benchmarks/) --- performance data with catalogs of 33k--66k objects

View File

@ -21,17 +21,22 @@ The pattern is familiar: download elements, propagate in Python or C, transform
## What changes with pg_orrery
Two functions handle comet/asteroid computation:
Five functions handle comet/asteroid computation:
| Function | What it does |
|---|---|
| `kepler_propagate(q, e, i, omega, Omega, T, time)` | Propagates orbital elements to a heliocentric position (AU) |
| `comet_observe(q, e, i, omega, Omega, T, ex, ey, ez, observer, time)` | Full observation pipeline: propagate + geocentric transform + topocentric |
| `oe_from_mpc(line)` | Parses one MPCORB.DAT line into an `orbital_elements` type |
| `small_body_heliocentric(oe, time)` | Heliocentric position from bundled elements |
| `small_body_observe(oe, observer, time)` | Topocentric observation — auto-fetches Earth via VSOP87 |
`kepler_propagate()` solves Kepler's equation for elliptic (e < 1), parabolic (e = 1), and hyperbolic (e > 1) orbits. The solver handles all three cases with appropriate numerical methods.
`comet_observe()` wraps the full chain: propagate the comet's position, subtract Earth's heliocentric position, and transform to horizon coordinates. You supply Earth's position as three floats (ecliptic J2000, AU) because you might want to compute it once and reuse it across many comets.
`small_body_observe()` (v0.8.0) does the same thing but fetches Earth's position automatically — you just pass the `orbital_elements` type and an observer. See the [orbital_elements type section](#the-orbital_elements-type) below.
The parameters map directly to MPC orbital element format:
| Parameter | MPC field | Units |
@ -56,6 +61,107 @@ Keplerian propagation assumes the body is influenced only by the Sun. Real small
For MPC elements less than a few months old, two-body propagation is typically accurate to a few arcminutes for asteroids and tens of arcminutes for comets. Fresh elements give better results.
## The `orbital_elements` type
The raw-parameter functions (`kepler_propagate`, `comet_observe`) work well when you have elements in variables or a table with individual columns. But they require passing 611 float8 arguments per call, and `comet_observe` requires you to manually fetch Earth's position.
The `orbital_elements` type (v0.8.0) bundles all nine classical elements into a single 72-byte PostgreSQL datum:
```sql
-- Construct from a literal
SELECT '(2460605.5,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements;
-- Or parse directly from the MPC catalog
SELECT oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
);
```
With bundled elements, observation becomes a single function call:
```sql
-- Before (comet_observe): 11 arguments, manual Earth fetch
WITH earth AS (SELECT planet_heliocentric(3, now()) AS h)
SELECT topo_elevation(comet_observe(
2.5478, 0.0789, 10.59, 73.43, 80.27, 2460319.0,
helio_x(h), helio_y(h), helio_z(h),
'40.0N 105.3W 1655m'::observer, now()))
FROM earth;
-- After (small_body_observe): 3 arguments, Earth auto-fetched
SELECT topo_elevation(small_body_observe(
oe, '40.0N 105.3W 1655m'::observer, now()))
FROM asteroid_catalog;
```
### Load and query the MPC catalog
The MPC publishes MPCORB.DAT — orbital elements for every numbered asteroid. Here's how to load it into PostgreSQL:
<Steps>
1. **Create a table with an `orbital_elements` column:**
```sql
CREATE TABLE asteroids (
number int PRIMARY KEY,
name text,
oe orbital_elements NOT NULL
);
```
2. **Load via a staging table:**
```sql
-- Stage the raw text lines
CREATE TEMP TABLE mpc_raw (line text);
\copy mpc_raw FROM 'MPCORB.DAT'
-- Parse into orbital_elements, extract number and name
INSERT INTO asteroids (number, name, oe)
SELECT substring(line FROM 1 FOR 7)::int,
trim(substring(line FROM 167 FOR 30)),
oe_from_mpc(line)
FROM mpc_raw
WHERE length(line) >= 103
AND substring(line FROM 1 FOR 7) ~ '^\s*\d+$';
DROP TABLE mpc_raw;
```
3. **Query: what asteroids are above 20 degrees tonight?**
```sql
SELECT name, number,
round(topo_elevation(t)::numeric, 1) AS el,
round(topo_azimuth(t)::numeric, 1) AS az,
round((topo_range(t) / 149597870.7)::numeric, 2) AS dist_au
FROM asteroids,
small_body_observe(oe, '40.0N 105.3W 1655m'::observer, now()) AS t
WHERE topo_elevation(t) > 20
ORDER BY topo_elevation(t) DESC
LIMIT 20;
```
4. **Query: heliocentric distance of Ceres over 6 months:**
```sql
SELECT t::date AS date,
round(helio_distance(
small_body_heliocentric(oe, t))::numeric, 4) AS dist_au
FROM asteroids,
generate_series(
'2025-01-01'::timestamptz,
'2025-07-01'::timestamptz,
interval '15 days'
) AS t
WHERE number = 1;
```
</Steps>
<Aside type="tip">
For batch observation at a single time, `comet_observe()` is still more efficient — it lets you compute Earth's VSOP87 position once with `planet_heliocentric(3, t)` and reuse it across all objects. `small_body_observe()` re-fetches Earth on every call. For interactive single-object queries, `small_body_observe()` is simpler.
</Aside>
## Try it
### Circular orbit sanity check

View File

@ -17,7 +17,7 @@ Operational conjunction screening uses several established tools and data source
- **CelesTrak SOCRATES**: Dr. Kelso's web-based close-approach listing. Updated regularly, covers the full public catalog. Not queryable; you read reports.
- **Python scripts**: Propagate the catalog in a loop, compute pairwise distances, filter by threshold. Works for small catalogs. Does not scale.
The fundamental challenge: a catalog of 25,000+ tracked objects produces over 300 million unique pairs. Even checking each pair at a single epoch takes significant time. Checking over a 7-day window at 1-minute resolution is computationally prohibitive without pre-filtering.
The fundamental challenge: a catalog of 66,000+ tracked objects produces over 2 billion unique pairs. Even checking each pair at a single epoch takes significant time. Checking over a 7-day window at 1-minute resolution is computationally prohibitive without pre-filtering.
## What changes with pg_orrery
@ -32,9 +32,9 @@ The two operators:
| Operator | Type | What it checks |
|---|---|---|
| `tle && tle` | boolean | Altitude band AND inclination range overlap |
| `tle <-> tle` | float8 | Minimum altitude-band separation in km |
| `tle <-> tle` | float8 | 2-D orbital distance in km (altitude + inclination) |
The `&&` operator is used for overlap queries (find all objects in the same shell). The `<->` operator is used for nearest-neighbor queries (find the N closest objects by altitude separation).
The `&&` operator is used for overlap queries (find all objects in the same shell). The `<->` operator is used for nearest-neighbor queries (find the N closest objects by orbital distance, combining altitude gap with inclination gap converted to km).
## What pg_orrery does not replace
@ -44,7 +44,7 @@ GiST-based conjunction screening is a coarse filter. It finds candidates that sh
- **Not a probability of collision.** pg_orrery does not compute Pc (probability of collision). It identifies objects in overlapping orbital shells and computes distances at discrete time steps. For Pc calculation, use CARA (Conjunction Assessment Risk Analysis) methods.
- **No covariance propagation.** SGP4 does not produce covariance matrices. The distance values have no uncertainty bounds. For operational conjunction assessment, use SP ephemerides with covariance (from CDMs or owner/operator data).
- **Altitude-band approximation.** The GiST key uses perigee-to-apogee altitude as a 1-D range and inclination as a second dimension. Two TLEs can share an altitude shell and never approach because their RAANs or phases are far apart. Always follow GiST filtering with full propagation.
- **Orbital envelope approximation.** The GiST key uses perigee-to-apogee altitude and inclination as a 2-D bounding box. The `<->` distance combines both dimensions. Two TLEs can still be close in this metric and never approach because their RAANs or phases are far apart. Always follow GiST filtering with full propagation.
- **No maneuver planning.** pg_orrery identifies close approaches. It does not compute avoidance maneuvers (delta-v, timing, constraints).
The workflow is: GiST narrows → `tle_distance()` verifies → operator/analyst decides.
@ -89,7 +89,7 @@ INSERT INTO catalog VALUES (99901, 'Equatorial-LEO',
CREATE INDEX catalog_orbit_gist ON catalog USING gist (tle);
```
The index builds in milliseconds for a small table. For a full 25,000-object catalog, expect about 200ms.
The index builds in milliseconds for a small table. For a full 66,440-object catalog, build time is 93 ms (15 MB index).
### Check orbital parameters
@ -120,20 +120,20 @@ ORDER BY a.name, b.name;
Key insight: ISS and Equatorial-LEO are at the same altitude but different inclinations. The `&&` operator returns **false** for this pair because the 2-D key requires overlap in BOTH altitude AND inclination. Two objects at the same altitude but in very different orbital planes are unlikely to conjunct.
### Altitude-band distance with `<->`
### Orbital distance with `<->`
The `<->` operator returns the minimum separation between altitude bands, in km:
The `<->` operator returns the 2-D orbital distance in km, combining altitude-band separation with inclination gap (converted to km via Earth radius):
```sql
SELECT a.name AS sat_a,
b.name AS sat_b,
round((a.tle <-> b.tle)::numeric, 0) AS alt_separation_km
round((a.tle <-> b.tle)::numeric, 0) AS orbital_dist_km
FROM catalog a, catalog b
WHERE a.norad_id < b.norad_id
ORDER BY a.tle <-> b.tle;
```
ISS and Equatorial-LEO should show ~0 km separation (same altitude shell). ISS and GPS should show ~19,800 km (vastly different orbits).
ISS and Equatorial-LEO show ~5192 km (0 km altitude gap, but 47° inclination difference × 6378 km/rad). ISS and Hubble show ~2582 km (115 km altitude gap + 23° inclination difference). ISS and GPS show ~19,456 km (altitude gap dominates).
### GiST index scan: find overlapping orbits
@ -152,24 +152,26 @@ RESET enable_seqscan;
This should return only ISS itself (and not Equatorial-LEO, which has a different inclination). The GiST index scan avoids checking every object in the catalog.
### K-nearest-neighbor by altitude
### K-nearest-neighbor by orbital distance
Find the 3 closest objects to the ISS by altitude band separation, ordered by distance:
Find the 3 closest objects to the ISS by 2-D orbital distance, ordered by distance:
```sql
SET enable_seqscan = off;
-- Scalar subquery probe enables GiST index-ordered scan
SELECT name,
round((tle <-> (SELECT tle FROM catalog WHERE norad_id = 25544))::numeric, 0) AS alt_dist_km
round((tle <-> (SELECT tle FROM catalog WHERE norad_id = 25544 LIMIT 1))::numeric, 0)
AS orbital_dist_km
FROM catalog
WHERE norad_id != 25544
ORDER BY tle <-> (SELECT tle FROM catalog WHERE norad_id = 25544)
ORDER BY tle <-> (SELECT tle FROM catalog WHERE norad_id = 25544 LIMIT 1)
LIMIT 3;
RESET enable_seqscan;
```
This uses the GiST distance operator for efficient ordering. PostgreSQL's KNN-GiST infrastructure handles this without computing all distances upfront.
This uses the GiST distance operator for efficient ordering. The 2-D metric means satellites at the same altitude but wildly different inclinations no longer tie at distance 0 --- Hubble (inc 28°, 115 km altitude gap) ranks ahead of an equatorial LEO object (inc 5°, 0 km altitude gap but 47° inclination difference). PostgreSQL's KNN-GiST infrastructure traverses the tree by increasing distance without computing all distances upfront. On a 66,440-object catalog, this completes in 2.1 ms for 10 neighbors.
<Aside type="caution" title="Use scalar subqueries, not CTEs">
GiST index-ordered scans require the probe value to be visible to the planner as a constant. A `WITH iss AS (...)` CTE makes the probe opaque, forcing a full sequential scan and sort. Always use `(SELECT tle FROM ... LIMIT 1)` as the probe argument for KNN queries on large catalogs.
</Aside>
### Self-overlap is always true
@ -206,7 +208,7 @@ The complete two-stage workflow for a larger catalog:
AND c.norad_id != 25544;
```
For the ISS in a 25,000-object catalog, this typically returns a few hundred candidates.
For the ISS in a 66,440-object catalog, this returns 9 candidates (all co-orbital vehicles: visiting spacecraft, modules, and debris). The GiST index scan completes in 4.6 ms vs. 63.3 ms for a sequential scan.
3. **Stage 2: Time-resolved distance computation:**
@ -253,7 +255,7 @@ ORDER BY actual_dist_km;
```
<Aside type="tip" title="Performance scaling">
The GiST index is the key to scaling. Without it, screening a 25,000-object catalog for all-vs-all conjunctions means 300 million pair evaluations. With GiST, the `&&` operator reduces this to tens of thousands of candidate pairs. The `tle_distance()` computation on candidates is then feasible even at fine time resolution.
The GiST index is the key to scaling. Without it, screening a 66,440-object catalog for all-vs-all conjunctions means over 2 billion pair evaluations. With GiST, the `&&` operator reduces single-probe screening from 63 ms (sequential) to 4.6 ms (indexed), a 5.8x speedup. For the ISS, only 9 candidates survive from 66,440 objects. The `tle_distance()` computation on these survivors is then feasible even at 1-minute time resolution over multi-day windows.
</Aside>
### Monitoring over time

View File

@ -0,0 +1,435 @@
---
title: Cosmic Queries Cookbook
sidebar:
order: 3
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Each pg_orrery guide covers a single domain — satellites, planets, comets, radio bursts. This page is different. These nine queries combine multiple pg_orrery function families with PostgreSQL's analytical engine to ask questions that span physical theories, orbital regimes, and even external extensions like PostGIS. They range from statistical analysis of 600,000+ asteroids to real-time cross-domain sky surveys.
Every query here is copy-paste ready. Swap the observer coordinates for your location and the timestamps for your session.
## Prerequisites
Not every query needs the same data. Here's what to load before you start:
| Data | Queries that use it | Setup |
|------|---------------------|-------|
| `asteroids` table (MPC catalog) | 1, 2, 3, 4 | See [Comet & Asteroid Tracking](/guides/comets-asteroids) — load the MPC export with `oe_from_mpc()` |
| `satellites` table (TLE catalog) | 4, 6, 7, 9 | See [Building TLE Catalogs](/guides/catalog-management) — any catalog with a `tle` column works |
| `countries` table (Natural Earth) | 7 | PostGIS + Natural Earth boundaries — [setup below](#postgis-setup) |
| PostGIS extension | 7 | `CREATE EXTENSION IF NOT EXISTS postgis;` |
| None — built-in functions only | 5, 8 | Just pg_orrery |
The expected table schemas:
```sql
-- Asteroids: name + orbital elements from MPC
CREATE TABLE asteroids (
name text PRIMARY KEY,
oe orbital_elements
);
-- Satellites: NORAD ID + parsed TLE
CREATE TABLE satellites (
norad_id integer PRIMARY KEY,
name text,
tle tle
);
```
---
## The Asteroid Belt as a Dataset
The MPC catalog isn't just a list of orbits — it's a dataset with 600,000+ rows and rich statistical structure. PostgreSQL's aggregate functions turn it into an orbital mechanics laboratory.
### 1. Kirkwood Gaps — Jupiter's Gravitational Fingerprint
In 1866, Daniel Kirkwood noticed that asteroids avoid certain orbital distances. The gaps correspond to mean-motion resonances with Jupiter: an asteroid at 2.50 AU completes exactly 3 orbits for every 1 of Jupiter's (the 3:1 resonance). Over millions of years, Jupiter's periodic gravitational nudges clear these orbits out.
`width_bucket()` bins the semi-major axes into a 200-bin histogram across the main belt. The depletions at 2.50, 2.82, 2.96, and 3.28 AU are unmistakable:
```sql
WITH belt AS (
SELECT oe_semi_major_axis(oe) AS a_au
FROM asteroids
WHERE oe_eccentricity(oe) < 0.4
AND oe_semi_major_axis(oe) IS NOT NULL
AND oe_semi_major_axis(oe) BETWEEN 1.5 AND 5.5
)
SELECT round((1.5 + (bucket - 1) * 0.02)::numeric, 2) AS a_au,
count AS n_asteroids
FROM (
SELECT width_bucket(a_au, 1.5, 5.5, 200) AS bucket, count(*) AS count
FROM belt GROUP BY bucket
) sub
ORDER BY a_au;
```
The output is a classic Kirkwood gap diagram. Plot `a_au` vs `n_asteroids` and the resonance depletions jump out — the 3:1 gap at 2.50 AU is the deepest, with the 5:2 (2.82 AU), 7:3 (2.96 AU), and 2:1 (3.28 AU) gaps clearly visible.
### 2. Kepler's Third Law as a Regression
Kepler published his third law in 1619: the square of a planet's orbital period is proportional to the cube of its semi-major axis, or equivalently $\log P = 1.5 \cdot \log a$. With `regr_slope()` and `regr_r2()`, you can verify this 400-year-old relationship against every bound asteroid in the MPC catalog:
```sql
WITH bounded AS (
SELECT oe_semi_major_axis(oe) AS a_au, oe_period_years(oe) AS p_yr
FROM asteroids
WHERE oe_semi_major_axis(oe) IS NOT NULL
AND oe_semi_major_axis(oe) BETWEEN 0.5 AND 100.0
)
SELECT round(regr_slope(ln(p_yr), ln(a_au))::numeric, 6) AS slope,
round(exp(regr_intercept(ln(p_yr), ln(a_au)))::numeric, 6) AS intercept_years,
regr_count(ln(p_yr), ln(a_au)) AS n_objects,
round(regr_r2(ln(p_yr), ln(a_au))::numeric, 12) AS r_squared
FROM bounded;
```
The slope will be exactly 1.500000 (Kepler's 3/2 power law). The intercept will be 1.000000 years (because for $a = 1$ AU, $P = 1$ year — Earth). The $R^2$ will be 1.000000000000. Not approximately. Exactly. This isn't a statistical correlation — it's a mathematical identity baked into `oe_period_years()`, which computes $a^{3/2}$. The query is a 600,000-row proof that the accessor functions are self-consistent.
<Aside type="tip" title="Why R² = 1 exactly">
`oe_period_years()` is defined as `a^1.5` where `a = q/(1-e)`. The regression isn't discovering a physical law — it's confirming that the accessor functions implement Kepler's third law without floating-point drift across the entire catalog. If you ever see R² < 1.0, something is wrong with your data (likely a corrupted MPC record).
</Aside>
### 3. Asteroid Family Taxonomy
Collisional families — groups of asteroids created by a single catastrophic impact — cluster tightly in (semi-major axis, eccentricity) space. A 2D `width_bucket()` grid reveals these density peaks as hot spots:
```sql
WITH belt AS (
SELECT oe_semi_major_axis(oe) AS a,
oe_eccentricity(oe) AS e
FROM asteroids
WHERE oe_eccentricity(oe) < 0.4
AND oe_semi_major_axis(oe) IS NOT NULL
AND oe_semi_major_axis(oe) BETWEEN 2.0 AND 3.5
)
SELECT round((2.0 + (a_bin - 1) * 0.03)::numeric, 2) AS a_au,
round((0.0 + (e_bin - 1) * 0.01)::numeric, 2) AS ecc,
count(*) AS n
FROM (
SELECT width_bucket(a, 2.0, 3.5, 50) AS a_bin,
width_bucket(e, 0.0, 0.4, 40) AS e_bin
FROM belt
) sub
GROUP BY a_bin, e_bin
HAVING count(*) >= 10
ORDER BY n DESC;
```
The highest-density cells correspond to known collisional families: Flora (~2.2 AU, e~0.15), Themis (~3.13 AU, e~0.15), Koronis (~2.87 AU, e~0.05), and Eos (~3.01 AU, e~0.07). The `HAVING count(*) >= 10` filter suppresses noise in sparsely populated cells. Increase the threshold to isolate only the major families; decrease it to reveal smaller groupings.
---
## Cross-Domain Observation
These queries combine satellite tracking, planetary ephemerides, and solar observation — functions backed by different physical theories, unified through pg_orrery's common `topocentric` return type.
### 4. Universal Sky Report — Everything at Once
Four gravitational theories in one query. `planet_observe()` uses VSOP87, `moon_observe()` uses ELP2000-82B, `observe_safe()` uses SGP4/SDP4, and `small_body_observe()` uses two-body Keplerian propagation. They all return `topocentric`, so `UNION ALL` works:
```sql
WITH obs AS (SELECT '40.0N 105.3W 1655m'::observer AS o),
sky AS (
-- Planets (VSOP87)
SELECT 'Mercury' AS body, planet_observe(1, o, now()) AS topo FROM obs
UNION ALL SELECT 'Venus', planet_observe(2, o, now()) FROM obs
UNION ALL SELECT 'Mars', planet_observe(4, o, now()) FROM obs
UNION ALL SELECT 'Jupiter', planet_observe(5, o, now()) FROM obs
UNION ALL SELECT 'Saturn', planet_observe(6, o, now()) FROM obs
UNION ALL SELECT 'Uranus', planet_observe(7, o, now()) FROM obs
UNION ALL SELECT 'Neptune', planet_observe(8, o, now()) FROM obs
-- Sun and Moon
UNION ALL SELECT 'Sun', sun_observe(o, now()) FROM obs
UNION ALL SELECT 'Moon', moon_observe(o, now()) FROM obs
-- Satellites (SGP4/SDP4) — observe_safe returns NULL for decayed TLEs
UNION ALL
SELECT s.name, observe_safe(s.tle, obs.o, now())
FROM satellites s, obs
WHERE s.norad_id IN (25544, 20580, 48274) -- ISS, HST, Tiangong
-- Asteroids (two-body Keplerian)
UNION ALL
SELECT a.name, small_body_observe(a.oe, obs.o, now())
FROM asteroids a, obs
WHERE a.name IN ('Ceres', 'Vesta', 'Pallas')
)
SELECT body,
round(topo_azimuth(topo)::numeric, 1) AS az,
round(topo_elevation(topo)::numeric, 1) AS el,
CASE WHEN topo_elevation(topo) > 0 THEN 'visible' ELSE 'below horizon' END AS status
FROM sky
WHERE topo IS NOT NULL
ORDER BY topo_elevation(topo) DESC;
```
Replace the NORAD IDs and asteroid names with whatever interests you. The `observe_safe` call is important for satellites — a decayed TLE will return NULL instead of raising an error, and the `WHERE topo IS NOT NULL` filter drops it cleanly.
### 5. Planetary Alignment Detector
How close are any two planets in the sky right now? The angular separation between two objects at (az₁, el₁) and (az₂, el₂) is the spherical law of cosines. PostgreSQL's built-in `sind()`, `cosd()`, and `acosd()` work in degrees — matching the degree output of `topo_azimuth()` and `topo_elevation()`:
```sql
WITH obs AS (SELECT '40.0N 105.3W 1655m'::observer AS o),
planets AS (
SELECT body_id, name,
planet_observe(body_id, o, now()) AS topo
FROM obs,
(VALUES (1,'Mercury'),(2,'Venus'),(4,'Mars'),
(5,'Jupiter'),(6,'Saturn')) AS p(body_id, name)
)
SELECT a.name AS body_a, b.name AS body_b,
round(acosd(
sind(topo_elevation(a.topo)) * sind(topo_elevation(b.topo)) +
cosd(topo_elevation(a.topo)) * cosd(topo_elevation(b.topo)) *
cosd(topo_azimuth(a.topo) - topo_azimuth(b.topo))
)::numeric, 1) AS separation_deg
FROM planets a
JOIN planets b ON a.body_id < b.body_id
WHERE topo_elevation(a.topo) > 0
AND topo_elevation(b.topo) > 0
ORDER BY separation_deg;
```
The `a.body_id < b.body_id` join condition gives each pair exactly once (VenusJupiter, not also JupiterVenus). Only above-horizon planets are included — no point measuring the angular separation of objects you can't see.
To find the closest approach over a year, sweep with `generate_series` and pick the tightest dates:
```sql
WITH obs AS (SELECT '40.0N 105.3W 1655m'::observer AS o),
sweep AS (
SELECT t,
planet_observe(5, o, t) AS jupiter,
planet_observe(6, o, t) AS saturn
FROM obs,
generate_series(
'2026-01-01'::timestamptz,
'2026-12-31'::timestamptz,
interval '1 day'
) AS t
)
SELECT t::date AS date,
round(acosd(
sind(topo_elevation(jupiter)) * sind(topo_elevation(saturn)) +
cosd(topo_elevation(jupiter)) * cosd(topo_elevation(saturn)) *
cosd(topo_azimuth(jupiter) - topo_azimuth(saturn))
)::numeric, 1) AS separation_deg
FROM sweep
WHERE topo_elevation(jupiter) > 0
AND topo_elevation(saturn) > 0
ORDER BY separation_deg
LIMIT 10;
```
### 6. ISS Eclipse Timing — Shadow Entry and Exit
A satellite enters Earth's shadow when the Sun is below the horizon at the satellite's nadir point. This chains three pg_orrery domains together: `subsatellite_point()` (SGP4 → geodetic), `observer_from_geodetic()` (geodetic → observer), and `sun_observe()` (VSOP87 → topocentric). The `lag()` window function then detects the sunlit/shadow transitions:
```sql
WITH orbit AS (
SELECT t,
subsatellite_point(s.tle, t) AS geo
FROM satellites s,
generate_series(now(), now() + interval '93 minutes', interval '30 seconds') AS t
WHERE s.norad_id = 25544
),
shadow AS (
SELECT t,
geodetic_lat(geo) AS lat,
geodetic_lon(geo) AS lon,
topo_elevation(
sun_observe(
observer_from_geodetic(geodetic_lat(geo), geodetic_lon(geo)),
t
)
) AS sun_el_at_nadir
FROM orbit
)
SELECT t,
round(lat::numeric, 2) AS lat,
round(lon::numeric, 2) AS lon,
round(sun_el_at_nadir::numeric, 1) AS sun_el,
CASE WHEN sun_el_at_nadir < 0 THEN 'SHADOW' ELSE 'SUNLIT' END AS state,
CASE
WHEN sun_el_at_nadir < 0 AND lag(sun_el_at_nadir) OVER (ORDER BY t) >= 0
THEN '>>> ECLIPSE ENTRY'
WHEN sun_el_at_nadir >= 0 AND lag(sun_el_at_nadir) OVER (ORDER BY t) < 0
THEN '<<< ECLIPSE EXIT'
END AS transition
FROM shadow
ORDER BY t;
```
<Aside type="caution" title="Approximation accuracy">
This treats the satellite's nadir point as the shadow boundary, which is geometrically simplified — it ignores the satellite's altitude above the surface and Earth's atmospheric refraction. For the ISS at ~400 km altitude, the shadow entry/exit times are accurate to roughly 1020 seconds. For precise eclipse predictions, you'd need a cylindrical or conical shadow model. But for observation planning — knowing *approximately* when the ISS goes dark — this is very usable.
</Aside>
### 7. Ground Track Geography with PostGIS
Where on Earth is the ISS flying over? Combine `ground_track()` with PostGIS spatial joins against Natural Earth country boundaries.
The simpler approach: a point-in-polygon test at each time step. Each (lat, lon) from the ground track becomes a PostGIS point, joined against country polygons:
```sql
WITH track AS (
SELECT t, lat, lon, alt
FROM satellites s,
ground_track(s.tle, now(), now() + interval '93 minutes', interval '30 seconds')
WHERE s.norad_id = 25544
)
SELECT track.t,
round(track.lat::numeric, 2) AS lat,
round(track.lon::numeric, 2) AS lon,
round(track.alt::numeric, 0) AS alt_km,
c.name AS country
FROM track
LEFT JOIN countries c
ON ST_Contains(c.geom, ST_SetSRID(ST_MakePoint(track.lon, track.lat), 4326));
```
The `LEFT JOIN` keeps rows over oceans (where `country` is NULL). The `ST_MakePoint()` argument order is (longitude, latitude) — x before y, the PostGIS convention.
<Aside type="note" title="PostGIS setup" id="postgis-setup">
Download [Natural Earth 110m countries](https://www.naturalearthdata.com/downloads/110m-cultural-vectors/) and load them:
```bash
wget https://naciscdn.org/naturalearth/110m/cultural/ne_110m_admin_0_countries.zip
unzip ne_110m_admin_0_countries.zip
shp2pgsql -s 4326 ne_110m_admin_0_countries.shp countries | psql -d your_database
```
This creates a `countries` table with `name` (text) and `geom` (geometry) columns. Add a spatial index for faster lookups:
```sql
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE INDEX ON countries USING gist (geom);
```
</Aside>
For a communications footprint, buffer the subsatellite point by the satellite's horizon radius. At ISS altitude (~400 km), the radio horizon is approximately 2,300 km:
```sql
-- Countries within ISS radio line-of-sight right now
WITH nadir AS (
SELECT subsatellite_point(s.tle, now()) AS geo
FROM satellites s
WHERE s.norad_id = 25544
)
SELECT c.name AS country
FROM nadir, countries c
WHERE ST_DWithin(
c.geom::geography,
ST_SetSRID(ST_MakePoint(geodetic_lon(geo), geodetic_lat(geo)), 4326)::geography,
2300000 -- 2,300 km horizon radius in meters
);
```
---
## The Solar System as Data
SQL views and aggregation functions turn pg_orrery's observation pipeline into data products — live dashboards and statistical breakdowns that update every time you query them.
### 8. Celestial Clock — Solar System Dashboard
One row. Every planet's elevation, the Moon, the Sun, Io's phase angle, Jupiter's central meridian longitude, and the decametric burst probability. Wrap it in a `CREATE VIEW` and `SELECT * FROM solar_system_now` becomes a real-time dashboard:
```sql
CREATE VIEW solar_system_now AS
SELECT now() AS computed_at,
round(topo_elevation(sun_observe(o, now()))::numeric, 1) AS sun_el,
round(topo_elevation(moon_observe(o, now()))::numeric, 1) AS moon_el,
round(topo_elevation(planet_observe(1, o, now()))::numeric, 1) AS mercury_el,
round(topo_elevation(planet_observe(2, o, now()))::numeric, 1) AS venus_el,
round(topo_elevation(planet_observe(4, o, now()))::numeric, 1) AS mars_el,
round(topo_elevation(planet_observe(5, o, now()))::numeric, 1) AS jupiter_el,
round(topo_elevation(planet_observe(6, o, now()))::numeric, 1) AS saturn_el,
round(topo_elevation(planet_observe(7, o, now()))::numeric, 1) AS uranus_el,
round(topo_elevation(planet_observe(8, o, now()))::numeric, 1) AS neptune_el,
round(io_phase_angle(now())::numeric, 1) AS io_phase,
round(jupiter_cml(o, now())::numeric, 1) AS jupiter_cml,
round(jupiter_burst_probability(
io_phase_angle(now()), jupiter_cml(o, now()))::numeric, 2) AS burst_prob
FROM (SELECT '40.0N 105.3W 1655m'::observer) AS cfg(o);
```
Because the view uses `now()`, every `SELECT` recomputes against the current time — no refresh needed. The conditional aggregation approach (one column per planet) avoids `tablefunc`/`crosstab` entirely. Change the observer literal to your coordinates.
<Aside type="tip" title="Parameterized version">
For multiple observers, replace the literal with a function parameter or a lookup table:
```sql
SELECT s.* FROM observers o,
LATERAL (SELECT * FROM solar_system_at(o.loc, now())) s;
```
That requires wrapping the view logic in a `CREATE FUNCTION`, but the pattern is the same.
</Aside>
### 9. Satellite Shell Census
How many satellites occupy each orbital shell? Compute altitude from the TLE's mean motion using Kepler's third law ($a = (\mu / n^2)^{1/3}$, altitude $= a - R_\oplus$), then classify into LEO/MEO/GEO/HEO:
```sql
WITH altitudes AS (
SELECT norad_id, name,
power(
398600.8 / power(tle_mean_motion(tle) * 2 * pi() / 86400.0, 2),
1.0 / 3.0
) - 6378.135 AS alt_km
FROM satellites
WHERE tle_mean_motion(tle) > 0
)
SELECT
CASE
WHEN alt_km < 2000 THEN 'LEO'
WHEN alt_km < 35786 THEN 'MEO'
WHEN alt_km < 35800 THEN 'GEO'
ELSE 'HEO/Other'
END AS shell,
count(*) AS n_satellites,
round(100.0 * count(*) / sum(count(*)) OVER (), 1) AS pct,
round(min(alt_km)::numeric, 0) AS min_alt_km,
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY alt_km)::numeric, 0) AS median_alt_km,
round(max(alt_km)::numeric, 0) AS max_alt_km
FROM altitudes
WHERE alt_km > 100 -- filter decayed objects
GROUP BY
CASE
WHEN alt_km < 2000 THEN 'LEO'
WHEN alt_km < 35786 THEN 'MEO'
WHEN alt_km < 35800 THEN 'GEO'
ELSE 'HEO/Other'
END
ORDER BY min_alt_km;
```
The 398600.8 is WGS-72 $\mu$ (km³/s²) and 6378.135 is WGS-72 $a_e$ (km) — the same constants SGP4 uses internally. The `percentile_cont(0.5)` gives the median altitude per shell, which is more informative than the mean when distributions are skewed (LEO has a long tail from Molniya-type parking orbits).
For a finer-grained altitude histogram within LEO — revealing the Starlink, ISS, sun-synchronous, and Iridium clusters:
```sql
WITH altitudes AS (
SELECT power(
398600.8 / power(tle_mean_motion(tle) * 2 * pi() / 86400.0, 2),
1.0 / 3.0
) - 6378.135 AS alt_km
FROM satellites
WHERE tle_mean_motion(tle) > 0
)
SELECT round((150 + (bucket - 1) * 10)::numeric, 0) AS alt_km,
count(*) AS n_satellites
FROM (
SELECT width_bucket(alt_km, 150, 2050, 190) AS bucket
FROM altitudes
WHERE alt_km BETWEEN 150 AND 2050
) sub
GROUP BY bucket
ORDER BY alt_km;
```
Plot `alt_km` vs `n_satellites` and you'll see pronounced peaks: a massive spike near 550 km (Starlink's operational shell), a cluster around 780 km (Iridium NEXT), concentrations at 500600 km (sun-synchronous polar orbits), and smaller peaks near 400 km (crewed missions) and 1200 km (older constellations).

View File

@ -0,0 +1,224 @@
---
title: Satellite Pass Prediction
sidebar:
order: 11
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Satellite pass prediction answers a deceptively simple question: "which satellites will fly over me tonight?" The brute-force approach -- propagating every object in a 30,000-satellite catalog with SGP4 at 10-second intervals over a 24-hour window -- requires billions of floating-point operations. pg_orrery solves this with the `&?` visibility cone operator, which applies three geometric filters (altitude, inclination, RAAN) to eliminate 80-90% of candidates without any SGP4 propagation. An optional SP-GiST index provides tree-level pruning for large catalogs.
## How you do it today
Ground station operators and amateur observers use several approaches:
- **Heavens-Above / N2YO**: Web tools that compute passes for a single observer. Great for casual use. Not queryable from SQL; you scrape or check manually.
- **Skyfield / PyEphem / predict**: Python libraries that propagate TLEs and compute topocentric coordinates. You write a loop over the catalog and check each object. Works, but scales linearly with catalog size.
- **GPredict / Orbitron**: Desktop applications for pass prediction. Local install, single observer, no database integration.
- **Custom scripts**: Propagate everything, compute elevation, filter. For a full catalog this takes minutes per observer per day.
The common thread: every approach propagates every satellite. There is no pre-filtering. A 30,000-object catalog takes the same time whether 2 satellites or 2,000 are visible from your location.
## What changes with pg_orrery
pg_orrery attacks the problem in two stages:
**Stage 1: SP-GiST index eliminates impossible candidates.** The `&?` operator checks whether a satellite _could_ be visible from a ground observer during a time window, using three geometric filters applied without SGP4 propagation:
| Filter | What it checks | What it eliminates |
|---|---|---|
| Altitude | Perigee too high for the observer's minimum elevation angle | MEO/GEO satellites for high-elevation queries |
| Inclination | Inclination + footprint angle must reach the observer's latitude | Equatorial satellites from high-latitude observers |
| RAAN | Right Ascension of Ascending Node alignment with observer's local sidereal time | Satellites whose orbital plane isn't overhead during the query window |
**Stage 2: SGP4 propagation on survivors.** The handful of candidates that pass the geometric filter are propagated with `predict_passes()` to find exact AOS/LOS times and maximum elevation.
The key type for queries is `observer_window`:
| Field | Type | Meaning |
|---|---|---|
| `obs` | `observer` | Ground location (lat, lon, altitude) |
| `t_start` | `timestamptz` | Start of observation window |
| `t_end` | `timestamptz` | End of observation window |
| `min_el` | `float8` | Minimum elevation angle in degrees |
## What pg_orrery does not replace
<Aside type="caution" title="Geometric filter, not a propagator">
The `&?` operator answers "could this satellite possibly be visible?" -- not "will it definitely pass overhead." It is a conservative superset: it may include satellites that do not actually produce a visible pass (false positives), but it will never exclude one that does (no false negatives). Always follow with `predict_passes()` for ground truth.
</Aside>
- **Not a pass schedule.** The `&?` operator does not compute AOS, LOS, or maximum elevation. Use `predict_passes()` on the candidates for precise timing.
- **No optical visibility.** The filter considers only geometric visibility (above the horizon at the required elevation). It does not check whether the satellite is sunlit, whether the observer is in darkness, or whether the pass is bright enough to see. Use `pass_visible()` to check illumination.
- **J2-only RAAN.** The RAAN filter projects the ascending node using only the J2 zonal harmonic. For short query windows (< 4 hours) the error is small. For long windows (> 12 hours) the RAAN filter automatically disables itself (full Earth rotation makes it meaningless).
## Try it
### Set up a catalog with the SP-GiST index
```sql
CREATE TABLE catalog (
norad_id integer PRIMARY KEY,
name text NOT NULL,
tle tle NOT NULL
);
-- Insert your TLEs (from CelesTrak, Space-Track, or any provider)
-- ISS example:
INSERT INTO catalog VALUES (25544, 'ISS',
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001');
-- Create the SP-GiST orbital trie index
CREATE INDEX catalog_spgist ON catalog USING spgist (tle tle_spgist_ops);
```
<Aside type="note">
The SP-GiST index is **not** the default operator class. You must explicitly specify `tle_spgist_ops`. The existing GiST index (`tle_ops`, used by `&&` and `<->`) is unaffected. Both indexes can coexist on the same table.
</Aside>
### Query: which satellites might be visible tonight?
```sql
-- Eagle, Idaho: 43.7N 116.4W, 760m elevation
-- Tonight's 6-hour window, minimum 10 deg elevation
SELECT name
FROM catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 01:00:00+00'::timestamptz,
'2024-01-01 07:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
```
This query runs the three geometric filters (altitude, inclination, RAAN) on every TLE in the catalog. With the SP-GiST index, PostgreSQL prunes entire subtrees of the index without examining individual TLEs.
### Full pass prediction workflow
<Steps>
1. **Filter candidates with `&?`:**
```sql
CREATE TEMPORARY TABLE candidates AS
SELECT norad_id, name, tle
FROM catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 01:00:00+00'::timestamptz,
'2024-01-01 07:00:00+00'::timestamptz,
10.0
)::observer_window;
```
For a 30,000-object catalog, this typically returns a few hundred candidates.
2. **Compute actual passes on survivors:**
```sql
SELECT c.name,
(p).aos_time, (p).los_time,
round((p).max_elevation::numeric, 1) AS max_el,
round((p).aos_azimuth::numeric, 1) AS aos_az
FROM candidates c,
LATERAL predict_passes(
c.tle,
observer('43.6977N 116.3535W 760m'),
'2024-01-01 01:00:00+00'::timestamptz,
'2024-01-01 07:00:00+00'::timestamptz,
10.0
) AS p
ORDER BY (p).aos_time;
```
Only the geometric survivors go through full SGP4 propagation.
3. **Filter for optical visibility (optional):**
```sql
SELECT c.name,
(p).aos_time, (p).los_time,
round((p).max_elevation::numeric, 1) AS max_el
FROM candidates c,
LATERAL predict_passes(
c.tle,
observer('43.6977N 116.3535W 760m'),
'2024-01-01 01:00:00+00'::timestamptz,
'2024-01-01 07:00:00+00'::timestamptz,
10.0
) AS p
WHERE pass_visible(c.tle, observer('43.6977N 116.3535W 760m'), (p).aos_time)
ORDER BY (p).max_elevation DESC;
```
This checks whether the satellite is sunlit while the observer is in darkness -- the condition for naked-eye visibility.
</Steps>
### Comparing query windows
<Tabs>
<TabItem label="Short window (2h)">
```sql
-- Short window: RAAN filter is most aggressive
-- Only satellites whose orbital plane is currently
-- aligned with the observer's meridian pass through
SELECT count(*) AS candidates
FROM catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 02:00:00+00'::timestamptz,
'2024-01-01 04:00:00+00'::timestamptz,
10.0
)::observer_window;
```
</TabItem>
<TabItem label="Full day (24h)">
```sql
-- 24-hour window: RAAN filter bypassed (full Earth rotation)
-- Only altitude and inclination filters apply
SELECT count(*) AS candidates
FROM catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window;
```
</TabItem>
<TabItem label="Equatorial observer">
```sql
-- Equatorial observer: all inclinations reach latitude 0
-- Only altitude and RAAN filters apply
SELECT count(*) AS candidates
FROM catalog
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2024-01-01 02:00:00+00'::timestamptz,
'2024-01-01 04:00:00+00'::timestamptz,
10.0
)::observer_window;
```
</TabItem>
</Tabs>
### Using both GiST and SP-GiST indexes
The two index types serve different purposes and coexist on the same table:
```sql
-- SP-GiST: "which satellites could I see tonight?"
CREATE INDEX catalog_spgist ON catalog USING spgist (tle tle_spgist_ops);
-- GiST: "which satellites share an orbital shell?"
CREATE INDEX catalog_gist ON catalog USING gist (tle tle_ops);
-- Both queries use their respective indexes
SELECT name FROM catalog WHERE tle &? ROW(...)::observer_window; -- SP-GiST
SELECT name FROM catalog WHERE tle && other_tle; -- GiST
```
<Aside type="tip" title="When to use which index">
Use the **SP-GiST** index (`&?` operator) for observer-to-catalog queries: "what can I see from here?" Use the **GiST** index (`&&` and `<->` operators) for catalog-to-catalog queries: "which satellites are in the same orbital shell?" The SP-GiST index partitions by SMA and inclination with a query-time RAAN filter. The GiST index partitions by altitude band and inclination range for overlap detection.
</Aside>

View File

@ -43,7 +43,7 @@ pg_orrery propagates TLEs and computes look angles. It does not replace the full
- **No real-time GUI.** GPredict and STK provide map displays, polar plots, and Doppler displays. pg_orrery returns numbers. Use any visualization tool to render its output.
- **No rotator control.** Hamlib drives antenna rotators. pg_orrery computes the azimuth and elevation values Hamlib would consume, but it has no hardware interface.
- **No TLE fetching.** Bring your own TLEs from Space-Track, CelesTrak, or any provider. pg_orrery parses and propagates them.
- **TLE fetching via companion tool.** pg_orrery itself doesn't download TLEs, but [`pg-orrery-catalog`](/guides/catalog-management/) handles the full pipeline: download from Space-Track, CelesTrak, and SatNOGS, merge with epoch-based dedup, and load into PostgreSQL.
- **Orbit determination available.** Since v0.4.0, pg_orrery can fit TLEs from ECI, topocentric, or angles-only observations via differential correction. See the [Orbit Determination guide](/guides/orbit-determination/).
- **No high-precision propagation.** SGP4/SDP4 accuracy degrades with TLE age. For operational conjunction assessment, use SP ephemerides or owner/operator-provided state vectors. pg_orrery's GiST screening finds candidates; you verify with better data.

View File

@ -6,7 +6,7 @@ sidebar:
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Measured performance numbers for pg_orrery's core operations. Every number on this page was produced by running the listed SQL query against a live PostgreSQL 17 instance with a single backend, no parallel workers, and no connection pooling overhead.
Measured performance numbers for pg_orrery's core operations. Every number on this page was produced by running the listed SQL query against a live PostgreSQL 18 instance with a single backend, no parallel workers, and no connection pooling overhead.
<Aside type="note" title="Methodology">
All benchmarks use PostgreSQL's `EXPLAIN (ANALYZE, BUFFERS)` for timing. The numbers are wall-clock execution time for the query, not per-function overhead. Each benchmark was run three times; the reported value is the median. Cold start was avoided by running each query once before measurement.
@ -17,6 +17,9 @@ All benchmarks use PostgreSQL's `EXPLAIN (ANALYZE, BUFFERS)` for timing. The num
| Operation | Count | Time | Rate | Notes |
|-----------|-------|------|------|-------|
| TLE propagation (SGP4) | 12,000 | 17 ms | 706K/sec | Mixed LEO/MEO/GEO |
| Visibility cone filter (`&?`) | 66,440 | 12.1 ms | 5.5M/sec | 84% pruned (2h, 10°), no SGP4 |
| Conjunction screening (`&&`) | 66,440 | 4.6 ms | — | ISS: 9 co-orbital objects found |
| KNN orbital distance (`<->`) | 66,440 | 2.1 ms | — | 10 nearest to ISS, 2-D index-ordered |
| Planet observation (VSOP87) | 875 | 57 ms | 15.4K/sec | All 7 non-Earth planets, 125 times each |
| Galilean moon observation | 1,000 | 63 ms | 15.9K/sec | L1.2 + VSOP87 pipeline |
| Saturn moon observation | 800 | 53 ms | 15.1K/sec | TASS17 + VSOP87 |
@ -24,7 +27,7 @@ All benchmarks use PostgreSQL's `EXPLAIN (ANALYZE, BUFFERS)` for timing. The num
| Lambert transfer solve | 100 | 0.1 ms | 800K/sec | Single-rev prograde |
| Pork chop plot (150 x 150) | 22,500 | 8.3 s | 2.7K/sec | Full VSOP87 + Lambert pipeline |
**Conditions:** PostgreSQL 17.2, single backend, no parallel workers, Intel Xeon E-2286G @ 4.0 GHz, 64 GB ECC DDR4-2666. Extension compiled with GCC 14.2, `-O2`.
**Conditions:** PostgreSQL 18.1, single backend, no parallel workers, Intel Xeon E-2286G @ 4.0 GHz, 64 GB ECC DDR4-2666. Extension compiled with GCC 14.2, `-O2`.
## TLE propagation
@ -219,12 +222,151 @@ FROM predict_passes(
A 7-day window at 30-second coarse scan resolution requires ~20,160 propagation calls for the coarse scan, plus bisection and ternary search calls for each pass found. Typical ISS result: 25--35 passes found in ~40 ms.
## Visibility cone filtering (`&?` operator)
The `&?` operator answers "could this satellite possibly be visible from this observer?" using three geometric filters (altitude, inclination, RAAN) without any SGP4 propagation. This is the first stage of the pass prediction pipeline, reducing the number of satellites that need full propagation.
```sql
-- Benchmark: filter a 66,440-object catalog
-- Eagle, Idaho: 2-hour window, 10 deg minimum elevation
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM satellite_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 02:00:00+00'::timestamptz,
'2024-01-01 04:00:00+00'::timestamptz,
10.0
)::observer_window;
```
**66,440 TLEs filtered in 12.1 ms --- 83.8% pruned, 10,763 candidates survive.**
The operator evaluates three geometric conditions per TLE: perigee altitude vs. maximum visible altitude, inclination + ground footprint vs. observer latitude, and RAAN alignment via J2 secular precession. Each check is a few floating-point operations --- no SGP4 initialization, no Kepler equation, no trigonometric series.
### Pruning rate by query pattern
Measured against a 66,440-object catalog merged from Space-Track, CelesTrak, SatNOGS, and CelesTrak SupGP. The pruning rate depends on observer latitude, query window duration, and minimum elevation. Shorter windows and higher minimum elevations prune more aggressively.
| Query | Candidates | Pruned | Notes |
|-------|-----------|--------|-------|
| 2h, Eagle ID (43.7°N), 10° | 10,763 | 83.8% | Typical mid-latitude evening |
| 2h, Equator (0°N), 10° | 10,174 | 84.7% | All inclinations pass latitude check; RAAN filter dominates |
| 2h, Eagle ID, 45° | 6,796 | 89.8% | Higher elevation: altitude filter tighter |
| 24h, Eagle ID, 10° | 61,426 | 7.5% | RAAN filter bypassed (full Earth rotation) |
### SP-GiST index performance
The optional SP-GiST index (`tle_spgist_ops`) builds a 2-level trie partitioned by semi-major axis and inclination. At 66,440 objects, sequential evaluation of the `&?` operator (12 ms) is faster than the SP-GiST index scan (16--23 ms). The tree traversal overhead exceeds the pruning benefit at this catalog size because the `&?` operator itself is so cheap --- three floating-point comparisons per TLE.
| Query | Seqscan | SP-GiST | Candidates | Pruned |
|-------|---------|---------|------------|--------|
| 2h, Eagle ID, 10° | 12.1 ms | 16.1 ms | 10,763 | 83.8% |
| 2h, Equator, 10° | 12.1 ms | 16.8 ms | 10,174 | 84.7% |
| 2h, Eagle ID, 45° | 11.9 ms | 16.9 ms | 6,796 | 89.8% |
| 24h, Eagle ID, 10° | 12.5 ms | 23.3 ms | 61,426 | 7.5% |
The SP-GiST index achieves zero heap fetches (pure Index Only Scan), but page traversal through 11 MB of index data (4,964 buffer hits) exceeds the cost of a 1,338-buffer sequential scan.
<Aside type="tip" title="Where the SP-GiST index adds value">
The `&?` operator prunes 84--90% of the catalog regardless of scan method. Its primary value is as a **gating filter** before expensive SGP4 propagation. For a 2-hour window, reducing 66,440 TLEs to ~10,000 candidates saves ~56,000 `predict_passes()` calls (each ~1 ms), a far greater benefit than the 4 ms difference between scan methods.
At larger catalog sizes (200k+ objects), the SP-GiST tree-level pruning should begin to outperform sequential evaluation. The crossover point depends on hardware, but the operator's pruning ratio is the dominant performance factor, not the scan method.
</Aside>
### What the pruning means for predict_passes()
For a 66,440-object catalog and a 2-hour window from Eagle, Idaho:
- **Without `&?`:** 66,440 `predict_passes()` calls (each ~1 ms for a 7-day window)
- **With `&?`:** 10,763 calls --- **55,677 unnecessary propagations avoided**
- **Time saved:** ~56 seconds per query at typical propagation cost
## Conjunction screening (`&&` operator)
The GiST index on the `tle` type enables indexed conjunction screening using the `&&` (overlap) operator. The index stores altitude band and inclination for each TLE, allowing PostgreSQL to skip entire subtrees of non-overlapping orbits.
```sql
-- Benchmark: find ISS conjunction candidates in a 66,440-object catalog
EXPLAIN (ANALYZE, BUFFERS)
SELECT b.name
FROM satellite_catalog a
JOIN satellite_catalog b ON a.tle && b.tle AND a.norad_id != b.norad_id
WHERE tle_norad_id(a.tle) = 25544;
```
**9 co-orbital objects found in 4.6 ms (vs. 63.3 ms sequential scan --- 5.8x speedup).**
The GiST index scan hits 237 buffers compared to 1,338 for a sequential scan. The 9 objects returned are all ISS-visiting vehicles or co-orbital modules: PROGRESS MS-31, PROGRESS MS-32, SOYUZ MS-28, DRAGON FREEDOM 3, DRAGON CRS-33, CYGNUS NG-23, HTV-X1, ISS (NAUKA), and OBJECT E.
### GiST `&&` performance by orbital regime
| Probe satellite | GiST | Seqscan | Matches | Notes |
|----------------|------|---------|---------|-------|
| ISS (LEO, 51.6°) | 4.6 ms | 63.3 ms | 9 | Co-orbital vehicles |
| Starlink-230369 (LEO, 53°) | 9.5 ms | 14.9 ms | 0 | Dense LEO shell |
| SYNCOM 2 (GEO, 33°) | 4.0 ms | 7.2 ms | 0 | Sparse regime |
The GiST index provides the largest speedup for queries that return few matches, where the index prunes most of the tree without reading leaf pages. Dense LEO shells produce more candidates and reduce the speedup ratio.
### Index characteristics
| Metric | Value |
|--------|-------|
| Build time | 93 ms (66,440 TLEs) |
| Index size | 15 MB (237 bytes/object) |
| Consistency | 0 false positives, 0 false negatives (verified against seqscan) |
## KNN orbital distance (`<->` operator)
The `<->` operator computes 2-D orbital distance in km, combining altitude-band separation with inclination gap (converted to km via Earth radius). With a GiST index, it supports index-ordered KNN queries --- PostgreSQL traverses the tree by increasing distance without computing all distances upfront.
```sql
-- Benchmark: 10 nearest orbits to the ISS by 2-D orbital distance
EXPLAIN (ANALYZE, BUFFERS)
SELECT name,
round((tle <-> (SELECT tle FROM satellite_catalog
WHERE tle_norad_id(tle) = 25544 LIMIT 1))::numeric, 1)
AS orbital_dist_km
FROM satellite_catalog
ORDER BY tle <-> (SELECT tle FROM satellite_catalog
WHERE tle_norad_id(tle) = 25544 LIMIT 1)
LIMIT 10;
```
**10 nearest in 2.1 ms, index-ordered (982 buffer hits).**
### KNN performance by scenario
| Query | Time | Buffers | Notes |
|-------|------|---------|-------|
| 10 nearest to ISS (LEO) | 2.1 ms | 982 | Dense regime, 2-D distance breaks altitude ties |
| 10 nearest to SYNCOM 2 (GEO) | 0.2 ms | 40 | Sparse regime, fewer nodes |
| 100 nearest to ISS | 1.4 ms | 1,062 | Marginal cost per additional neighbor |
| All within 50 km of ISS | 16.0 ms | 4,014 | 12,496 matches |
<Aside type="caution" title="KNN requires a scalar subquery probe">
GiST index-ordered scans only activate when the probe value is visible to the planner as a constant. Use a **scalar subquery** for the probe TLE:
```sql
-- This uses the index (scalar subquery → constant to planner):
ORDER BY tle <-> (SELECT tle FROM catalog WHERE tle_norad_id(tle) = 25544 LIMIT 1)
-- This does NOT use the index (CTE is opaque to the planner):
WITH iss AS (SELECT tle FROM catalog WHERE tle_norad_id(tle) = 25544)
SELECT ... ORDER BY tle <-> iss.tle -- falls back to full scan + sort
```
The CTE pattern works correctly but forces PostgreSQL to compute all distances and sort, which is much slower for large catalogs. For small catalogs (< 100 rows), the difference is negligible.
</Aside>
## Reproducing these benchmarks
<Tabs>
<TabItem label="Requirements">
- PostgreSQL 17 with pg_orrery installed
- A satellite catalog table with ~12,000 TLEs (available from CelesTrak)
- PostgreSQL 18 with pg_orrery installed
- A satellite catalog table (the numbers on this page use a 66,440-object catalog merged from Space-Track, CelesTrak, SatNOGS, and CelesTrak SupGP; see [Building TLE Catalogs](/guides/catalog-management/))
- GiST and SP-GiST indexes on the `tle` column for index benchmarks
- A star catalog table (any subset of Hipparcos or Yale BSC)
- No concurrent queries during measurement
- `shared_buffers` and `work_mem` at default or higher
@ -233,13 +375,18 @@ A 7-day window at 30-second coarse scan resolution requires ~20,160 propagation
```sql
CREATE EXTENSION pg_orrery;
-- Load a TLE catalog
CREATE TABLE satellite_catalog (tle tle);
-- (COPY from CelesTrak bulk TLE file)
-- Load a TLE catalog (pg-orrery-catalog handles this)
-- pg-orrery-catalog build --table satellite_catalog | psql -d mydb
CREATE TABLE satellite_catalog (name text, tle tle);
-- (or COPY from CelesTrak bulk TLE file)
-- Create both indexes for full benchmark coverage
CREATE INDEX idx_tle_gist ON satellite_catalog USING gist (tle tle_ops);
CREATE INDEX idx_tle_spgist ON satellite_catalog USING spgist (tle tle_spgist_ops);
-- Verify catalog size
SELECT count(*) FROM satellite_catalog;
-- Expected: ~12,000 rows
-- The numbers on this page use 66,440 rows
-- Disable parallel workers for baseline measurement
SET max_parallel_workers_per_gather = 0;
@ -265,4 +412,8 @@ A 7-day window at 30-second coarse scan resolution requires ~20,160 propagation
The benchmarks demonstrate that pg_orrery's computation cost is low enough to treat orbital mechanics as a SQL primitive. Propagating an entire satellite catalog takes less time than a typical index scan on a moderately-sized table. Planet observation is fast enough to generate ephemeris tables with `generate_series`. Pork chop plots are feasible as interactive queries rather than batch jobs.
The numbers also show where the bottlenecks are: VSOP87 series evaluation dominates everything except star observation and raw SGP4 propagation. If a future optimization effort targets one component, it should be the VSOP87 evaluation loop.
The visibility cone filter (`&?`) is the fastest operation per evaluation --- three floating-point comparisons vs. the full SGP4 pipeline --- and its 84--90% pruning rate means the most expensive operation in a pass prediction pipeline (SGP4 propagation) only runs on the small fraction of the catalog that could actually produce a visible pass.
The GiST index provides the clearest speedup for conjunction screening: 5.8x faster than sequential scan for ISS `&&` queries, with 0 false positives or negatives verified against exhaustive sequential evaluation. KNN queries find the nearest orbits in 2 ms via index-ordered traversal using 2-D orbital distance (altitude + inclination), which would otherwise require computing and sorting all 66,440 distances.
The numbers also show where the bottlenecks are: VSOP87 series evaluation dominates everything except star observation, raw SGP4 propagation, and the geometric filters. If a future optimization effort targets one component, it should be the VSOP87 evaluation loop.

View File

@ -303,6 +303,71 @@ mars_moon_observe_de(moon_id int4, obs observer, t timestamptz) → topocentric
---
## planet_equatorial_de
Computes the geocentric apparent equatorial coordinates (RA/Dec) of a planet using JPL DE ephemeris. Falls back to VSOP87 plus the equatorial conversion when DE is unavailable.
### Signature
```sql
planet_equatorial_de(body_id int4, t timestamptz) → equatorial
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `body_id` | `int4` | Planet identifier (1-8, excluding 0 and 3) |
| `t` | `timestamptz` | Evaluation time |
### Returns
An `equatorial` with RA (hours), Dec (degrees), and distance (km) from Earth's center.
### Example
```sql
-- Compare DE vs VSOP87 equatorial coordinates for Mars
SELECT round(eq_ra(planet_equatorial(4, now()))::numeric, 6) AS ra_vsop87,
round(eq_ra(planet_equatorial_de(4, now()))::numeric, 6) AS ra_de,
round(eq_dec(planet_equatorial(4, now()))::numeric, 6) AS dec_vsop87,
round(eq_dec(planet_equatorial_de(4, now()))::numeric, 6) AS dec_de;
```
---
## moon_equatorial_de
Computes the geocentric apparent equatorial coordinates (RA/Dec) of the Moon using JPL DE ephemeris. Falls back to ELP2000-82B plus the equatorial conversion when DE is unavailable.
### Signature
```sql
moon_equatorial_de(t timestamptz) → equatorial
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `t` | `timestamptz` | Evaluation time |
### Returns
An `equatorial` with RA (hours), Dec (degrees), and distance (km) from Earth's center.
### Example
```sql
-- Moon RA/Dec via DE
SELECT round(eq_ra(e)::numeric, 4) AS ra_hours,
round(eq_dec(e)::numeric, 4) AS dec_deg,
round(eq_distance(e)::numeric, 0) AS dist_km
FROM moon_equatorial_de(now()) AS e;
```
---
## pg_orrery_ephemeris_info
Returns diagnostic information about the current ephemeris provider.

View File

@ -0,0 +1,209 @@
---
title: "Functions: Atmospheric Refraction"
sidebar:
order: 7
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Functions for computing atmospheric refraction corrections using Bennett's (1982) empirical formula. Earth's atmosphere bends light from celestial objects, making them appear higher above the horizon than their true geometric position. Near the horizon, refraction is approximately 0.57 degrees --- enough to extend satellite visibility windows by roughly 35 seconds at AOS and LOS, and to make the Sun appear above the horizon when it has already geometrically set.
---
## atmospheric_refraction
Computes the atmospheric refraction correction in degrees for a given geometric elevation using Bennett's (1982) formula under standard atmosphere conditions (pressure 1010 mbar, temperature 10 C). The domain is clamped at -1 degree to avoid singularity in the cotangent term.
### Signature
```sql
atmospheric_refraction(elevation_deg float8) → float8
```
### Parameters
| Parameter | Type | Unit | Description |
|-----------|------|------|-------------|
| `elevation_deg` | `float8` | degrees | Geometric elevation of the object above the horizon |
### Returns
Refraction correction in degrees. Always positive --- add this value to the geometric elevation to get the apparent elevation. At the horizon (0 degrees), refraction is approximately 0.57 degrees. It drops rapidly with increasing elevation and is negligible above 45 degrees.
<Aside type="note">
The domain guard at -1 degree prevents numerical blowup in the cotangent. Objects below -1 degree geometric elevation are deeply below the horizon and refraction is not physically meaningful there.
</Aside>
### Example
```sql
-- Refraction at various elevations
SELECT elevation,
round(atmospheric_refraction(elevation)::numeric, 4) AS refraction_deg
FROM unnest(ARRAY[-1, 0, 5, 10, 20, 45, 90]) AS elevation;
```
```sql
-- How much does refraction shift the Sun at sunset?
SELECT round(atmospheric_refraction(0)::numeric, 4) AS horizon_refraction_deg;
```
---
## atmospheric_refraction_ext
Computes atmospheric refraction with a pressure and temperature correction factor applied to Bennett's formula, following the Meeus formulation. Useful for high-altitude observatories or extreme weather conditions where standard atmosphere assumptions break down.
### Signature
```sql
atmospheric_refraction_ext(elevation_deg float8, pressure_mbar float8, temp_celsius float8) → float8
```
### Parameters
| Parameter | Type | Unit | Description |
|-----------|------|------|-------------|
| `elevation_deg` | `float8` | degrees | Geometric elevation of the object above the horizon |
| `pressure_mbar` | `float8` | mbar | Atmospheric pressure at the observer |
| `temp_celsius` | `float8` | C | Air temperature at the observer |
### Returns
Refraction correction in degrees, adjusted for the given pressure and temperature. The correction factor is `(P / 1010) * (283 / (273 + T))` applied to the standard Bennett formula result.
### Example
```sql
-- Refraction at Mauna Kea summit (4205m, ~620 mbar, -2C)
SELECT round(atmospheric_refraction_ext(5.0, 620.0, -2.0)::numeric, 4) AS refraction_mauna_kea,
round(atmospheric_refraction(5.0)::numeric, 4) AS refraction_standard;
```
```sql
-- Compare standard vs corrected refraction across a range of elevations
SELECT elevation,
round(atmospheric_refraction(elevation)::numeric, 4) AS standard,
round(atmospheric_refraction_ext(elevation, 850.0, -10.0)::numeric, 4) AS high_altitude_cold
FROM unnest(ARRAY[0, 2, 5, 10, 30]) AS elevation;
```
---
## topo_elevation_apparent
Convenience function that returns the apparent elevation of an object by adding the atmospheric refraction correction to the geometric elevation stored in a `topocentric` value. The result is in degrees.
### Signature
```sql
topo_elevation_apparent(topocentric) → float8
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| (unnamed) | `topocentric` | A topocentric observation result from any `*_observe` function |
### Returns
Apparent elevation in degrees --- the geometric elevation plus the Bennett refraction correction under standard atmosphere. Always higher than the geometric `topo_elevation()` value.
### Example
```sql
-- Compare geometric vs apparent elevation for the Moon
SELECT round(topo_elevation(t)::numeric, 3) AS geometric_el,
round(topo_elevation_apparent(t)::numeric, 3) AS apparent_el,
round(topo_elevation_apparent(t) - topo_elevation(t)::numeric, 4) AS refraction_correction
FROM moon_observe('40.0N 105.3W 1655m'::observer, now()) AS t;
```
```sql
-- Find objects that are geometrically below horizon but visible due to refraction
SELECT norad_id,
round(topo_elevation(o)::numeric, 3) AS geometric_el,
round(topo_elevation_apparent(o)::numeric, 3) AS apparent_el
FROM satellite_catalog,
observe_safe(tle, '40.0N 105.3W 1655m'::observer, now()) AS o
WHERE o IS NOT NULL
AND topo_elevation(o) < 0
AND topo_elevation_apparent(o) > 0;
```
---
## predict_passes_refracted
Predicts satellite passes using a refracted horizon threshold instead of the geometric horizon. The geometric threshold is set to -0.569 degrees, which corresponds to the apparent horizon after atmospheric refraction. This means satellites become visible approximately 35 seconds earlier at AOS and remain visible approximately 35 seconds later at LOS compared to `predict_passes`.
### Signature
```sql
predict_passes_refracted(
tle tle,
obs observer,
start_time timestamptz,
end_time timestamptz,
min_el float8 DEFAULT 0.0
) → SETOF pass_event
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `tle` | `tle` | | Satellite TLE |
| `obs` | `observer` | | Observer location |
| `start_time` | `timestamptz` | | Start of the search window |
| `end_time` | `timestamptz` | | End of the search window |
| `min_el` | `float8` | `0.0` | Minimum peak elevation in degrees. Passes whose maximum elevation is below this threshold are excluded. |
### Returns
A set of `pass_event` records, ordered by AOS time. Each pass will show slightly earlier AOS and later LOS times compared to `predict_passes` due to the refracted horizon.
<Aside type="tip">
The refracted threshold of -0.569 degrees geometric matches what visual observers actually experience --- the atmosphere bends satellite light so it is visible even when the satellite is geometrically below the horizon. Use this function for scheduling visual observations, antenna pointing, or any application where the physical visibility window matters.
</Aside>
### Example
```sql
-- Compare geometric vs refracted pass predictions for the ISS
WITH iss AS (
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS tle
)
SELECT pass_aos_time(p) AS rise,
pass_max_elevation(p) AS max_el,
pass_los_time(p) AS set,
pass_duration(p) AS dur
FROM iss,
predict_passes_refracted(tle, '40.0N 105.3W 1655m'::observer,
now(), now() + interval '24 hours', 10.0) AS p;
```
```sql
-- How much extra visibility does refraction add?
WITH iss AS (
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS tle
),
geo AS (
SELECT pass_duration(p) AS dur
FROM iss, predict_passes(tle, '40.0N 105.3W 1655m'::observer,
now(), now() + interval '24 hours') AS p
LIMIT 1
),
refr AS (
SELECT pass_duration(p) AS dur
FROM iss, predict_passes_refracted(tle, '40.0N 105.3W 1655m'::observer,
now(), now() + interval '24 hours') AS p
LIMIT 1
)
SELECT geo.dur AS geometric_duration,
refr.dur AS refracted_duration
FROM geo, refr;
```

View File

@ -527,3 +527,118 @@ FROM satellite_catalog
WHERE pass_visible(tle, '40.0N 105.3W 1655m'::observer,
'2024-06-15 02:00:00+00', '2024-06-15 10:00:00+00');
```
---
## eci_to_equatorial
Converts a TEME ECI position to topocentric apparent equatorial coordinates (RA/Dec) for a given observer. The observer's position is subtracted from the satellite's ECI vector to produce parallax-corrected coordinates. For LEO satellites, observer parallax is approximately 1 degree.
### Signature
```sql
eci_to_equatorial(pos eci_position, obs observer, t timestamptz) → equatorial
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `pos` | `eci_position` | TEME ECI position and velocity |
| `obs` | `observer` | Observer location on Earth |
| `t` | `timestamptz` | Time of the position (for sidereal time computation) |
### Returns
An `equatorial` with RA (hours), Dec (degrees), and distance (km) from the observer's perspective.
### Example
```sql
WITH iss AS (
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS tle
)
SELECT round(eq_ra(e)::numeric, 4) AS ra_hours,
round(eq_dec(e)::numeric, 4) AS dec_deg,
round(eq_distance(e)::numeric, 1) AS dist_km
FROM iss,
eci_to_equatorial(
sgp4_propagate(tle, now()),
'40.0N 105.3W 1655m'::observer,
now()
) AS e;
```
---
## eci_to_equatorial_geo
Converts a TEME ECI position to geocentric apparent equatorial coordinates (RA/Dec). This is the direction of the position vector as seen from Earth's center, independent of any observer location.
### Signature
```sql
eci_to_equatorial_geo(pos eci_position, t timestamptz) → equatorial
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `pos` | `eci_position` | TEME ECI position |
| `t` | `timestamptz` | Time of the position |
### Returns
An `equatorial` with geocentric RA (hours), Dec (degrees), and distance (km) from Earth's center.
### Example
```sql
-- Geocentric RA/Dec of the ISS
WITH iss AS (
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS tle
)
SELECT round(eq_ra(e)::numeric, 4) AS ra_hours,
round(eq_dec(e)::numeric, 4) AS dec_deg
FROM iss,
eci_to_equatorial_geo(sgp4_propagate(tle, now()), now()) AS e;
```
---
## predict_passes_refracted
Predicts satellite passes using a refracted horizon threshold (-0.569 degrees geometric) instead of the geometric horizon. Atmospheric refraction makes satellites visible approximately 35 seconds earlier at AOS and later at LOS.
<Aside type="tip">
See [Functions: Atmospheric Refraction](/reference/functions-refraction/) for the full documentation on this function and the underlying refraction model.
</Aside>
### Signature
```sql
predict_passes_refracted(
tle tle,
obs observer,
start_time timestamptz,
end_time timestamptz,
min_el float8 DEFAULT 0.0
) → SETOF pass_event
```
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `tle` | `tle` | | Satellite TLE |
| `obs` | `observer` | | Observer location |
| `start_time` | `timestamptz` | | Start of the search window |
| `end_time` | `timestamptz` | | End of the search window |
| `min_el` | `float8` | `0.0` | Minimum peak elevation in degrees |
### Returns
A set of `pass_event` records with refraction-extended visibility windows.

View File

@ -228,3 +228,262 @@ FROM generate_series(
moon_observe('40.0N 105.3W 1655m'::observer, t) AS m
WHERE topo_elevation(m) > 0;
```
---
## planet_equatorial
Computes the geocentric apparent equatorial coordinates (RA/Dec) of a planet at a given time using VSOP87. The heliocentric ecliptic position is converted to geocentric equatorial and precessed to the date of observation via IAU 1976 precession.
### Signature
```sql
planet_equatorial(body_id int4, t timestamptz) → equatorial
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `body_id` | `int4` | Planet identifier (1-8, same as `planet_heliocentric` excluding 0 and 3) |
| `t` | `timestamptz` | Evaluation time |
### Returns
An `equatorial` with RA (hours), Dec (degrees), and distance (km) from Earth's center.
### Example
```sql
-- Current RA/Dec of all planets
SELECT body_id,
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,
round(eq_ra(e)::numeric, 4) AS ra_h,
round(eq_dec(e)::numeric, 4) AS dec_deg,
round(eq_distance(e)::numeric, 0) AS dist_km
FROM unnest(ARRAY[1,2,4,5,6,7,8]) AS body_id,
planet_equatorial(body_id, now()) AS e;
```
---
## sun_equatorial
Computes the geocentric apparent equatorial coordinates (RA/Dec) of the Sun at a given time using VSOP87.
### Signature
```sql
sun_equatorial(t timestamptz) → equatorial
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `t` | `timestamptz` | Evaluation time |
### Returns
An `equatorial` with RA (hours), Dec (degrees), and distance (km) from Earth's center.
### Example
```sql
-- Sun's current RA/Dec
SELECT round(eq_ra(e)::numeric, 4) AS ra_hours,
round(eq_dec(e)::numeric, 4) AS dec_deg,
round(eq_distance(e)::numeric, 0) AS dist_km
FROM sun_equatorial(now()) AS e;
```
---
## moon_equatorial
Computes the geocentric apparent equatorial coordinates (RA/Dec) of the Moon at a given time using ELP2000-82B.
### Signature
```sql
moon_equatorial(t timestamptz) → equatorial
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `t` | `timestamptz` | Evaluation time |
### Returns
An `equatorial` with RA (hours), Dec (degrees), and distance (km) from Earth's center.
### Example
```sql
-- Moon's current RA/Dec and distance
SELECT round(eq_ra(e)::numeric, 4) AS ra_hours,
round(eq_dec(e)::numeric, 4) AS dec_deg,
round(eq_distance(e)::numeric, 0) AS dist_km
FROM moon_equatorial(now()) AS e;
```
```sql
-- Moon's RA/Dec path over one lunation at daily intervals
SELECT t::date AS date,
round(eq_ra(e)::numeric, 3) AS ra_h,
round(eq_dec(e)::numeric, 3) AS dec_deg
FROM generate_series(
now(), now() + interval '29 days', interval '1 day'
) AS t,
moon_equatorial(t) AS e;
```
---
## planet_observe_apparent
Computes the topocentric position of a planet with single-iteration light-time correction. The planet's position is evaluated at the retarded time (observation time minus light travel time), while Earth's position is evaluated at the observation time. Uses VSOP87.
### Signature
```sql
planet_observe_apparent(body_id int4, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `body_id` | `int4` | Planet identifier (1-8, excluding 0 and 3) |
| `obs` | `observer` | Observer location on Earth |
| `t` | `timestamptz` | Observation time |
### Returns
A `topocentric` with azimuth, elevation, range (km), and range rate (km/s). The range reflects the geometric distance at the retarded time.
<Aside type="note">
For the inner planets, light-time correction changes the apparent position by a few arcseconds to a few tens of arcseconds. For the outer planets, the effect can be several arcminutes. Use `planet_observe()` when light-time correction is not needed (faster, single VSOP87 evaluation per body).
</Aside>
### Example
```sql
-- Compare geometric vs light-time corrected Mars observation
SELECT round(topo_azimuth(g)::numeric, 4) AS az_geo,
round(topo_azimuth(a)::numeric, 4) AS az_apparent,
round(topo_elevation(g)::numeric, 4) AS el_geo,
round(topo_elevation(a)::numeric, 4) AS el_apparent
FROM planet_observe(4, '40.0N 105.3W 1655m'::observer, now()) AS g,
planet_observe_apparent(4, '40.0N 105.3W 1655m'::observer, now()) AS a;
```
---
## sun_observe_apparent
Computes the topocentric position of the Sun with light-time correction (approximately 8.3 minutes). Uses VSOP87.
### Signature
```sql
sun_observe_apparent(obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `obs` | `observer` | Observer location on Earth |
| `t` | `timestamptz` | Observation time |
### Returns
A `topocentric` with azimuth, elevation, range (km), and range rate (km/s).
### Example
```sql
-- Sun position with light-time correction
SELECT round(topo_azimuth(t)::numeric, 4) AS az,
round(topo_elevation(t)::numeric, 4) AS el
FROM sun_observe_apparent('40.0N 105.3W 1655m'::observer, now()) AS t;
```
---
## planet_equatorial_apparent
Computes the geocentric apparent equatorial coordinates (RA/Dec) of a planet with light-time correction. The planet is evaluated at the retarded time. Uses VSOP87.
### Signature
```sql
planet_equatorial_apparent(body_id int4, t timestamptz) → equatorial
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `body_id` | `int4` | Planet identifier (1-8, excluding 0 and 3) |
| `t` | `timestamptz` | Evaluation time |
### Returns
An `equatorial` with RA (hours), Dec (degrees), and distance (km), corrected for light travel time.
### Example
```sql
-- Light-time corrected RA/Dec of Jupiter
SELECT round(eq_ra(e)::numeric, 4) AS ra_hours,
round(eq_dec(e)::numeric, 4) AS dec_deg,
round(eq_distance(e)::numeric, 0) AS dist_km
FROM planet_equatorial_apparent(5, now()) AS e;
```
---
## moon_equatorial_apparent
Computes the geocentric apparent equatorial coordinates (RA/Dec) of the Moon with light-time correction (approximately 1.3 seconds). Uses ELP2000-82B.
### Signature
```sql
moon_equatorial_apparent(t timestamptz) → equatorial
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `t` | `timestamptz` | Evaluation time |
### Returns
An `equatorial` with RA (hours), Dec (degrees), and distance (km), corrected for light travel time.
<Aside type="note">
The Moon's light-time correction is approximately 1.3 seconds. This produces a sub-arcsecond shift in apparent position --- negligible for most applications, but included for completeness and consistency with the other `_apparent()` functions.
</Aside>
### Example
```sql
-- Compare geometric vs light-time corrected Moon RA/Dec
SELECT round(eq_ra(g)::numeric, 6) AS ra_geo,
round(eq_ra(a)::numeric, 6) AS ra_apparent,
round(eq_dec(g)::numeric, 6) AS dec_geo,
round(eq_dec(a)::numeric, 6) AS dec_apparent
FROM moon_equatorial(now()) AS g,
moon_equatorial_apparent(now()) AS a;
```

View File

@ -1,12 +1,12 @@
---
title: "Functions: Stars & Comets"
title: "Functions: Stars, Comets & Asteroids"
sidebar:
order: 5
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Functions for computing topocentric positions of stars from catalog coordinates, propagating comets and asteroids on Keplerian orbits, and observing them from Earth.
Functions for computing topocentric positions of stars from catalog coordinates, propagating comets and asteroids on Keplerian orbits, and observing them from Earth. The `orbital_elements` type (v0.8.0) bundles Keplerian elements into a first-class PostgreSQL datum, with `oe_from_mpc()` for bulk-loading the MPC catalog and `small_body_observe()` for ergonomic topocentric observation.
---
@ -34,7 +34,7 @@ star_observe(ra_hours float8, dec_deg float8, obs observer, t timestamptz) → t
A `topocentric` with azimuth and elevation in degrees. `topo_range` is 0 (infinite distance). `topo_range_rate` is 0.
<Aside type="note">
This function does not account for proper motion, parallax, aberration, or atmospheric refraction. For stars with significant proper motion (e.g., Barnard's Star), the J2000 coordinates should be corrected externally before calling this function.
This function does not account for proper motion, parallax, aberration, or atmospheric refraction. For stars with significant proper motion (e.g., Barnard's Star at 10.3 arcsec/yr), use `star_observe_pm` instead, which applies proper motion, parallax, and radial velocity corrections using the Hipparcos/Gaia convention.
</Aside>
### Example
@ -255,3 +255,419 @@ FROM comet_catalog, earth,
WHERE topo_elevation(c) > 0
ORDER BY topo_range(c);
```
---
## oe_from_mpc
Parses one line of the MPC MPCORB.DAT fixed-width format into an `orbital_elements` type. The MPC publishes orbital elements for over 1.3 million numbered asteroids in this format.
### Signature
```sql
oe_from_mpc(line text) → orbital_elements
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `line` | `text` | One complete line from MPCORB.DAT (at least 103 characters) |
### Returns
An `orbital_elements` with all nine fields populated. The parser performs several conversions at parse time:
- **Packed epoch** (e.g. `K24AM`) is decoded to a Julian date. The century letter (`I`=1800, `J`=1900, `K`=2000), two-digit year, packed month (`1-9`, `A-C`), and packed day (`1-9`, `A-V`) are expanded to a calendar date.
- **Perihelion distance** is derived from the MPC's semi-major axis and eccentricity: q = a × (1 e).
- **Perihelion time** is computed from the epoch and mean anomaly via Gauss's constant: tp = epoch M / n, where n = k / a^(3/2).
<Aside type="tip">
The full MPCORB.DAT format is documented at the [IAU Minor Planet Center](https://www.minorplanetcenter.net/iau/info/MPOrbitFormat.html). The file is freely downloadable (~280 MB compressed) and contains orbital elements for all numbered and multi-opposition asteroids.
</Aside>
### Example
```sql
-- Parse Ceres, extract semi-major axis and period
WITH ceres AS (
SELECT oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
) AS oe
)
SELECT round(oe_semi_major_axis(oe)::numeric, 4) AS a_au,
round(oe_period_years(oe)::numeric, 2) AS period_yr,
round(oe_inclination(oe)::numeric, 3) AS inc_deg,
round(oe_h_mag(oe)::numeric, 2) AS h_mag
FROM ceres;
```
---
## small_body_heliocentric
Propagates an `orbital_elements` to a heliocentric ecliptic J2000 position at a given time using two-body Keplerian mechanics. Extracts q, e, inc, omega, Omega, and tp from the type and calls the internal Kepler solver.
### Signature
```sql
small_body_heliocentric(oe orbital_elements, t timestamptz) → heliocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `oe` | `orbital_elements` | Bundled orbital elements |
| `t` | `timestamptz` | Evaluation time |
### Returns
A `heliocentric` position in AU (ecliptic J2000 frame). Identical to calling `kepler_propagate()` with the individual fields extracted from the type.
### Example
```sql
-- Propagate Ceres to 2025-01-01, check heliocentric distance
WITH ceres AS (
SELECT oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
) AS oe
)
SELECT round(helio_x(h)::numeric, 4) AS x_au,
round(helio_y(h)::numeric, 4) AS y_au,
round(helio_z(h)::numeric, 4) AS z_au,
round(helio_distance(h)::numeric, 3) AS dist_au
FROM ceres,
small_body_heliocentric(oe, '2025-01-01 00:00:00+00') AS h;
```
---
## small_body_observe
Computes the topocentric position of a comet or asteroid from its `orbital_elements` as seen by an Earth-based observer. Auto-fetches Earth's heliocentric position via VSOP87, matching the ergonomics of `planet_observe()`.
### Signature
```sql
small_body_observe(oe orbital_elements, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `oe` | `orbital_elements` | Bundled orbital elements |
| `obs` | `observer` | Observer location on Earth |
| `t` | `timestamptz` | Observation time |
### Returns
A `topocentric` with azimuth, elevation (degrees), range (km), and range rate (km/s).
<Aside type="note">
Unlike `comet_observe()` which requires you to pass Earth's position as three separate floats, `small_body_observe()` fetches Earth internally via VSOP87. This is simpler for single-object queries. For batch observations at the same time, `comet_observe()` still lets you compute Earth's position once and reuse it.
</Aside>
### Example
```sql
-- Observe Ceres from Boulder
WITH ceres AS (
SELECT oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
) AS oe
)
SELECT round(topo_azimuth(t)::numeric, 1) AS az,
round(topo_elevation(t)::numeric, 1) AS el,
round((topo_range(t) / 149597870.7)::numeric, 3) AS dist_au
FROM ceres,
small_body_observe(oe, '40.0N 105.3W 1655m'::observer, now()) AS t;
```
```sql
-- Which asteroids in the catalog are above 20 degrees tonight?
SELECT name,
round(topo_elevation(t)::numeric, 1) AS el,
round(topo_azimuth(t)::numeric, 1) AS az
FROM asteroid_catalog,
small_body_observe(oe, '40.0N 105.3W 1655m'::observer, now()) AS t
WHERE topo_elevation(t) > 20
ORDER BY topo_elevation(t) DESC;
```
---
## star_equatorial
Computes the apparent equatorial coordinates (RA/Dec) of a star at a given time by precessing J2000 catalog coordinates to the date of observation via IAU 1976 precession. Does not account for proper motion.
### Signature
```sql
star_equatorial(ra_hours float8, dec_deg float8, t timestamptz) → equatorial
```
### Parameters
| Parameter | Type | Unit | Description |
|-----------|------|------|-------------|
| `ra_hours` | `float8` | hours | Right Ascension in J2000 (0-24) |
| `dec_deg` | `float8` | degrees | Declination in J2000 (-90 to +90) |
| `t` | `timestamptz` | | Evaluation time |
### Returns
An `equatorial` with RA (hours) and Dec (degrees) precessed to the date of observation. Distance is 0 (infinite).
### Example
```sql
-- Precessed RA/Dec of Sirius at current date
SELECT round(eq_ra(e)::numeric, 6) AS ra_hours,
round(eq_dec(e)::numeric, 6) AS dec_deg
FROM star_equatorial(6.7525, -16.7161, now()) AS e;
```
---
## star_observe_pm
Computes the topocentric position of a star with full proper motion, parallax, and radial velocity corrections. The proper motion convention follows Hipparcos/Gaia: `pm_ra` is mu_alpha * cos(delta) in milliarcseconds per year. Cos(dec) is clamped near the poles to avoid division by zero.
### Signature
```sql
star_observe_pm(
ra_hours float8,
dec_deg float8,
pm_ra_masyr float8,
pm_dec_masyr float8,
parallax_mas float8,
rv_kms float8,
obs observer,
t timestamptz
) → topocentric
```
### Parameters
| Parameter | Type | Unit | Description |
|-----------|------|------|-------------|
| `ra_hours` | `float8` | hours | J2000 Right Ascension (0-24) |
| `dec_deg` | `float8` | degrees | J2000 Declination (-90 to +90) |
| `pm_ra_masyr` | `float8` | mas/yr | Proper motion in RA (mu_alpha * cos(delta), Hipparcos/Gaia convention) |
| `pm_dec_masyr` | `float8` | mas/yr | Proper motion in Declination |
| `parallax_mas` | `float8` | mas | Trigonometric parallax (0 if unknown) |
| `rv_kms` | `float8` | km/s | Radial velocity (0 if unknown) |
| `obs` | `observer` | | Observer location |
| `t` | `timestamptz` | | Observation time |
### Returns
A `topocentric` with azimuth, elevation, range (km from parallax, or 0 if parallax is 0), and range rate (km/s).
<Aside type="caution">
A NOTICE is raised if the propagation interval exceeds 200 years from J2000, as proper motion becomes unreliable over long timescales due to unmodeled orbital motion and galactic effects.
</Aside>
### Example
```sql
-- Observe Barnard's Star (large proper motion: 10.3 arcsec/yr total)
-- Hipparcos: RA 17h 57m 48.5s, Dec +4d 41m 36.2s
-- pm_ra = -798.58 mas/yr, pm_dec = 10328.12 mas/yr
-- parallax = 548.31 mas, rv = -110.6 km/s
SELECT round(topo_azimuth(t)::numeric, 2) AS az,
round(topo_elevation(t)::numeric, 2) AS el,
round(topo_range(t)::numeric, 0) AS dist_km
FROM star_observe_pm(
17.9635, 4.6934, -- J2000 RA/Dec
-798.58, 10328.12, -- proper motion (mas/yr)
548.31, -110.6, -- parallax (mas), RV (km/s)
'40.0N 105.3W 1655m'::observer, now()
) AS t;
```
---
## star_equatorial_pm
Computes the apparent equatorial coordinates of a star with proper motion, parallax, and radial velocity corrections. Returns precessed coordinates of date. Distance is derived from parallax if greater than zero.
### Signature
```sql
star_equatorial_pm(
ra_hours float8,
dec_deg float8,
pm_ra_masyr float8,
pm_dec_masyr float8,
parallax_mas float8,
rv_kms float8,
t timestamptz
) → equatorial
```
### Parameters
| Parameter | Type | Unit | Description |
|-----------|------|------|-------------|
| `ra_hours` | `float8` | hours | J2000 Right Ascension (0-24) |
| `dec_deg` | `float8` | degrees | J2000 Declination (-90 to +90) |
| `pm_ra_masyr` | `float8` | mas/yr | Proper motion in RA (mu_alpha * cos(delta)) |
| `pm_dec_masyr` | `float8` | mas/yr | Proper motion in Declination |
| `parallax_mas` | `float8` | mas | Trigonometric parallax (0 if unknown) |
| `rv_kms` | `float8` | km/s | Radial velocity (0 if unknown) |
| `t` | `timestamptz` | | Evaluation time |
### Returns
An `equatorial` with RA (hours) and Dec (degrees) corrected for proper motion and precessed to date. Distance in km from parallax (0 if parallax is 0 or unknown).
### Example
```sql
-- RA/Dec of Barnard's Star, corrected for proper motion
SELECT round(eq_ra(e)::numeric, 6) AS ra_hours,
round(eq_dec(e)::numeric, 6) AS dec_deg,
round(eq_distance(e)::numeric, 0) AS dist_km
FROM star_equatorial_pm(
17.9635, 4.6934,
-798.58, 10328.12,
548.31, -110.6,
now()
) AS e;
```
```sql
-- Track the motion of Barnard's Star over 50 years
SELECT extract(year from t) AS year,
round(eq_ra(e)::numeric, 6) AS ra_h,
round(eq_dec(e)::numeric, 6) AS dec_deg
FROM generate_series(
'2000-01-01'::timestamptz,
'2050-01-01'::timestamptz,
interval '10 years'
) AS t,
star_equatorial_pm(17.9635, 4.6934, -798.58, 10328.12, 548.31, -110.6, t) AS e;
```
---
## small_body_equatorial
Computes the geocentric apparent equatorial coordinates (RA/Dec) of a comet or asteroid from its `orbital_elements`. The body is propagated on a Keplerian orbit, and Earth's position is obtained from VSOP87.
### Signature
```sql
small_body_equatorial(oe orbital_elements, t timestamptz) → equatorial
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `oe` | `orbital_elements` | Bundled orbital elements |
| `t` | `timestamptz` | Evaluation time |
### Returns
An `equatorial` with RA (hours), Dec (degrees), and distance (km) from Earth's center.
### Example
```sql
-- RA/Dec of Ceres
WITH ceres AS (
SELECT oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
) AS oe
)
SELECT round(eq_ra(e)::numeric, 4) AS ra_hours,
round(eq_dec(e)::numeric, 4) AS dec_deg,
round(eq_distance(e)::numeric, 0) AS dist_km
FROM ceres,
small_body_equatorial(oe, now()) AS e;
```
---
## small_body_observe_apparent
Computes the topocentric position of a comet or asteroid with single-iteration light-time correction. The body is propagated at the retarded time (observation time minus light travel time), while Earth's position is evaluated at the observation time via VSOP87.
### Signature
```sql
small_body_observe_apparent(oe orbital_elements, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `oe` | `orbital_elements` | Bundled orbital elements |
| `obs` | `observer` | Observer location on Earth |
| `t` | `timestamptz` | Observation time |
### Returns
A `topocentric` with azimuth, elevation, range (km), and range rate (km/s), corrected for light travel time.
### Example
```sql
-- Observe Ceres with light-time correction from Boulder
WITH ceres AS (
SELECT oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
) AS oe
)
SELECT round(topo_azimuth(t)::numeric, 2) AS az,
round(topo_elevation(t)::numeric, 2) AS el,
round((topo_range(t) / 149597870.7)::numeric, 4) AS dist_au
FROM ceres,
small_body_observe_apparent(oe, '40.0N 105.3W 1655m'::observer, now()) AS t;
```
---
## small_body_equatorial_apparent
Computes the geocentric apparent equatorial coordinates (RA/Dec) of a comet or asteroid with light-time correction. The body is propagated at the retarded time.
### Signature
```sql
small_body_equatorial_apparent(oe orbital_elements, t timestamptz) → equatorial
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `oe` | `orbital_elements` | Bundled orbital elements |
| `t` | `timestamptz` | Evaluation time |
### Returns
An `equatorial` with RA (hours), Dec (degrees), and distance (km), corrected for light travel time.
### Example
```sql
-- Light-time corrected RA/Dec of Ceres
WITH ceres AS (
SELECT oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
) AS oe
)
SELECT round(eq_ra(e)::numeric, 4) AS ra_hours,
round(eq_dec(e)::numeric, 4) AS dec_deg,
round(eq_distance(e)::numeric, 0) AS dist_km
FROM ceres,
small_body_equatorial_apparent(oe, now()) AS e;
```

View File

@ -1,12 +1,12 @@
---
title: "Operators & GiST Index"
title: "Operators & Indexes"
sidebar:
order: 8
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
pg_orrery defines two operators on the `tle` type and a GiST operator class that enables indexed conjunction screening over large satellite catalogs. The operators work on the orbital altitude band and inclination range extracted from TLE elements, providing a fast necessary-condition filter for proximity analysis.
pg_orrery defines four operators on the `tle` type and two operator classes (GiST and SP-GiST) that enable indexed satellite queries over large catalogs. The GiST index accelerates conjunction screening (orbit-to-orbit overlap). The SP-GiST index accelerates pass prediction (observer-to-orbit visibility).
---
@ -51,7 +51,7 @@ WHERE satellite_catalog.tle && iss.tle;
### `<->` (Distance)
Computes the minimum separation between the altitude bands of two TLEs, in kilometers. If the altitude bands overlap, returns 0.
Computes the 2-D orbital distance between two TLEs, in kilometers. Combines altitude-band separation with inclination gap (converted to km via Earth radius), returning the L2 norm. Returns 0 only when both altitude bands AND inclination ranges overlap.
#### Signature
@ -61,16 +61,16 @@ tle <-> tle → float8
#### Description
This is an altitude-only metric. It computes:
- `max(0, perigee_a - apogee_b)` and `max(0, perigee_b - apogee_a)`
- Returns the minimum of these two values
The distance metric combines two components:
- **Altitude gap:** minimum separation between perigee-to-apogee bands, in km
- **Inclination gap:** angular difference in radians, converted to km by multiplying by Earth's radius (WGS-72: 6378.135 km)
The result is the minimum possible radial separation. A result of 0 means the altitude bands overlap (but the satellites may still be far apart in along-track or cross-track distance).
The result is `sqrt(alt_gap² + inc_km²)`. Two satellites at the same altitude but with a 90° inclination difference report ~6378 km distance. Two satellites at vastly different altitudes but similar inclinations are dominated by the altitude gap. A result of 0 means both the altitude bands and inclination ranges overlap.
#### Example
```sql
-- Altitude band separation between ISS and a GEO satellite
-- Orbital distance between ISS and a GEO satellite
WITH iss AS (
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS tle
@ -79,17 +79,17 @@ geo AS (
SELECT '1 28884U 05041A 24001.50000000 -.00000089 00000-0 00000-0 0 9997
2 28884 0.0153 93.0424 0001699 138.1498 336.5718 1.00271128 67481'::tle AS tle
)
SELECT round((iss.tle <-> geo.tle)::numeric, 1) AS separation_km
SELECT round((iss.tle <-> geo.tle)::numeric, 1) AS orbital_dist_km
FROM iss, geo;
```
```sql
-- Order catalog by altitude proximity to a target satellite
-- Order catalog by orbital proximity to a target satellite
WITH target AS (
SELECT tle FROM satellite_catalog WHERE norad_id = 25544
)
SELECT norad_id, name,
round((satellite_catalog.tle <-> target.tle)::numeric, 1) AS alt_sep_km
round((satellite_catalog.tle <-> target.tle)::numeric, 1) AS orbital_dist_km
FROM satellite_catalog, target
ORDER BY satellite_catalog.tle <-> target.tle
LIMIT 20;
@ -131,20 +131,26 @@ WHERE c.tle && iss.tle
AND c.norad_id != 25544;
```
</TabItem>
<TabItem label="kNN by altitude">
<TabItem label="kNN by orbital distance">
```sql
-- Find the 10 satellites with the closest altitude bands to the ISS
-- The <-> operator supports GiST ordering (ORDER BY ... <-> ...)
WITH iss AS (
SELECT tle FROM satellite_catalog WHERE norad_id = 25544
)
SELECT c.norad_id, c.name,
round((c.tle <-> iss.tle)::numeric, 1) AS alt_sep_km
FROM satellite_catalog c, iss
WHERE c.norad_id != 25544
ORDER BY c.tle <-> iss.tle
-- Find the 10 satellites with the closest orbits to the ISS
-- The <-> operator supports GiST index ordering (ORDER BY ... <-> ...)
-- IMPORTANT: use a scalar subquery for the probe TLE so the planner
-- can see it as a constant and activate index-ordered scan.
SELECT c.name,
round((c.tle <-> (SELECT tle FROM satellite_catalog
WHERE tle_norad_id(tle) = 25544 LIMIT 1))::numeric, 1)
AS orbital_dist_km
FROM satellite_catalog c
WHERE tle_norad_id(c.tle) != 25544
ORDER BY c.tle <-> (SELECT tle FROM satellite_catalog
WHERE tle_norad_id(tle) = 25544 LIMIT 1)
LIMIT 10;
```
<Aside type="caution" title="CTE pattern prevents index ordering">
A CTE like `WITH iss AS (SELECT tle ...)` makes the probe value opaque to the planner, forcing a full sequential scan and sort instead of an index-ordered traversal. Always use a scalar subquery `(SELECT tle FROM ... LIMIT 1)` for the probe argument. For small catalogs (< 100 rows) the difference is negligible; for large catalogs it is the difference between 2 ms and a full sort.
</Aside>
</TabItem>
<TabItem label="Two-stage screening">
```sql
@ -171,10 +177,18 @@ ORDER BY dist_km;
### Performance
Without the GiST index, the `&&` operator requires a sequential scan of the entire catalog (O(n) per query). With the index, overlap queries run in O(log n) time. For a catalog of 12,000 active TLEs, this reduces conjunction screening from seconds to milliseconds.
Benchmarked against a 66,440-object catalog (Space-Track + CelesTrak + SatNOGS):
| Query | GiST | Seqscan | Matches | Speedup |
|-------|------|---------|---------|---------|
| ISS conjunction (`&&`) | 4.6 ms | 63.3 ms | 9 | 5.8x |
| 10 nearest to ISS (`<->` KNN) | 2.1 ms | — | 10 | Index-ordered (2-D orbital distance) |
| 10 nearest to GEO sat (`<->` KNN) | 0.2 ms | — | 10 | Sparse regime |
The GiST index (15 MB, 93 ms build) provides the clearest speedup for conjunction screening. The `&&` operator reduces the search from 1,338 buffer hits (sequential scan) to 237 buffer hits (index scan). KNN queries traverse the tree by increasing distance without computing all distances upfront.
<Aside type="tip">
The GiST index is most valuable for large catalogs (thousands of TLEs). For small catalogs (< 100 TLEs), sequential scans may be faster than the index overhead. PostgreSQL's query planner handles this decision automatically.
For small catalogs (< 100 TLEs), sequential scans may be faster than the index overhead. PostgreSQL's query planner handles this decision automatically. The GiST index shows the largest relative speedup when the query returns few matches against a large catalog --- exactly the conjunction screening pattern.
</Aside>
### Index Maintenance
@ -188,3 +202,125 @@ TRUNCATE satellite_catalog;
COPY satellite_catalog FROM '/path/to/catalog.csv' WITH (FORMAT csv);
REINDEX INDEX idx_tle_gist;
```
---
### &? (Visibility Cone)
Tests whether a satellite could possibly be visible from a ground observer during a time window. This is a geometric superset filter -- it may include satellites that do not produce an actual pass (false positives), but will never exclude one that does (no false negatives).
#### Signature
```sql
tle &? observer_window → boolean
```
#### The `observer_window` Type
The right argument is a composite type constructed with `ROW(...)::observer_window`:
| Field | Type | Description |
|---|---|---|
| `obs` | `observer` | Ground location (latitude, longitude, altitude) |
| `t_start` | `timestamptz` | Start of observation window |
| `t_end` | `timestamptz` | End of observation window |
| `min_el` | `float8` | Minimum elevation angle in degrees (default 10.0 if NULL) |
#### Description
Applies three geometric filters without SGP4 propagation:
1. **Altitude filter:** Rejects satellites whose perigee altitude exceeds the maximum visible altitude for the given minimum elevation angle
2. **Inclination filter:** Rejects satellites whose inclination + ground footprint angle cannot reach the observer's latitude
3. **RAAN filter:** Projects the ascending node to the query midpoint via J2 secular precession and checks alignment with the observer's local sidereal time. Automatically bypassed for query windows spanning a full Earth rotation (>= ~12 hours)
Returns `true` if the satellite passes all three filters. Returns `false` for degenerate TLEs with zero mean motion.
<Aside type="note">
The `&?` operator is designed as the first stage of a two-stage pipeline. Use it to reduce a 30,000-object catalog to a few hundred candidates, then run `predict_passes()` on the survivors for exact pass times and elevations.
</Aside>
#### Example
```sql
-- Which satellites might be visible from Eagle, Idaho tonight?
SELECT name
FROM satellite_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 02:00:00+00'::timestamptz,
'2024-01-01 08:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
```
---
## SP-GiST Operator Class: tle_spgist_ops
The `tle_spgist_ops` operator class enables SP-GiST indexing on `tle` columns. The index builds a 2-level space-partitioning trie: semi-major axis at level 0 (altitude regime) and inclination at level 1 (latitude coverage). Equal-population splits ensure balanced trees across the dense LEO region.
### Creating the Index
```sql
CREATE INDEX idx_tle_spgist ON satellite_catalog USING spgist (tle tle_spgist_ops);
```
<Aside type="note">
The SP-GiST operator class is **not** the default for the `tle` type. The GiST operator class (`tle_ops`) remains the default. Both can coexist on the same table -- they serve different query patterns.
</Aside>
### What Gets Indexed
The SP-GiST trie partitions TLEs by two static orbital elements:
- **Level 0: Semi-major axis** (computed from mean motion via Kepler's 3rd law). Separates LEO, MEO, HEO, and GEO objects into altitude bins.
- **Level 1: Inclination** (in radians). Within each altitude bin, objects are further partitioned by orbital inclination.
Equal-population splits (`floor(sqrt(n))` bins, clamped to [2, 16]) ensure dense orbital regimes like LEO get finer partitioning.
<Aside type="note" title="Eccentricity and highly elliptical orbits">
The SP-GiST inner nodes store only SMA bin boundaries — they do not carry eccentricity information. This means the index cannot prune HEO (Highly Elliptical Orbit) satellites by altitude at the tree level. A satellite like CLUSTER II (SMA ~70,000 km, eccentricity 0.88) sits in a high-SMA bin alongside GEO satellites, but its actual perigee is only ~2,000 km. The leaf-level filter correctly identifies these using the full TLE (including eccentricity), so **no false negatives occur** — but HEO bins may produce slightly more candidates than a purely circular-orbit catalog would.
</Aside>
### Index-Accelerated Queries
```sql
-- The &? operator uses the SP-GiST index when available
SELECT name
FROM satellite_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 02:00:00+00'::timestamptz,
'2024-01-01 08:00:00+00'::timestamptz,
10.0
)::observer_window;
```
During the index scan, inner nodes are pruned by altitude band (level 0) and inclination range (level 1). The RAAN filter is applied at the leaf level. This avoids examining individual TLEs in entire subtrees that cannot produce visible passes.
### Performance
The `&?` operator eliminates 84--90% of a satellite catalog without SGP4 propagation --- this is the primary value, regardless of whether a sequential scan or index scan evaluates it.
Benchmarked against a 66,440-object catalog:
| Query | Seqscan | SP-GiST | Candidates | Pruned |
|-------|---------|---------|------------|--------|
| 2h, Eagle ID, 10° | 12.1 ms | 16.1 ms | 10,763 | 83.8% |
| 2h, Equator, 10° | 12.1 ms | 16.8 ms | 10,174 | 84.7% |
| 2h, Eagle ID, 45° | 11.9 ms | 16.9 ms | 6,796 | 89.8% |
| 24h, Eagle ID, 10° | 12.5 ms | 23.3 ms | 61,426 | 7.5% |
At 66k objects, the sequential scan is faster than the SP-GiST index for all tested scenarios. The `&?` operator is so cheap per evaluation (three floating-point comparisons) that tree traversal overhead exceeds the pruning benefit at this catalog size. The index is most effective for:
- **Short query windows** (1-6 hours): The RAAN filter aggressively eliminates satellites whose orbital plane is not currently aligned with the observer
- **Higher minimum elevation** (> 20 degrees): The altitude filter eliminates distant MEO/GEO objects
- **Larger catalogs** (200k+ objects): Tree-level pruning avoids examining individual TLEs in entire subtrees
For 24-hour query windows, the RAAN filter self-disables (full Earth rotation makes it meaningless), and only the altitude and inclination filters apply. The real value of the `&?` operator is as a gating filter before expensive SGP4 propagation, not the scan method itself.
### Index Maintenance
Like the GiST index, the SP-GiST index is maintained automatically by PostgreSQL on `INSERT`, `UPDATE`, and `DELETE`. No manual `REINDEX` is needed under normal operation.

View File

@ -6,7 +6,7 @@ sidebar:
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
pg_orrery defines seven composite types that represent the core data structures of orbital mechanics. Each type has a fixed on-disk size, a text I/O format for readability, and accessor functions for extracting individual fields.
pg_orrery defines nine fixed-size base types and one SQL composite type that represent the core data structures of orbital mechanics. Each base type has a fixed on-disk size, a text I/O format for readability, and accessor functions for extracting individual fields.
## tle
@ -267,3 +267,161 @@ SELECT body_id,
FROM generate_series(1, 8) AS body_id,
planet_heliocentric(body_id, now()) AS h;
```
---
## orbital_elements
**Size:** 72 bytes
Classical Keplerian orbital elements for comets and asteroids. Stores nine doubles: osculation epoch, perihelion distance, eccentricity, inclination, argument of perihelion, longitude of ascending node, time of perihelion passage, absolute magnitude H, and slope parameter G.
### Input Format
A parenthesized tuple of nine values:
```sql
SELECT '(2460605.5,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements;
```
The fields in order are: `(epoch_jd, q_au, e, inc_deg, omega_deg, Omega_deg, tp_jd, H, G)`.
<Aside type="note">
Angular fields (inclination, argument of perihelion, RAAN) are displayed in degrees but stored internally as radians — the same convention used by the `tle` type. H and G display as `NaN` when unknown.
</Aside>
### Constructor
| Function | Signature | Description |
|----------|-----------|-------------|
| `oe_from_mpc` | `oe_from_mpc(line text) → orbital_elements` | Parses one MPCORB.DAT fixed-width line. Converts MPC packed epoch, computes q and tp from (a, e, M). |
```sql
-- Parse (1) Ceres from an MPCORB.DAT line
SELECT oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
);
```
### Accessor Functions
| Function | Return Type | Description |
|----------|-------------|-------------|
| `oe_epoch(orbital_elements)` | `float8` | Osculation epoch (Julian date) |
| `oe_perihelion(orbital_elements)` | `float8` | Perihelion distance q (AU) |
| `oe_eccentricity(orbital_elements)` | `float8` | Eccentricity |
| `oe_inclination(orbital_elements)` | `float8` | Inclination (degrees) |
| `oe_arg_perihelion(orbital_elements)` | `float8` | Argument of perihelion (degrees) |
| `oe_raan(orbital_elements)` | `float8` | Longitude of ascending node (degrees) |
| `oe_tp(orbital_elements)` | `float8` | Time of perihelion passage (Julian date) |
| `oe_h_mag(orbital_elements)` | `float8` | Absolute magnitude H (NaN if unknown) |
| `oe_g_slope(orbital_elements)` | `float8` | Slope parameter G (NaN if unknown) |
| `oe_semi_major_axis(orbital_elements)` | `float8` | Semi-major axis a = q/(1-e) in AU. NULL for e ≥ 1. |
| `oe_period_years(orbital_elements)` | `float8` | Orbital period a^1.5 in years. NULL for e ≥ 1. |
```sql
-- Parse Ceres and extract key parameters
WITH ceres AS (
SELECT oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
) AS oe
)
SELECT oe_epoch(oe) AS epoch_jd,
oe_perihelion(oe) AS q_au,
oe_eccentricity(oe) AS ecc,
oe_inclination(oe) AS inc_deg,
oe_semi_major_axis(oe) AS a_au,
oe_period_years(oe) AS period_yr,
oe_h_mag(oe) AS h_mag
FROM ceres;
```
---
## equatorial
**Size:** 24 bytes
Apparent equatorial coordinates of date. Stores three double-precision values: right ascension (radians internally, displayed as hours in the range [0, 24)), declination (radians internally, displayed as degrees in the range [-90, 90]), and distance in km.
For solar system bodies, these are J2000 coordinates precessed to the date of observation via IAU 1976 precession. For satellites, these are TEME frame RA/Dec, which approximates coordinates of date to arcsecond accuracy.
### Input Format
A parenthesized tuple of three values:
```
(ra_hours,dec_degrees,distance_km)
```
```sql
SELECT '(4.29220000,20.60000000,885412345.678)'::equatorial;
```
### Accessor Functions
| Function | Return Type | Unit | Description |
|----------|-------------|------|-------------|
| `eq_ra(equatorial)` | `float8` | hours | Right ascension [0, 24) |
| `eq_dec(equatorial)` | `float8` | degrees | Declination [-90, 90] |
| `eq_distance(equatorial)` | `float8` | km | Distance (0 for stars without parallax) |
```sql
-- Geocentric RA/Dec of Jupiter
SELECT eq_ra(e) AS ra_hours,
eq_dec(e) AS dec_deg,
eq_distance(e) AS dist_km
FROM planet_equatorial(5, now()) AS e;
```
```sql
-- Compare RA/Dec of all planets
SELECT body_id,
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,
round(eq_ra(e)::numeric, 4) AS ra_h,
round(eq_dec(e)::numeric, 4) AS dec_deg
FROM unnest(ARRAY[1,2,4,5,6,7,8]) AS body_id,
planet_equatorial(body_id, now()) AS e;
```
---
## observer_window
**Type:** SQL composite (variable-length)
A query-time parameter that defines a ground observer's visibility window. Unlike the seven base types above, `observer_window` is a SQL composite type --- it is not designed for table storage, but as an argument to the `&?` visibility cone operator.
### Fields
| Field | Type | Description |
|-------|------|-------------|
| `obs` | `observer` | Ground location (latitude, longitude, altitude) |
| `t_start` | `timestamptz` | Start of observation window |
| `t_end` | `timestamptz` | End of observation window |
| `min_el` | `float8` | Minimum elevation angle in degrees |
### Construction
Construct with `ROW(...)::observer_window` syntax:
```sql
-- Eagle, Idaho: 6-hour window, 10 deg minimum elevation
SELECT tle
FROM catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 01:00:00+00'::timestamptz,
'2024-01-01 07:00:00+00'::timestamptz,
10.0
)::observer_window;
```
<Aside type="tip">
See [Satellite Pass Prediction](/guides/pass-prediction/) for the full two-stage workflow using `&?` to filter candidates before SGP4 propagation with `predict_passes()`.
</Aside>

View File

@ -1,4 +1,4 @@
comment = 'A database orrery — celestial mechanics types and functions for PostgreSQL'
default_version = '0.6.0'
default_version = '0.10.0'
module_pathname = '$libdir/pg_orrery'
relocatable = true

View File

@ -470,7 +470,7 @@ CREATE OPERATOR <-> (
COMMUTATOR = <->
);
COMMENT ON OPERATOR <-> (tle, tle) IS 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.';
COMMENT ON OPERATOR <-> (tle, tle) IS '2-D orbital distance in km: L2 norm of altitude-band gap and inclination gap (radians × Earth radius). Returns 0 when both dimensions overlap.';
-- ============================================================

1339
sql/pg_orrery--0.10.0.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@ -470,7 +470,7 @@ CREATE OPERATOR <-> (
COMMUTATOR = <->
);
COMMENT ON OPERATOR <-> (tle, tle) IS 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.';
COMMENT ON OPERATOR <-> (tle, tle) IS '2-D orbital distance in km: L2 norm of altitude-band gap and inclination gap (radians × Earth radius). Returns 0 when both dimensions overlap.';
-- ============================================================

View File

@ -470,7 +470,7 @@ CREATE OPERATOR <-> (
COMMUTATOR = <->
);
COMMENT ON OPERATOR <-> (tle, tle) IS 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.';
COMMENT ON OPERATOR <-> (tle, tle) IS '2-D orbital distance in km: L2 norm of altitude-band gap and inclination gap (radians × Earth radius). Returns 0 when both dimensions overlap.';
-- ============================================================

View File

@ -470,7 +470,7 @@ CREATE OPERATOR <-> (
COMMUTATOR = <->
);
COMMENT ON OPERATOR <-> (tle, tle) IS 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.';
COMMENT ON OPERATOR <-> (tle, tle) IS '2-D orbital distance in km: L2 norm of altitude-band gap and inclination gap (radians × Earth radius). Returns 0 when both dimensions overlap.';
-- ============================================================

View File

@ -470,7 +470,7 @@ CREATE OPERATOR <-> (
COMMUTATOR = <->
);
COMMENT ON OPERATOR <-> (tle, tle) IS 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.';
COMMENT ON OPERATOR <-> (tle, tle) IS '2-D orbital distance in km: L2 norm of altitude-band gap and inclination gap (radians × Earth radius). Returns 0 when both dimensions overlap.';
-- ============================================================

View File

@ -0,0 +1,78 @@
-- pg_orrery 0.6.0 -> 0.7.0 migration
--
-- Adds SP-GiST orbital trie index for satellite pass prediction.
-- 2-level trie: SMA (L0) + inclination (L1) with query-time RAAN filter.
-- The &? operator answers "might this satellite be visible?"
-- ============================================================
-- observer_window composite type (query parameter bundle)
-- ============================================================
CREATE TYPE observer_window AS (
obs observer,
t_start timestamptz,
t_end timestamptz,
min_el float8
);
COMMENT ON TYPE observer_window IS
'Observation query parameters: observer location, time window, and minimum elevation angle (degrees). Used with the &? visibility cone operator.';
-- ============================================================
-- Visibility cone operator function
-- ============================================================
CREATE FUNCTION tle_visibility_possible(tle, observer_window) RETURNS boolean
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_visibility_possible(tle, observer_window) IS
'Could this satellite be visible from the observer during the time window? Combines altitude, inclination, and RAAN checks. Conservative superset — survivors need SGP4 propagation for ground truth.';
-- ============================================================
-- &? operator (visibility cone check)
-- ============================================================
-- The indexed column (tle) MUST be the left argument so PostgreSQL
-- can form a ScanKey and pass it to inner_consistent for pruning.
CREATE OPERATOR &? (
LEFTARG = tle,
RIGHTARG = observer_window,
FUNCTION = tle_visibility_possible,
RESTRICT = contsel,
JOIN = contjoinsel
);
COMMENT ON OPERATOR &? (tle, observer_window) IS
'Visibility cone check: could this satellite be visible from the observer during the time window? Index-accelerated via SP-GiST orbital trie.';
-- ============================================================
-- SP-GiST support functions
-- ============================================================
CREATE FUNCTION spgist_tle_config(internal, internal) RETURNS void
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION spgist_tle_choose(internal, internal) RETURNS void
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION spgist_tle_picksplit(internal, internal) RETURNS void
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION spgist_tle_inner_consistent(internal, internal) RETURNS void
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION spgist_tle_leaf_consistent(internal, internal) RETURNS void
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
-- ============================================================
-- SP-GiST operator class (opt-in, not DEFAULT)
-- ============================================================
CREATE OPERATOR CLASS tle_spgist_ops
FOR TYPE tle USING spgist AS
OPERATOR 1 &? (tle, observer_window),
FUNCTION 1 spgist_tle_config(internal, internal),
FUNCTION 2 spgist_tle_choose(internal, internal),
FUNCTION 3 spgist_tle_picksplit(internal, internal),
FUNCTION 4 spgist_tle_inner_consistent(internal, internal),
FUNCTION 5 spgist_tle_leaf_consistent(internal, internal);

View File

@ -470,7 +470,7 @@ CREATE OPERATOR <-> (
COMMUTATOR = <->
);
COMMENT ON OPERATOR <-> (tle, tle) IS 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.';
COMMENT ON OPERATOR <-> (tle, tle) IS '2-D orbital distance in km: L2 norm of altitude-band gap and inclination gap (radians × Earth radius). Returns 0 when both dimensions overlap.';
-- ============================================================

View File

@ -0,0 +1,109 @@
-- pg_orrery 0.7.0 -> 0.8.0 migration
--
-- Adds orbital_elements type for comets/asteroids, MPC MPCORB.DAT parser,
-- and small_body_observe()/small_body_heliocentric() observation functions.
-- ============================================================
-- orbital_elements type
-- ============================================================
CREATE TYPE orbital_elements;
CREATE FUNCTION orbital_elements_in(cstring) RETURNS orbital_elements
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION orbital_elements_out(orbital_elements) RETURNS cstring
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION orbital_elements_recv(internal) RETURNS orbital_elements
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION orbital_elements_send(orbital_elements) RETURNS bytea
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE TYPE orbital_elements (
INPUT = orbital_elements_in,
OUTPUT = orbital_elements_out,
RECEIVE = orbital_elements_recv,
SEND = orbital_elements_send,
INTERNALLENGTH = 72,
ALIGNMENT = double,
STORAGE = plain
);
COMMENT ON TYPE orbital_elements IS
'Classical Keplerian orbital elements for comets and asteroids (epoch, q, e, inc, omega, Omega, tp, H, G). 72 bytes, fixed-size.';
-- ============================================================
-- Accessor functions
-- ============================================================
CREATE FUNCTION oe_epoch(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_epoch(orbital_elements) IS 'Osculation epoch (Julian date)';
CREATE FUNCTION oe_perihelion(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_perihelion(orbital_elements) IS 'Perihelion distance q (AU)';
CREATE FUNCTION oe_eccentricity(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_eccentricity(orbital_elements) IS 'Eccentricity';
CREATE FUNCTION oe_inclination(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_inclination(orbital_elements) IS 'Inclination (degrees)';
CREATE FUNCTION oe_arg_perihelion(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_arg_perihelion(orbital_elements) IS 'Argument of perihelion (degrees)';
CREATE FUNCTION oe_raan(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_raan(orbital_elements) IS 'Longitude of ascending node (degrees)';
CREATE FUNCTION oe_tp(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_tp(orbital_elements) IS 'Time of perihelion passage (Julian date)';
CREATE FUNCTION oe_h_mag(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_h_mag(orbital_elements) IS 'Absolute magnitude H (NaN if unknown)';
CREATE FUNCTION oe_g_slope(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_g_slope(orbital_elements) IS 'Slope parameter G (NaN if unknown)';
CREATE FUNCTION oe_semi_major_axis(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_semi_major_axis(orbital_elements) IS 'Semi-major axis a = q/(1-e) in AU. NULL for parabolic/hyperbolic orbits (e >= 1).';
CREATE FUNCTION oe_period_years(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_period_years(orbital_elements) IS 'Orbital period in years = a^1.5 (Kepler third law). NULL for parabolic/hyperbolic orbits (e >= 1).';
-- ============================================================
-- MPC MPCORB.DAT parser
-- ============================================================
CREATE FUNCTION oe_from_mpc(text) RETURNS orbital_elements
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_from_mpc(text) IS
'Parse one MPCORB.DAT fixed-width line into orbital_elements. Converts MPC packed epoch, computes perihelion distance and tp from (a, e, M).';
-- ============================================================
-- Observation functions
-- ============================================================
CREATE FUNCTION small_body_heliocentric(orbital_elements, timestamptz) RETURNS heliocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION small_body_heliocentric(orbital_elements, timestamptz) IS
'Heliocentric ecliptic J2000 position of a comet/asteroid from its orbital elements at a given time.';
CREATE FUNCTION small_body_observe(orbital_elements, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION small_body_observe(orbital_elements, observer, timestamptz) IS
'Observe a comet/asteroid from orbital elements. Auto-fetches Earth via VSOP87. Returns topocentric az/el with geocentric range in km.';

963
sql/pg_orrery--0.7.0.sql Normal file
View File

@ -0,0 +1,963 @@
-- pg_orrery -- Orbital mechanics types and functions for PostgreSQL
--
-- Types: tle, eci_position, geodetic, topocentric, observer, pass_event
-- Provides SGP4/SDP4 propagation, coordinate transforms, pass prediction,
-- and GiST indexing on altitude bands for conjunction screening.
--
-- All propagation uses WGS-72 constants (matching TLE mean element fitting).
-- Coordinate output uses WGS-84 (matching modern geodetic standards).
-- ============================================================
-- Shell types (forward declarations)
-- ============================================================
CREATE TYPE tle;
CREATE TYPE eci_position;
CREATE TYPE geodetic;
CREATE TYPE topocentric;
CREATE TYPE observer;
CREATE TYPE pass_event;
-- ============================================================
-- TLE type: Two-Line Element set
-- ============================================================
CREATE FUNCTION tle_in(cstring) RETURNS tle
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION tle_out(tle) RETURNS cstring
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION tle_recv(internal) RETURNS tle
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION tle_send(tle) RETURNS bytea
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE TYPE tle (
INPUT = tle_in,
OUTPUT = tle_out,
RECEIVE = tle_recv,
SEND = tle_send,
INTERNALLENGTH = 112,
ALIGNMENT = double,
STORAGE = plain
);
COMMENT ON TYPE tle IS 'Two-Line Element set — parsed mean orbital elements for SGP4/SDP4 propagation';
-- TLE accessor functions
CREATE FUNCTION tle_epoch(tle) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_epoch(tle) IS 'TLE epoch as Julian date (UTC)';
CREATE FUNCTION tle_norad_id(tle) RETURNS int4
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_norad_id(tle) IS 'NORAD catalog number';
CREATE FUNCTION tle_inclination(tle) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_inclination(tle) IS 'Orbital inclination in degrees';
CREATE FUNCTION tle_eccentricity(tle) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_eccentricity(tle) IS 'Orbital eccentricity (dimensionless)';
CREATE FUNCTION tle_raan(tle) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_raan(tle) IS 'Right ascension of ascending node in degrees';
CREATE FUNCTION tle_arg_perigee(tle) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_arg_perigee(tle) IS 'Argument of perigee in degrees';
CREATE FUNCTION tle_mean_anomaly(tle) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_mean_anomaly(tle) IS 'Mean anomaly in degrees';
CREATE FUNCTION tle_mean_motion(tle) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_mean_motion(tle) IS 'Mean motion in revolutions per day';
CREATE FUNCTION tle_bstar(tle) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_bstar(tle) IS 'B* drag coefficient (1/earth-radii)';
CREATE FUNCTION tle_period(tle) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_period(tle) IS 'Orbital period in minutes';
CREATE FUNCTION tle_age(tle, timestamptz) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_age(tle, timestamptz) IS 'TLE age in days (positive = past epoch, negative = before epoch)';
CREATE FUNCTION tle_perigee(tle) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_perigee(tle) IS 'Perigee altitude in km above WGS-72 ellipsoid';
CREATE FUNCTION tle_apogee(tle) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_apogee(tle) IS 'Apogee altitude in km above WGS-72 ellipsoid';
CREATE FUNCTION tle_intl_desig(tle) RETURNS text
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_intl_desig(tle) IS 'International designator (COSPAR ID)';
CREATE FUNCTION tle_from_lines(text, text) RETURNS tle
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_from_lines(text, text) IS
'Construct TLE from separate line1/line2 text columns';
-- ============================================================
-- ECI position type: True Equator Mean Equinox (TEME) frame
-- ============================================================
CREATE FUNCTION eci_in(cstring) RETURNS eci_position
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION eci_out(eci_position) RETURNS cstring
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION eci_recv(internal) RETURNS eci_position
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION eci_send(eci_position) RETURNS bytea
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE TYPE eci_position (
INPUT = eci_in,
OUTPUT = eci_out,
RECEIVE = eci_recv,
SEND = eci_send,
INTERNALLENGTH = 48,
ALIGNMENT = double,
STORAGE = plain
);
COMMENT ON TYPE eci_position IS 'Earth-Centered Inertial position and velocity in TEME frame (km, km/s)';
-- ECI accessor functions
CREATE FUNCTION eci_x(eci_position) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION eci_y(eci_position) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION eci_z(eci_position) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION eci_vx(eci_position) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION eci_vy(eci_position) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION eci_vz(eci_position) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION eci_speed(eci_position) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eci_speed(eci_position) IS 'Velocity magnitude in km/s';
CREATE FUNCTION eci_altitude(eci_position) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eci_altitude(eci_position) IS 'Approximate geocentric altitude in km (radius - WGS72_AE)';
-- ============================================================
-- Geodetic type: WGS-84 latitude/longitude/altitude
-- ============================================================
CREATE FUNCTION geodetic_in(cstring) RETURNS geodetic
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION geodetic_out(geodetic) RETURNS cstring
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION geodetic_recv(internal) RETURNS geodetic
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION geodetic_send(geodetic) RETURNS bytea
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE TYPE geodetic (
INPUT = geodetic_in,
OUTPUT = geodetic_out,
RECEIVE = geodetic_recv,
SEND = geodetic_send,
INTERNALLENGTH = 24,
ALIGNMENT = double,
STORAGE = plain
);
COMMENT ON TYPE geodetic IS 'Geodetic coordinates on WGS-84 ellipsoid (lat/lon in degrees, altitude in km)';
CREATE FUNCTION geodetic_lat(geodetic) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION geodetic_lon(geodetic) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION geodetic_alt(geodetic) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
-- ============================================================
-- Topocentric type: observer-relative az/el/range
-- ============================================================
CREATE FUNCTION topocentric_in(cstring) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION topocentric_out(topocentric) RETURNS cstring
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION topocentric_recv(internal) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION topocentric_send(topocentric) RETURNS bytea
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE TYPE topocentric (
INPUT = topocentric_in,
OUTPUT = topocentric_out,
RECEIVE = topocentric_recv,
SEND = topocentric_send,
INTERNALLENGTH = 32,
ALIGNMENT = double,
STORAGE = plain
);
COMMENT ON TYPE topocentric IS 'Topocentric coordinates relative to observer (azimuth, elevation, range, range rate)';
CREATE FUNCTION topo_azimuth(topocentric) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION topo_azimuth(topocentric) IS 'Azimuth in degrees (0=N, 90=E, 180=S, 270=W)';
CREATE FUNCTION topo_elevation(topocentric) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION topo_elevation(topocentric) IS 'Elevation in degrees (0=horizon, 90=zenith)';
CREATE FUNCTION topo_range(topocentric) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION topo_range(topocentric) IS 'Slant range in km';
CREATE FUNCTION topo_range_rate(topocentric) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION topo_range_rate(topocentric) IS 'Range rate in km/s (positive = receding)';
-- ============================================================
-- Observer type: ground station location
-- ============================================================
CREATE FUNCTION observer_in(cstring) RETURNS observer
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION observer_out(observer) RETURNS cstring
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION observer_recv(internal) RETURNS observer
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION observer_send(observer) RETURNS bytea
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE TYPE observer (
INPUT = observer_in,
OUTPUT = observer_out,
RECEIVE = observer_recv,
SEND = observer_send,
INTERNALLENGTH = 24,
ALIGNMENT = double,
STORAGE = plain
);
COMMENT ON TYPE observer IS 'Ground observer location (accepts: 40.0N 105.3W 1655m or decimal degrees)';
CREATE FUNCTION observer_lat(observer) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION observer_lat(observer) IS 'Latitude in degrees (positive = North)';
CREATE FUNCTION observer_lon(observer) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION observer_lon(observer) IS 'Longitude in degrees (positive = East)';
CREATE FUNCTION observer_alt(observer) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION observer_alt(observer) IS 'Altitude in meters above WGS-84 ellipsoid';
CREATE FUNCTION observer_from_geodetic(float8, float8, float8 DEFAULT 0.0) RETURNS observer
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION observer_from_geodetic(float8, float8, float8) IS
'Construct observer from lat (deg), lon (deg), altitude (meters). Avoids text formatting round-trips.';
-- ============================================================
-- Pass event type: satellite visibility window
-- ============================================================
CREATE FUNCTION pass_event_in(cstring) RETURNS pass_event
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION pass_event_out(pass_event) RETURNS cstring
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION pass_event_recv(internal) RETURNS pass_event
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION pass_event_send(pass_event) RETURNS bytea
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE TYPE pass_event (
INPUT = pass_event_in,
OUTPUT = pass_event_out,
RECEIVE = pass_event_recv,
SEND = pass_event_send,
INTERNALLENGTH = 48,
ALIGNMENT = double,
STORAGE = plain
);
COMMENT ON TYPE pass_event IS 'Satellite pass event (AOS/MAX/LOS times, max elevation, AOS/LOS azimuths)';
CREATE FUNCTION pass_aos_time(pass_event) RETURNS timestamptz
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION pass_aos_time(pass_event) IS 'Acquisition of signal time';
CREATE FUNCTION pass_max_el_time(pass_event) RETURNS timestamptz
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION pass_max_el_time(pass_event) IS 'Maximum elevation time';
CREATE FUNCTION pass_los_time(pass_event) RETURNS timestamptz
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION pass_los_time(pass_event) IS 'Loss of signal time';
CREATE FUNCTION pass_max_elevation(pass_event) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION pass_max_elevation(pass_event) IS 'Maximum elevation in degrees';
CREATE FUNCTION pass_aos_azimuth(pass_event) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION pass_aos_azimuth(pass_event) IS 'AOS azimuth in degrees (0=N)';
CREATE FUNCTION pass_los_azimuth(pass_event) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION pass_los_azimuth(pass_event) IS 'LOS azimuth in degrees (0=N)';
CREATE FUNCTION pass_duration(pass_event) RETURNS interval
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION pass_duration(pass_event) IS 'Pass duration (LOS - AOS)';
-- ============================================================
-- SGP4/SDP4 propagation functions
-- ============================================================
CREATE FUNCTION sgp4_propagate(tle, timestamptz) RETURNS eci_position
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION sgp4_propagate(tle, timestamptz) IS
'Propagate TLE to a point in time using SGP4 (near-earth) or SDP4 (deep-space). Returns TEME ECI position/velocity.';
CREATE FUNCTION sgp4_propagate_safe(tle, timestamptz) RETURNS eci_position
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE PARALLEL SAFE;
COMMENT ON FUNCTION sgp4_propagate_safe(tle, timestamptz) IS
'Like sgp4_propagate but returns NULL on error instead of raising an exception. For batch queries with potentially invalid TLEs.';
CREATE FUNCTION sgp4_propagate_series(tle, timestamptz, timestamptz, interval)
RETURNS TABLE(t timestamptz, x float8, y float8, z float8, vx float8, vy float8, vz float8)
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE
ROWS 100;
COMMENT ON FUNCTION sgp4_propagate_series(tle, timestamptz, timestamptz, interval) IS
'Propagate TLE over a time range at regular intervals. Returns time series of TEME positions.';
CREATE FUNCTION tle_distance(tle, tle, timestamptz) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_distance(tle, tle, timestamptz) IS
'Euclidean distance in km between two TLEs at a reference time';
-- ============================================================
-- Coordinate transform functions
-- ============================================================
CREATE FUNCTION eci_to_geodetic(eci_position, timestamptz) RETURNS geodetic
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eci_to_geodetic(eci_position, timestamptz) IS
'Convert TEME ECI position to WGS-84 geodetic coordinates at given time';
CREATE FUNCTION eci_to_topocentric(eci_position, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eci_to_topocentric(eci_position, observer, timestamptz) IS
'Convert TEME ECI position to topocentric (az/el/range) relative to observer';
CREATE FUNCTION subsatellite_point(tle, timestamptz) RETURNS geodetic
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION subsatellite_point(tle, timestamptz) IS
'Subsatellite (nadir) point on WGS-84 ellipsoid at given time';
CREATE FUNCTION ground_track(tle, timestamptz, timestamptz, interval)
RETURNS TABLE(t timestamptz, lat float8, lon float8, alt float8)
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE
ROWS 100;
COMMENT ON FUNCTION ground_track(tle, timestamptz, timestamptz, interval) IS
'Ground track as time series of subsatellite points (lat/lon in degrees, alt in km)';
CREATE FUNCTION observe(tle, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION observe(tle, observer, timestamptz) IS
'Propagate TLE and compute observer-relative look angles in one call. Returns topocentric (az/el/range/range_rate).';
CREATE FUNCTION observe_safe(tle, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE PARALLEL SAFE;
COMMENT ON FUNCTION observe_safe(tle, observer, timestamptz) IS
'Like observe() but returns NULL on propagation error. For batch queries with potentially invalid/decayed TLEs.';
-- ============================================================
-- Pass prediction functions
-- ============================================================
CREATE FUNCTION next_pass(tle, observer, timestamptz) RETURNS pass_event
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION next_pass(tle, observer, timestamptz) IS
'Find the next satellite pass over observer (searches up to 7 days ahead)';
CREATE FUNCTION predict_passes(tle, observer, timestamptz, timestamptz, float8 DEFAULT 0.0)
RETURNS SETOF pass_event
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE
ROWS 10;
COMMENT ON FUNCTION predict_passes(tle, observer, timestamptz, timestamptz, float8) IS
'Predict all satellite passes over observer in time window. Optional min_elevation in degrees.';
CREATE FUNCTION pass_visible(tle, observer, timestamptz, timestamptz) RETURNS boolean
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION pass_visible(tle, observer, timestamptz, timestamptz) IS
'True if any pass occurs over observer in the time window';
-- ============================================================
-- GiST operator support functions
-- ============================================================
-- Overlap operator: do orbital keys overlap in altitude AND inclination?
CREATE FUNCTION tle_overlap(tle, tle) RETURNS boolean
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
-- Altitude distance operator (altitude-only, for KNN ordering)
CREATE FUNCTION tle_alt_distance(tle, tle) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE OPERATOR && (
LEFTARG = tle,
RIGHTARG = tle,
FUNCTION = tle_overlap,
COMMUTATOR = &&,
RESTRICT = areasel,
JOIN = areajoinsel
);
COMMENT ON OPERATOR && (tle, tle) IS 'Orbital key overlap (altitude band AND inclination range) — necessary condition for conjunction';
CREATE OPERATOR <-> (
LEFTARG = tle,
RIGHTARG = tle,
FUNCTION = tle_alt_distance,
COMMUTATOR = <->
);
COMMENT ON OPERATOR <-> (tle, tle) IS '2-D orbital distance in km: L2 norm of altitude-band gap and inclination gap (radians × Earth radius). Returns 0 when both dimensions overlap.';
-- ============================================================
-- GiST operator class for 2-D orbital indexing (altitude + inclination)
-- ============================================================
-- GiST internal support functions
CREATE FUNCTION gist_tle_compress(internal) RETURNS internal
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION gist_tle_decompress(internal) RETURNS internal
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION gist_tle_consistent(internal, tle, smallint, oid, internal) RETURNS boolean
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION gist_tle_union(internal, internal) RETURNS internal
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION gist_tle_penalty(internal, internal, internal) RETURNS internal
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION gist_tle_picksplit(internal, internal) RETURNS internal
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION gist_tle_same(internal, internal, internal) RETURNS internal
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION gist_tle_distance(internal, tle, smallint, oid, internal) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE OPERATOR CLASS tle_ops
DEFAULT FOR TYPE tle USING gist AS
OPERATOR 3 && ,
OPERATOR 15 <-> (tle, tle) FOR ORDER BY float_ops,
FUNCTION 1 gist_tle_consistent(internal, tle, smallint, oid, internal),
FUNCTION 2 gist_tle_union(internal, internal),
FUNCTION 3 gist_tle_compress(internal),
FUNCTION 4 gist_tle_decompress(internal),
FUNCTION 5 gist_tle_penalty(internal, internal, internal),
FUNCTION 6 gist_tle_picksplit(internal, internal),
FUNCTION 7 gist_tle_same(internal, internal, internal),
FUNCTION 8 gist_tle_distance(internal, tle, smallint, oid, internal);
-- ============================================================
-- Heliocentric type: ecliptic J2000 position in AU
-- ============================================================
CREATE TYPE heliocentric;
CREATE FUNCTION heliocentric_in(cstring) RETURNS heliocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION heliocentric_out(heliocentric) RETURNS cstring
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION heliocentric_recv(internal) RETURNS heliocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION heliocentric_send(heliocentric) RETURNS bytea
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE TYPE heliocentric (
INPUT = heliocentric_in,
OUTPUT = heliocentric_out,
RECEIVE = heliocentric_recv,
SEND = heliocentric_send,
INTERNALLENGTH = 24,
ALIGNMENT = double,
STORAGE = plain
);
COMMENT ON TYPE heliocentric IS 'Heliocentric position in ecliptic J2000 frame (x, y, z in AU)';
CREATE FUNCTION helio_x(heliocentric) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION helio_x(heliocentric) IS 'X component in AU (ecliptic J2000, vernal equinox direction)';
CREATE FUNCTION helio_y(heliocentric) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION helio_y(heliocentric) IS 'Y component in AU (ecliptic J2000)';
CREATE FUNCTION helio_z(heliocentric) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION helio_z(heliocentric) IS 'Z component in AU (ecliptic J2000, north ecliptic pole)';
CREATE FUNCTION helio_distance(heliocentric) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION helio_distance(heliocentric) IS 'Heliocentric distance in AU';
-- ============================================================
-- Star observation functions
-- ============================================================
CREATE FUNCTION star_observe(float8, float8, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION star_observe(float8, float8, observer, timestamptz) IS
'Observe a star from (ra_hours J2000, dec_degrees J2000, observer, time). Returns topocentric az/el. Range is 0 (infinite distance).';
CREATE FUNCTION star_observe_safe(float8, float8, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE PARALLEL SAFE;
COMMENT ON FUNCTION star_observe_safe(float8, float8, observer, timestamptz) IS
'Like star_observe but returns NULL on invalid inputs. For batch queries over star catalogs.';
-- ============================================================
-- Keplerian propagation functions
-- ============================================================
CREATE FUNCTION kepler_propagate(
q_au float8, eccentricity float8,
inclination_deg float8, arg_perihelion_deg float8,
long_asc_node_deg float8, perihelion_jd float8,
t timestamptz
) RETURNS heliocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION kepler_propagate(float8, float8, float8, float8, float8, float8, timestamptz) IS
'Two-body Keplerian propagation from classical orbital elements. Returns heliocentric ecliptic J2000 position in AU. Handles elliptic, parabolic, and hyperbolic orbits.';
-- ============================================================
-- Comet observation
-- ============================================================
CREATE FUNCTION comet_observe(
q_au float8, eccentricity float8,
inclination_deg float8, arg_perihelion_deg float8,
long_asc_node_deg float8, perihelion_jd float8,
earth_x_au float8, earth_y_au float8, earth_z_au float8,
obs observer, t timestamptz
) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION comet_observe(float8, float8, float8, float8, float8, float8, float8, float8, float8, observer, timestamptz) IS
'Observe a comet/asteroid from orbital elements. Requires Earth heliocentric ecliptic J2000 position (AU). Returns topocentric az/el with geocentric range in km.';
-- ============================================================
-- VSOP87 planets, ELP82B Moon, Sun observation
-- ============================================================
CREATE FUNCTION planet_heliocentric(int4, timestamptz) RETURNS heliocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION planet_heliocentric(int4, timestamptz) IS
'VSOP87 heliocentric ecliptic J2000 position (AU). Body IDs: 0=Sun, 1=Mercury, ..., 8=Neptune.';
CREATE FUNCTION planet_observe(int4, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION planet_observe(int4, observer, timestamptz) IS
'Observe a planet from (body_id 1-8, observer, time). Returns topocentric az/el with geocentric range in km.';
CREATE FUNCTION sun_observe(observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION sun_observe(observer, timestamptz) IS
'Observe the Sun from (observer, time). Returns topocentric az/el with geocentric range in km.';
CREATE FUNCTION moon_observe(observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION moon_observe(observer, timestamptz) IS
'Observe the Moon via ELP2000-82B from (observer, time). Returns topocentric az/el with geocentric range in km.';
-- ============================================================
-- Planetary moon observation
-- ============================================================
CREATE FUNCTION galilean_observe(int4, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION galilean_observe(int4, observer, timestamptz) IS
'Observe a Galilean moon of Jupiter via L1.2 theory. Body IDs: 0=Io, 1=Europa, 2=Ganymede, 3=Callisto.';
CREATE FUNCTION saturn_moon_observe(int4, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION saturn_moon_observe(int4, observer, timestamptz) IS
'Observe a Saturn moon via TASS 1.7. Body IDs: 0=Mimas, 1=Enceladus, 2=Tethys, 3=Dione, 4=Rhea, 5=Titan, 6=Iapetus, 7=Hyperion.';
CREATE FUNCTION uranus_moon_observe(int4, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION uranus_moon_observe(int4, observer, timestamptz) IS
'Observe a Uranus moon via GUST86. Body IDs: 0=Miranda, 1=Ariel, 2=Umbriel, 3=Titania, 4=Oberon.';
CREATE FUNCTION mars_moon_observe(int4, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION mars_moon_observe(int4, observer, timestamptz) IS
'Observe a Mars moon via MarsSat. Body IDs: 0=Phobos, 1=Deimos.';
-- ============================================================
-- Jupiter decametric radio burst prediction
-- ============================================================
CREATE FUNCTION io_phase_angle(timestamptz) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION io_phase_angle(timestamptz) IS
'Io orbital phase angle in degrees [0,360). 0=superior conjunction (behind Jupiter). Standard Radio JOVE convention.';
CREATE FUNCTION jupiter_cml(observer, timestamptz) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION jupiter_cml(observer, timestamptz) IS
'Jupiter Central Meridian Longitude, System III (1965.0), in degrees [0,360). Light-time corrected.';
CREATE FUNCTION jupiter_burst_probability(float8, float8) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION jupiter_burst_probability(float8, float8) IS
'Estimated Jupiter decametric burst probability (0-1) from (io_phase_deg, cml_deg). Based on Carr et al. (1983) source regions A, B, C, D.';
-- ============================================================
-- Interplanetary transfer orbits (Lambert solver)
-- ============================================================
CREATE FUNCTION lambert_transfer(
dep_body_id int4, arr_body_id int4,
dep_time timestamptz, arr_time timestamptz,
OUT c3_departure float8, OUT c3_arrival float8,
OUT v_inf_departure float8, OUT v_inf_arrival float8,
OUT tof_days float8, OUT transfer_sma float8
) RETURNS RECORD
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION lambert_transfer(int4, int4, timestamptz, timestamptz) IS
'Solve Lambert transfer between two planets. Returns C3 (km^2/s^2), v_infinity (km/s), TOF (days), SMA (AU). Body IDs 1-8.';
CREATE FUNCTION lambert_c3(int4, int4, timestamptz, timestamptz) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION lambert_c3(int4, int4, timestamptz, timestamptz) IS
'Departure C3 (km^2/s^2) for a Lambert transfer. Returns NULL if solver fails. For pork chop plots.';
-- ============================================================
-- DE ephemeris functions (optional high-precision)
-- ============================================================
CREATE FUNCTION planet_heliocentric_de(int4, timestamptz) RETURNS heliocentric
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION planet_heliocentric_de(int4, timestamptz) IS
'Heliocentric ecliptic J2000 position via JPL DE (sub-arcsecond). Falls back to VSOP87 if DE unavailable. Body IDs: 0=Sun, 1-8=Mercury-Neptune.';
CREATE FUNCTION planet_observe_de(int4, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION planet_observe_de(int4, observer, timestamptz) IS
'Observe planet via JPL DE. Falls back to VSOP87. Body IDs: 1-8 (Mercury-Neptune).';
CREATE FUNCTION sun_observe_de(observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION sun_observe_de(observer, timestamptz) IS
'Observe Sun via JPL DE. Falls back to VSOP87.';
CREATE FUNCTION moon_observe_de(observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION moon_observe_de(observer, timestamptz) IS
'Observe Moon via JPL DE. Falls back to ELP2000-82B.';
CREATE FUNCTION lambert_transfer_de(
dep_body_id int4, arr_body_id int4,
dep_time timestamptz, arr_time timestamptz,
OUT c3_departure float8, OUT c3_arrival float8,
OUT v_inf_departure float8, OUT v_inf_arrival float8,
OUT tof_days float8, OUT transfer_sma float8
) RETURNS RECORD
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION lambert_transfer_de(int4, int4, timestamptz, timestamptz) IS
'Lambert transfer via JPL DE positions. Falls back to VSOP87. Body IDs 1-8.';
CREATE FUNCTION lambert_c3_de(int4, int4, timestamptz, timestamptz) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION lambert_c3_de(int4, int4, timestamptz, timestamptz) IS
'Departure C3 via JPL DE. Falls back to VSOP87. For pork chop plots.';
CREATE FUNCTION galilean_observe_de(int4, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION galilean_observe_de(int4, observer, timestamptz) IS
'Observe Galilean moon with JPL DE parent position. L1.2 moon offsets. Body IDs: 0-3 (Io-Callisto).';
CREATE FUNCTION saturn_moon_observe_de(int4, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION saturn_moon_observe_de(int4, observer, timestamptz) IS
'Observe Saturn moon with JPL DE parent position. TASS 1.7 moon offsets. Body IDs: 0-7 (Mimas-Hyperion).';
CREATE FUNCTION uranus_moon_observe_de(int4, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION uranus_moon_observe_de(int4, observer, timestamptz) IS
'Observe Uranus moon with JPL DE parent position. GUST86 moon offsets. Body IDs: 0-4 (Miranda-Oberon).';
CREATE FUNCTION mars_moon_observe_de(int4, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION mars_moon_observe_de(int4, observer, timestamptz) IS
'Observe Mars moon with JPL DE parent position. MarsSat moon offsets. Body IDs: 0-1 (Phobos-Deimos).';
-- Diagnostic function
CREATE FUNCTION pg_orrery_ephemeris_info(
OUT provider text, OUT file_path text,
OUT start_jd float8, OUT end_jd float8,
OUT version int4, OUT au_km float8
) RETURNS RECORD
AS 'MODULE_PATHNAME' LANGUAGE C STABLE PARALLEL SAFE;
COMMENT ON FUNCTION pg_orrery_ephemeris_info() IS
'Returns current ephemeris provider status: VSOP87 or JPL_DE with file path, JD range, version, and AU value.';
-- ============================================================
-- Orbit determination (TLE fitting from observations)
-- ============================================================
-- Fit TLE from ECI position/velocity ephemeris
-- weights: per-observation weighting (NULL = uniform)
CREATE FUNCTION tle_from_eci(
positions eci_position[], times timestamptz[],
seed tle DEFAULT NULL, fit_bstar boolean DEFAULT false,
max_iter int4 DEFAULT 15,
weights float8[] DEFAULT NULL,
OUT fitted_tle tle, OUT iterations int4,
OUT rms_final float8, OUT rms_initial float8, OUT status text,
OUT condition_number float8, OUT covariance float8[], OUT nstate int4
) RETURNS RECORD
AS 'MODULE_PATHNAME' LANGUAGE C STABLE PARALLEL SAFE;
COMMENT ON FUNCTION tle_from_eci(eci_position[], timestamptz[], tle, boolean, int4, float8[]) IS
'Fit a TLE from ECI position/velocity observations via differential correction. Optional per-observation weights for heterogeneous sensor fusion. Returns fitted TLE, RMS residuals, convergence status, condition number, and formal covariance matrix.';
-- Fit TLE from topocentric observations (az/el/range) — single observer
-- fit_range_rate: include range_rate as 4th residual component
-- weights: per-observation weighting (NULL = uniform)
CREATE FUNCTION tle_from_topocentric(
observations topocentric[], times timestamptz[],
obs observer,
seed tle DEFAULT NULL, fit_bstar boolean DEFAULT false,
max_iter int4 DEFAULT 15,
fit_range_rate boolean DEFAULT false,
weights float8[] DEFAULT NULL,
OUT fitted_tle tle, OUT iterations int4,
OUT rms_final float8, OUT rms_initial float8, OUT status text,
OUT condition_number float8, OUT covariance float8[], OUT nstate int4
) RETURNS RECORD
AS 'MODULE_PATHNAME' LANGUAGE C STABLE PARALLEL SAFE;
COMMENT ON FUNCTION tle_from_topocentric(topocentric[], timestamptz[], observer, tle, boolean, int4, boolean, float8[]) IS
'Fit a TLE from topocentric (az/el/range) observations via differential correction. Optional range_rate fitting and per-observation weights. Returns fitted TLE, RMS residuals, convergence status, condition number, and formal covariance matrix.';
-- Fit TLE from topocentric observations — multiple observers
CREATE FUNCTION tle_from_topocentric(
observations topocentric[], times timestamptz[],
observers observer[], observer_ids int4[],
seed tle DEFAULT NULL, fit_bstar boolean DEFAULT false,
max_iter int4 DEFAULT 15,
fit_range_rate boolean DEFAULT false,
weights float8[] DEFAULT NULL,
OUT fitted_tle tle, OUT iterations int4,
OUT rms_final float8, OUT rms_initial float8, OUT status text,
OUT condition_number float8, OUT covariance float8[], OUT nstate int4
) RETURNS RECORD
AS 'MODULE_PATHNAME', 'tle_from_topocentric_multi'
LANGUAGE C STABLE PARALLEL SAFE;
COMMENT ON FUNCTION tle_from_topocentric(topocentric[], timestamptz[], observer[], int4[], tle, boolean, int4, boolean, float8[]) IS
'Fit a TLE from topocentric observations collected by multiple ground stations. observer_ids[i] indexes into observers[]. Optional range_rate fitting and per-observation weights.';
-- Per-observation residuals diagnostic
CREATE FUNCTION tle_fit_residuals(
fitted tle,
positions eci_position[],
times timestamptz[]
) RETURNS TABLE (
t timestamptz,
dx_km float8,
dy_km float8,
dz_km float8,
pos_err_km float8
)
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_fit_residuals(tle, eci_position[], timestamptz[]) IS
'Compute per-observation position residuals (km) between a TLE and ECI observations. Useful for fit quality assessment.';
-- Fit TLE from RA/Dec observations — single observer
-- Uses Gauss method for initial orbit determination when no seed is provided.
-- RA in hours [0,24), Dec in degrees [-90,90] (matches star_observe convention).
-- RMS output is in radians for angles-only mode.
CREATE FUNCTION tle_from_angles(
ra_hours float8[], dec_degrees float8[],
times timestamptz[],
obs observer,
seed tle DEFAULT NULL, fit_bstar boolean DEFAULT false,
max_iter int4 DEFAULT 15,
weights float8[] DEFAULT NULL,
OUT fitted_tle tle, OUT iterations int4,
OUT rms_final float8, OUT rms_initial float8, OUT status text,
OUT condition_number float8, OUT covariance float8[], OUT nstate int4
) RETURNS RECORD AS 'MODULE_PATHNAME' LANGUAGE C STABLE PARALLEL SAFE;
COMMENT ON FUNCTION tle_from_angles(float8[], float8[], timestamptz[], observer, tle, boolean, int4, float8[]) IS
'Fit a TLE from angles-only (RA/Dec) observations via Gauss IOD + differential correction. RA in hours [0,24), Dec in degrees [-90,90]. RMS output in radians. Uses Gauss method for seed-free initial guess.';
-- Fit TLE from RA/Dec observations — multiple observers
CREATE FUNCTION tle_from_angles(
ra_hours float8[], dec_degrees float8[],
times timestamptz[],
observers observer[], observer_ids int4[],
seed tle DEFAULT NULL, fit_bstar boolean DEFAULT false,
max_iter int4 DEFAULT 15,
weights float8[] DEFAULT NULL,
OUT fitted_tle tle, OUT iterations int4,
OUT rms_final float8, OUT rms_initial float8, OUT status text,
OUT condition_number float8, OUT covariance float8[], OUT nstate int4
) RETURNS RECORD
AS 'MODULE_PATHNAME', 'tle_from_angles_multi'
LANGUAGE C STABLE PARALLEL SAFE;
COMMENT ON FUNCTION tle_from_angles(float8[], float8[], timestamptz[], observer[], int4[], tle, boolean, int4, float8[]) IS
'Fit a TLE from angles-only (RA/Dec) observations from multiple ground stations via Gauss IOD + differential correction.';
-- pg_orrery 0.6.0 -> 0.7.0 migration
--
-- Adds SP-GiST orbital trie index for satellite pass prediction.
-- 2-level trie: SMA (L0) + inclination (L1) with query-time RAAN filter.
-- The &? operator answers "might this satellite be visible?"
-- ============================================================
-- observer_window composite type (query parameter bundle)
-- ============================================================
CREATE TYPE observer_window AS (
obs observer,
t_start timestamptz,
t_end timestamptz,
min_el float8
);
COMMENT ON TYPE observer_window IS
'Observation query parameters: observer location, time window, and minimum elevation angle (degrees). Used with the &? visibility cone operator.';
-- ============================================================
-- Visibility cone operator function
-- ============================================================
CREATE FUNCTION tle_visibility_possible(tle, observer_window) RETURNS boolean
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION tle_visibility_possible(tle, observer_window) IS
'Could this satellite be visible from the observer during the time window? Combines altitude, inclination, and RAAN checks. Conservative superset — survivors need SGP4 propagation for ground truth.';
-- ============================================================
-- &? operator (visibility cone check)
-- ============================================================
-- The indexed column (tle) MUST be the left argument so PostgreSQL
-- can form a ScanKey and pass it to inner_consistent for pruning.
CREATE OPERATOR &? (
LEFTARG = tle,
RIGHTARG = observer_window,
FUNCTION = tle_visibility_possible,
RESTRICT = contsel,
JOIN = contjoinsel
);
COMMENT ON OPERATOR &? (tle, observer_window) IS
'Visibility cone check: could this satellite be visible from the observer during the time window? Index-accelerated via SP-GiST orbital trie.';
-- ============================================================
-- SP-GiST support functions
-- ============================================================
CREATE FUNCTION spgist_tle_config(internal, internal) RETURNS void
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION spgist_tle_choose(internal, internal) RETURNS void
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION spgist_tle_picksplit(internal, internal) RETURNS void
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION spgist_tle_inner_consistent(internal, internal) RETURNS void
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION spgist_tle_leaf_consistent(internal, internal) RETURNS void
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
-- ============================================================
-- SP-GiST operator class (opt-in, not DEFAULT)
-- ============================================================
CREATE OPERATOR CLASS tle_spgist_ops
FOR TYPE tle USING spgist AS
OPERATOR 1 &? (tle, observer_window),
FUNCTION 1 spgist_tle_config(internal, internal),
FUNCTION 2 spgist_tle_choose(internal, internal),
FUNCTION 3 spgist_tle_picksplit(internal, internal),
FUNCTION 4 spgist_tle_inner_consistent(internal, internal),
FUNCTION 5 spgist_tle_leaf_consistent(internal, internal);

View File

@ -0,0 +1,204 @@
-- pg_orrery 0.8.0 -> 0.9.0 migration
--
-- Adds equatorial type (apparent RA/Dec of date), atmospheric refraction,
-- stellar proper motion, and light-time corrected _apparent() functions.
-- ============================================================
-- equatorial type — apparent RA/Dec of date
-- ============================================================
CREATE TYPE equatorial;
CREATE FUNCTION equatorial_in(cstring) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION equatorial_out(equatorial) RETURNS cstring
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION equatorial_recv(internal) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION equatorial_send(equatorial) RETURNS bytea
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE TYPE equatorial (
INPUT = equatorial_in,
OUTPUT = equatorial_out,
RECEIVE = equatorial_recv,
SEND = equatorial_send,
INTERNALLENGTH = 24,
ALIGNMENT = double,
STORAGE = plain
);
COMMENT ON TYPE equatorial IS
'Apparent equatorial coordinates of date: RA (hours), Dec (degrees), distance (km). Solar system: J2000 precessed via IAU 1976. Satellites: TEME frame (~of-date to ~arcsecond). 24 bytes, fixed-size.';
-- ============================================================
-- Equatorial accessor functions
-- ============================================================
CREATE FUNCTION eq_ra(equatorial) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eq_ra(equatorial) IS 'Right ascension in hours [0, 24)';
CREATE FUNCTION eq_dec(equatorial) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eq_dec(equatorial) IS 'Declination in degrees [-90, 90]';
CREATE FUNCTION eq_distance(equatorial) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eq_distance(equatorial) IS 'Distance in km (0 for stars without parallax)';
-- ============================================================
-- Satellite RA/Dec functions
-- ============================================================
CREATE FUNCTION eci_to_equatorial(eci_position, observer, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eci_to_equatorial(eci_position, observer, timestamptz) IS
'Topocentric apparent RA/Dec from ECI position. Observer parallax-corrected — LEO parallax is ~1 degree.';
CREATE FUNCTION eci_to_equatorial_geo(eci_position, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eci_to_equatorial_geo(eci_position, timestamptz) IS
'Geocentric apparent RA/Dec from ECI position. Observer-independent — the direction of the TEME position vector.';
-- ============================================================
-- Solar system equatorial functions (VSOP87)
-- ============================================================
CREATE FUNCTION planet_equatorial(int4, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION planet_equatorial(int4, timestamptz) IS
'Geocentric apparent RA/Dec of a planet via VSOP87. Body IDs: 1=Mercury through 8=Neptune.';
CREATE FUNCTION sun_equatorial(timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION sun_equatorial(timestamptz) IS
'Geocentric apparent RA/Dec of the Sun via VSOP87.';
CREATE FUNCTION moon_equatorial(timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION moon_equatorial(timestamptz) IS
'Geocentric apparent RA/Dec of the Moon via ELP2000-82B.';
CREATE FUNCTION small_body_equatorial(orbital_elements, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION small_body_equatorial(orbital_elements, timestamptz) IS
'Geocentric apparent RA/Dec of a comet/asteroid from orbital elements. Earth via VSOP87.';
CREATE FUNCTION star_equatorial(float8, float8, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION star_equatorial(float8, float8, timestamptz) IS
'Apparent RA/Dec of a star at a given time. Precesses J2000 catalog coordinates (RA hours, Dec degrees) to date via IAU 1976.';
-- ============================================================
-- Atmospheric refraction (Bennett 1982)
-- ============================================================
CREATE FUNCTION atmospheric_refraction(float8) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION atmospheric_refraction(float8) IS
'Atmospheric refraction correction in degrees for a given geometric elevation (degrees). Standard atmosphere: P=1010 mbar, T=10C. Bennett (1982) formula with domain guard at -1 degree.';
CREATE FUNCTION atmospheric_refraction_ext(float8, float8, float8) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION atmospheric_refraction_ext(float8, float8, float8) IS
'Atmospheric refraction with pressure/temperature correction. Args: elevation_deg, pressure_mbar, temperature_celsius. Meeus P/T factor applied to Bennett formula.';
CREATE FUNCTION topo_elevation_apparent(topocentric) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION topo_elevation_apparent(topocentric) IS
'Apparent elevation in degrees — geometric elevation plus atmospheric refraction correction.';
-- ============================================================
-- Refracted pass prediction
-- ============================================================
CREATE FUNCTION predict_passes_refracted(
tle, observer, timestamptz, timestamptz, float8 DEFAULT 0.0
) RETURNS SETOF pass_event
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE
ROWS 20;
COMMENT ON FUNCTION predict_passes_refracted(tle, observer, timestamptz, timestamptz, float8) IS
'Predict satellite passes using a refracted horizon threshold (-0.569 deg geometric). Atmospheric refraction makes satellites visible ~35 seconds earlier at AOS and later at LOS.';
-- ============================================================
-- Stellar proper motion
-- ============================================================
CREATE FUNCTION star_observe_pm(
float8, float8, float8, float8, float8, float8, observer, timestamptz
) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION star_observe_pm(float8, float8, float8, float8, float8, float8, observer, timestamptz) IS
'Observe a star with proper motion. Args: ra_hours, dec_deg, pm_ra_masyr (mu_alpha*cos(delta)), pm_dec_masyr, parallax_mas, rv_kms, observer, time. Hipparcos/Gaia convention for pm_ra.';
CREATE FUNCTION star_equatorial_pm(
float8, float8, float8, float8, float8, float8, timestamptz
) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION star_equatorial_pm(float8, float8, float8, float8, float8, float8, timestamptz) IS
'Apparent RA/Dec of a star with proper motion. Args: ra_hours, dec_deg, pm_ra_masyr, pm_dec_masyr, parallax_mas, rv_kms, time. Distance from parallax if > 0.';
-- ============================================================
-- Light-time corrected observation functions
-- ============================================================
CREATE FUNCTION planet_observe_apparent(int4, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION planet_observe_apparent(int4, observer, timestamptz) IS
'Observe a planet with single-iteration light-time correction. Body at retarded time, Earth at observation time. VSOP87.';
CREATE FUNCTION sun_observe_apparent(observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION sun_observe_apparent(observer, timestamptz) IS
'Observe the Sun with light-time correction (~8.3 min). VSOP87.';
CREATE FUNCTION small_body_observe_apparent(orbital_elements, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION small_body_observe_apparent(orbital_elements, observer, timestamptz) IS
'Observe a comet/asteroid with single-iteration light-time correction. Kepler propagation at retarded time, Earth via VSOP87 at observation time.';
-- ============================================================
-- Light-time corrected equatorial functions
-- ============================================================
CREATE FUNCTION planet_equatorial_apparent(int4, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION planet_equatorial_apparent(int4, timestamptz) IS
'Geocentric apparent RA/Dec of a planet with light-time correction. VSOP87.';
CREATE FUNCTION moon_equatorial_apparent(timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION moon_equatorial_apparent(timestamptz) IS
'Geocentric apparent RA/Dec of the Moon with light-time correction (~1.3 sec). ELP2000-82B.';
CREATE FUNCTION small_body_equatorial_apparent(orbital_elements, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION small_body_equatorial_apparent(orbital_elements, timestamptz) IS
'Geocentric apparent RA/Dec of a comet/asteroid with light-time correction.';
-- ============================================================
-- DE ephemeris equatorial variants (STABLE)
-- ============================================================
CREATE FUNCTION planet_equatorial_de(int4, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION planet_equatorial_de(int4, timestamptz) IS
'Geocentric apparent RA/Dec of a planet via JPL DE ephemeris (falls back to VSOP87 + equatorial).';
CREATE FUNCTION moon_equatorial_de(timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION moon_equatorial_de(timestamptz) IS
'Geocentric apparent RA/Dec of the Moon via JPL DE ephemeris (falls back to ELP2000-82B + equatorial).';

1072
sql/pg_orrery--0.8.0.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
-- pg_orrery 0.9.0 -> 0.10.0 migration
--
-- Adds annual aberration to existing _apparent() functions,
-- 6 new _apparent_de() variants, equatorial angular separation
-- operator and cone predicate, and stellar annual parallax.
-- ============================================================
-- Equatorial angular distance and cone search
-- ============================================================
CREATE FUNCTION eq_angular_distance(equatorial, equatorial) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eq_angular_distance(equatorial, equatorial) IS
'Angular separation in degrees between two equatorial positions. Vincenty formula (stable at 0 and 180 degrees).';
CREATE FUNCTION eq_within_cone(equatorial, equatorial, float8) RETURNS bool
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eq_within_cone(equatorial, equatorial, float8) IS
'True if first position is within radius_deg of second position. Cosine shortcut for fast rejection.';
CREATE OPERATOR <-> (
LEFTARG = equatorial,
RIGHTARG = equatorial,
FUNCTION = eq_angular_distance,
COMMUTATOR = <->
);
COMMENT ON OPERATOR <-> (equatorial, equatorial) IS
'Angular separation in degrees between two equatorial positions.';
-- ============================================================
-- DE apparent observation functions (STABLE, light-time + aberration)
-- ============================================================
CREATE FUNCTION planet_observe_apparent_de(int4, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION planet_observe_apparent_de(int4, observer, timestamptz) IS
'Observe a planet with light-time correction and annual aberration via JPL DE (falls back to VSOP87).';
CREATE FUNCTION sun_observe_apparent_de(observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION sun_observe_apparent_de(observer, timestamptz) IS
'Observe the Sun with aberration via JPL DE (falls back to VSOP87).';
CREATE FUNCTION moon_observe_apparent_de(observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION moon_observe_apparent_de(observer, timestamptz) IS
'Observe the Moon with light-time correction and annual aberration via JPL DE (falls back to ELP2000-82B).';
CREATE FUNCTION planet_equatorial_apparent_de(int4, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION planet_equatorial_apparent_de(int4, timestamptz) IS
'Geocentric apparent RA/Dec of a planet with light-time correction and annual aberration via JPL DE (falls back to VSOP87).';
CREATE FUNCTION moon_equatorial_apparent_de(timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION moon_equatorial_apparent_de(timestamptz) IS
'Geocentric apparent RA/Dec of the Moon with light-time correction and annual aberration via JPL DE (falls back to ELP2000-82B).';
CREATE FUNCTION small_body_observe_apparent_de(orbital_elements, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION small_body_observe_apparent_de(orbital_elements, observer, timestamptz) IS
'Observe a comet/asteroid with light-time correction and annual aberration. Earth position via JPL DE (falls back to VSOP87).';

1276
sql/pg_orrery--0.9.0.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@ -217,4 +217,213 @@ observe_from_geocentric(const double geo_ecl_au[3], double jd,
result->range_rate = 0.0; /* no velocity computation yet */
}
/*
* Geocentric ecliptic J2000 (AU) -> equatorial RA/Dec of date.
*
* Captures the intermediate result that observe_from_geocentric() computes
* and discards. Stops after precession -- no hour angle or az/el.
* Distance is converted to km (AU_KM) to match topocentric.range_km.
*/
static inline void
geocentric_to_equatorial(const double geo_ecl_au[3], double jd,
pg_equatorial *result)
{
double geo_equ[3];
double ra_j2000, dec_j2000, geo_dist;
double ra_date, dec_date;
ecliptic_to_equatorial(geo_ecl_au, geo_equ);
cartesian_to_spherical(geo_equ, &ra_j2000, &dec_j2000, &geo_dist);
precess_j2000_to_date(jd, ra_j2000, dec_j2000, &ra_date, &dec_date);
result->ra = ra_date;
result->dec = dec_date;
result->distance = geo_dist * AU_KM;
}
/*
* Apply classical annual aberration to J2000 equatorial RA/Dec.
*
* The apparent direction of an object is displaced toward the apex of
* Earth's motion by v/c (~20.5 arcseconds maximum).
*
* earth_vel_equ: Earth's velocity in equatorial J2000 (AU/day)
* ra, dec: pointers to J2000 RA and Dec (radians), modified in-place
*
* Reference: Green (1985) "Spherical Astronomy", Eq. 10.22
*/
static inline void
apply_annual_aberration(const double earth_vel_equ[3],
double *ra, double *dec)
{
double cos_ra = cos(*ra);
double sin_ra = sin(*ra);
double cos_dec = cos(*dec);
double sin_dec = sin(*dec);
double d_ra, d_dec;
/* Near celestial poles, cos(dec) -> 0, dRA diverges. Skip. */
if (fabs(cos_dec) < 1e-12)
return;
d_ra = (-earth_vel_equ[0] * sin_ra + earth_vel_equ[1] * cos_ra)
/ (C_LIGHT_AU_DAY * cos_dec);
d_dec = (-earth_vel_equ[0] * cos_ra * sin_dec
- earth_vel_equ[1] * sin_ra * sin_dec
+ earth_vel_equ[2] * cos_dec)
/ C_LIGHT_AU_DAY;
*ra += d_ra;
*dec += d_dec;
if (*ra < 0.0)
*ra += 2.0 * M_PI;
if (*ra >= 2.0 * M_PI)
*ra -= 2.0 * M_PI;
}
/*
* Geocentric observation pipeline with annual aberration.
*
* Same as observe_from_geocentric() plus aberration correction applied
* in J2000 equatorial coordinates before precessing to date.
*
* earth_vel_ecl: Earth's velocity in ecliptic J2000 (AU/day)
* gets rotated to equatorial internally.
*/
static inline void
observe_from_geocentric_aberrated(const double geo_ecl_au[3], double jd,
const pg_observer *obs,
const double earth_vel_ecl[3],
pg_topocentric *result)
{
double geo_equ[3];
double vel_equ[3];
double ra_j2000, dec_j2000, geo_dist;
double ra_date, dec_date;
double gmst_val, lst, ha;
double az, el;
ecliptic_to_equatorial(geo_ecl_au, geo_equ);
ecliptic_to_equatorial(earth_vel_ecl, vel_equ);
cartesian_to_spherical(geo_equ, &ra_j2000, &dec_j2000, &geo_dist);
apply_annual_aberration(vel_equ, &ra_j2000, &dec_j2000);
precess_j2000_to_date(jd, ra_j2000, dec_j2000, &ra_date, &dec_date);
gmst_val = gmst_from_jd(jd);
lst = gmst_val + obs->lon;
ha = lst - ra_date;
equatorial_to_horizontal(ha, dec_date, obs->lat, &az, &el);
result->azimuth = az;
result->elevation = el;
result->range_km = geo_dist * AU_KM;
result->range_rate = 0.0;
}
/*
* Geocentric ecliptic J2000 (AU) -> equatorial RA/Dec of date, with aberration.
*
* Same as geocentric_to_equatorial() plus annual aberration before precession.
* earth_vel_ecl: Earth's velocity in ecliptic J2000 (AU/day).
*/
static inline void
geocentric_to_equatorial_aberrated(const double geo_ecl_au[3], double jd,
const double earth_vel_ecl[3],
pg_equatorial *result)
{
double geo_equ[3];
double vel_equ[3];
double ra_j2000, dec_j2000, geo_dist;
double ra_date, dec_date;
ecliptic_to_equatorial(geo_ecl_au, geo_equ);
ecliptic_to_equatorial(earth_vel_ecl, vel_equ);
cartesian_to_spherical(geo_equ, &ra_j2000, &dec_j2000, &geo_dist);
apply_annual_aberration(vel_equ, &ra_j2000, &dec_j2000);
precess_j2000_to_date(jd, ra_j2000, dec_j2000, &ra_date, &dec_date);
result->ra = ra_date;
result->dec = dec_date;
result->distance = geo_dist * AU_KM;
}
/*
* TEME position (km) to equatorial RA/Dec, geocentric.
*
* TEME is "True Equator, Mean Equinox" -- approximately the mean
* equatorial frame of date. For geocentric (no observer parallax),
* RA/Dec is just the direction of the position vector in TEME.
* Accuracy vs true-of-date: ~arcsecond (nutation residual).
*/
static inline void
teme_to_equatorial_geo(const double pos_teme[3], pg_equatorial *result)
{
double rm = sqrt(pos_teme[0]*pos_teme[0] +
pos_teme[1]*pos_teme[1] +
pos_teme[2]*pos_teme[2]);
result->dec = asin(pos_teme[2] / rm);
result->ra = atan2(pos_teme[1], pos_teme[0]);
if (result->ra < 0.0)
result->ra += 2.0 * M_PI;
result->distance = rm;
}
/*
* TEME position (km) to equatorial RA/Dec, topocentric (observer-corrected).
*
* Subtracts observer position (via ECEF) to get the observer-relative
* range vector in TEME, then computes RA/Dec from that vector.
* For LEO satellites, the topocentric vs geocentric parallax is ~1 deg.
*
* Lifted from od_math.c:od_teme_to_radec() logic.
*/
static inline void
teme_to_equatorial_topo(const double pos_teme[3], double gmst,
const double obs_ecef[3], pg_equatorial *result)
{
double cg = cos(gmst), sg = sin(gmst);
double pos_ecef[3], range_ecef[3], range_teme[3], rm;
/* TEME -> ECEF */
pos_ecef[0] = cg * pos_teme[0] + sg * pos_teme[1];
pos_ecef[1] = -sg * pos_teme[0] + cg * pos_teme[1];
pos_ecef[2] = pos_teme[2];
/* Observer-relative range in ECEF */
range_ecef[0] = pos_ecef[0] - obs_ecef[0];
range_ecef[1] = pos_ecef[1] - obs_ecef[1];
range_ecef[2] = pos_ecef[2] - obs_ecef[2];
/* Back to TEME (inertial) for RA/Dec */
range_teme[0] = cg * range_ecef[0] - sg * range_ecef[1];
range_teme[1] = sg * range_ecef[0] + cg * range_ecef[1];
range_teme[2] = range_ecef[2];
rm = sqrt(range_teme[0]*range_teme[0] +
range_teme[1]*range_teme[1] +
range_teme[2]*range_teme[2]);
result->dec = asin(range_teme[2] / rm);
result->ra = atan2(range_teme[1], range_teme[0]);
if (result->ra < 0.0)
result->ra += 2.0 * M_PI;
result->distance = rm;
}
#endif /* PG_ORRERY_ASTRO_MATH_H */

View File

@ -27,6 +27,7 @@
#include "eph_provider.h"
#include "vsop87.h"
#include "elp82b.h"
#include "kepler.h"
#include "lambert.h"
#include "l12.h"
#include "tass17.h"
@ -47,6 +48,14 @@ PG_FUNCTION_INFO_V1(galilean_observe_de);
PG_FUNCTION_INFO_V1(saturn_moon_observe_de);
PG_FUNCTION_INFO_V1(uranus_moon_observe_de);
PG_FUNCTION_INFO_V1(mars_moon_observe_de);
PG_FUNCTION_INFO_V1(planet_equatorial_de);
PG_FUNCTION_INFO_V1(moon_equatorial_de);
PG_FUNCTION_INFO_V1(planet_observe_apparent_de);
PG_FUNCTION_INFO_V1(sun_observe_apparent_de);
PG_FUNCTION_INFO_V1(moon_observe_apparent_de);
PG_FUNCTION_INFO_V1(planet_equatorial_apparent_de);
PG_FUNCTION_INFO_V1(moon_equatorial_apparent_de);
PG_FUNCTION_INFO_V1(small_body_observe_apparent_de);
PG_FUNCTION_INFO_V1(pg_orrery_ephemeris_info);
@ -610,6 +619,494 @@ mars_moon_observe_de(PG_FUNCTION_ARGS)
}
/* ================================================================
* planet_equatorial_de(body_id int, timestamptz) -> equatorial
*
* DE variant of planet_equatorial(). STABLE.
* Rule 7: both planet and Earth from the same provider.
* ================================================================
*/
Datum
planet_equatorial_de(PG_FUNCTION_ARGS)
{
int32 body_id = PG_GETARG_INT32(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double earth_xyz[6];
double planet_xyz[6];
double geo_ecl[3];
pg_equatorial *result;
if (body_id < BODY_MERCURY || body_id > BODY_NEPTUNE)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("planet_equatorial_de: body_id %d must be 1-8 (Mercury-Neptune)",
body_id)));
if (body_id == BODY_EARTH)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot observe Earth from Earth")));
jd = timestamptz_to_jd(ts);
/* Rule 7: both planet and Earth from same provider */
if (eph_de_planet(body_id, jd, planet_xyz) &&
eph_de_earth(jd, earth_xyz))
{
/* DE succeeded */
}
else
{
int vsop_body = body_id - 1;
if (eph_de_available())
ereport(NOTICE,
(errmsg("DE ephemeris unavailable for this query, falling back to VSOP87")));
GetVsop87Coor(jd, 2, earth_xyz);
GetVsop87Coor(jd, vsop_body, planet_xyz);
}
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(geo_ecl, jd, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* moon_equatorial_de(timestamptz) -> equatorial
*
* DE variant of moon_equatorial(). STABLE.
* ================================================================
*/
Datum
moon_equatorial_de(PG_FUNCTION_ARGS)
{
int64 ts = PG_GETARG_INT64(0);
double jd;
double moon_ecl[3];
pg_equatorial *result;
jd = timestamptz_to_jd(ts);
if (!eph_de_moon(jd, moon_ecl))
{
if (eph_de_available())
ereport(NOTICE,
(errmsg("DE ephemeris unavailable, falling back to ELP2000-82B")));
GetElp82bCoor(jd, moon_ecl);
}
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(moon_ecl, jd, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* Earth velocity via DE (numerical differentiation) or VSOP87 (analytic).
*
* DE path: central difference (earth(jd+dt) - earth(jd-dt)) / (2*dt)
* VSOP87 path: analytic velocity from earth_xyz[3..5]
*
* use_de: must match the provider used for position (rule 7).
* vel_ecl[3]: output Earth velocity in ecliptic J2000 (AU/day).
* ================================================================
*/
static void
earth_velocity_de(double jd, bool use_de, double vel_ecl[3])
{
if (use_de)
{
double pos_fwd[6], pos_bwd[6];
double dt = 0.01; /* days */
bool got_fwd = eph_de_earth(jd + dt, pos_fwd);
bool got_bwd = eph_de_earth(jd - dt, pos_bwd);
if (got_fwd && got_bwd)
{
vel_ecl[0] = (pos_fwd[0] - pos_bwd[0]) / (2.0 * dt);
vel_ecl[1] = (pos_fwd[1] - pos_bwd[1]) / (2.0 * dt);
vel_ecl[2] = (pos_fwd[2] - pos_bwd[2]) / (2.0 * dt);
return;
}
/* DE boundary straddled — fall through to VSOP87 */
}
{
double earth_xyz[6];
GetVsop87Coor(jd, 2, earth_xyz);
vel_ecl[0] = earth_xyz[3];
vel_ecl[1] = earth_xyz[4];
vel_ecl[2] = earth_xyz[5];
}
}
/* ================================================================
* planet_observe_apparent_de(body_id int, observer, timestamptz) -> topocentric
*
* DE variant of planet_observe_apparent(). STABLE.
* Light-time + annual aberration. Rule 7.
* ================================================================
*/
Datum
planet_observe_apparent_de(PG_FUNCTION_ARGS)
{
int32 body_id = PG_GETARG_INT32(0);
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(1);
int64 ts = PG_GETARG_INT64(2);
double jd;
double earth_xyz[6];
double planet_xyz[6];
double geo_ecl[3];
double geo_dist, tau;
double vel_ecl[3];
pg_topocentric *result;
bool have_de;
if (body_id < BODY_MERCURY || body_id > BODY_NEPTUNE)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("planet_observe_apparent_de: body_id %d must be 1-8 (Mercury-Neptune)",
body_id)));
if (body_id == BODY_EARTH)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot observe Earth from Earth")));
jd = timestamptz_to_jd(ts);
/* Rule 7: both planet and Earth from same provider */
have_de = eph_de_planet(body_id, jd, planet_xyz) &&
eph_de_earth(jd, earth_xyz);
if (!have_de)
{
int vsop_body = body_id - 1;
if (eph_de_available())
ereport(NOTICE,
(errmsg("DE ephemeris unavailable for this query, falling back to VSOP87")));
GetVsop87Coor(jd, 2, earth_xyz);
GetVsop87Coor(jd, vsop_body, planet_xyz);
}
/* Geometric geocentric distance */
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
geo_dist = sqrt(geo_ecl[0]*geo_ecl[0] + geo_ecl[1]*geo_ecl[1] + geo_ecl[2]*geo_ecl[2]);
/* Retarded planet position (same provider) */
tau = geo_dist / C_LIGHT_AU_DAY;
if (have_de)
eph_de_planet(body_id, jd - tau, planet_xyz);
else
GetVsop87Coor(jd - tau, body_id - 1, planet_xyz);
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
/* Earth velocity for aberration */
earth_velocity_de(jd, have_de, vel_ecl);
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric_aberrated(geo_ecl, jd, obs, vel_ecl, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* sun_observe_apparent_de(observer, timestamptz) -> topocentric
*
* DE variant of sun_observe_apparent(). STABLE.
* ================================================================
*/
Datum
sun_observe_apparent_de(PG_FUNCTION_ARGS)
{
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double earth_xyz[6];
double geo_ecl[3];
double vel_ecl[3];
pg_topocentric *result;
bool have_de;
jd = timestamptz_to_jd(ts);
have_de = eph_de_earth(jd, earth_xyz);
if (!have_de)
{
if (eph_de_available())
ereport(NOTICE,
(errmsg("DE ephemeris unavailable, falling back to VSOP87")));
GetVsop87Coor(jd, 2, earth_xyz);
}
geo_ecl[0] = -earth_xyz[0];
geo_ecl[1] = -earth_xyz[1];
geo_ecl[2] = -earth_xyz[2];
earth_velocity_de(jd, have_de, vel_ecl);
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric_aberrated(geo_ecl, jd, obs, vel_ecl, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* moon_observe_apparent_de(observer, timestamptz) -> topocentric
*
* DE variant with light-time + aberration. STABLE.
* Moon position from DE, Earth velocity from DE (numerical) or VSOP87.
* ================================================================
*/
Datum
moon_observe_apparent_de(PG_FUNCTION_ARGS)
{
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double moon_ecl[3];
double geo_dist, tau;
double vel_ecl[3];
pg_topocentric *result;
bool have_de;
jd = timestamptz_to_jd(ts);
have_de = eph_de_moon(jd, moon_ecl);
if (!have_de)
{
if (eph_de_available())
ereport(NOTICE,
(errmsg("DE ephemeris unavailable, falling back to ELP2000-82B")));
GetElp82bCoor(jd, moon_ecl);
}
/* Light-time correction */
geo_dist = sqrt(moon_ecl[0]*moon_ecl[0] + moon_ecl[1]*moon_ecl[1] + moon_ecl[2]*moon_ecl[2]);
tau = geo_dist / C_LIGHT_AU_DAY;
if (have_de)
eph_de_moon(jd - tau, moon_ecl);
else
GetElp82bCoor(jd - tau, moon_ecl);
/* Earth velocity for aberration (DE numerical or VSOP87 analytic) */
earth_velocity_de(jd, have_de, vel_ecl);
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric_aberrated(moon_ecl, jd, obs, vel_ecl, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* planet_equatorial_apparent_de(body_id int, timestamptz) -> equatorial
*
* DE variant with light-time + aberration. STABLE.
* ================================================================
*/
Datum
planet_equatorial_apparent_de(PG_FUNCTION_ARGS)
{
int32 body_id = PG_GETARG_INT32(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double earth_xyz[6];
double planet_xyz[6];
double geo_ecl[3];
double geo_dist, tau;
double vel_ecl[3];
pg_equatorial *result;
bool have_de;
if (body_id < BODY_MERCURY || body_id > BODY_NEPTUNE)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("planet_equatorial_apparent_de: body_id %d must be 1-8 (Mercury-Neptune)",
body_id)));
if (body_id == BODY_EARTH)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot observe Earth from Earth")));
jd = timestamptz_to_jd(ts);
have_de = eph_de_planet(body_id, jd, planet_xyz) &&
eph_de_earth(jd, earth_xyz);
if (!have_de)
{
int vsop_body = body_id - 1;
if (eph_de_available())
ereport(NOTICE,
(errmsg("DE ephemeris unavailable for this query, falling back to VSOP87")));
GetVsop87Coor(jd, 2, earth_xyz);
GetVsop87Coor(jd, vsop_body, planet_xyz);
}
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
geo_dist = sqrt(geo_ecl[0]*geo_ecl[0] + geo_ecl[1]*geo_ecl[1] + geo_ecl[2]*geo_ecl[2]);
tau = geo_dist / C_LIGHT_AU_DAY;
if (have_de)
eph_de_planet(body_id, jd - tau, planet_xyz);
else
GetVsop87Coor(jd - tau, body_id - 1, planet_xyz);
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
earth_velocity_de(jd, have_de, vel_ecl);
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial_aberrated(geo_ecl, jd, vel_ecl, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* moon_equatorial_apparent_de(timestamptz) -> equatorial
*
* DE variant with light-time + aberration. STABLE.
* ================================================================
*/
Datum
moon_equatorial_apparent_de(PG_FUNCTION_ARGS)
{
int64 ts = PG_GETARG_INT64(0);
double jd;
double moon_ecl[3];
double geo_dist, tau;
double vel_ecl[3];
pg_equatorial *result;
bool have_de;
jd = timestamptz_to_jd(ts);
have_de = eph_de_moon(jd, moon_ecl);
if (!have_de)
{
if (eph_de_available())
ereport(NOTICE,
(errmsg("DE ephemeris unavailable, falling back to ELP2000-82B")));
GetElp82bCoor(jd, moon_ecl);
}
geo_dist = sqrt(moon_ecl[0]*moon_ecl[0] + moon_ecl[1]*moon_ecl[1] + moon_ecl[2]*moon_ecl[2]);
tau = geo_dist / C_LIGHT_AU_DAY;
if (have_de)
eph_de_moon(jd - tau, moon_ecl);
else
GetElp82bCoor(jd - tau, moon_ecl);
earth_velocity_de(jd, have_de, vel_ecl);
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial_aberrated(moon_ecl, jd, vel_ecl, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* small_body_observe_apparent_de(orbital_elements, observer, timestamptz) -> topocentric
*
* DE variant of small_body_observe_apparent(). Uses DE for Earth
* position (rule 7 satisfied: body is always Kepler, Earth from
* best available provider). STABLE.
* ================================================================
*/
Datum
small_body_observe_apparent_de(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(1);
int64 ts = PG_GETARG_INT64(2);
double jd;
double body_helio[3];
double earth_xyz[6];
double geo_ecl[3];
double geo_dist, tau;
double vel_ecl[3];
pg_topocentric *result;
bool have_de;
jd = timestamptz_to_jd(ts);
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd, body_helio);
have_de = eph_de_earth(jd, earth_xyz);
if (!have_de)
{
if (eph_de_available())
ereport(NOTICE,
(errmsg("DE ephemeris unavailable, falling back to VSOP87")));
GetVsop87Coor(jd, 2, earth_xyz);
}
geo_ecl[0] = body_helio[0] - earth_xyz[0];
geo_ecl[1] = body_helio[1] - earth_xyz[1];
geo_ecl[2] = body_helio[2] - earth_xyz[2];
geo_dist = sqrt(geo_ecl[0]*geo_ecl[0] + geo_ecl[1]*geo_ecl[1] + geo_ecl[2]*geo_ecl[2]);
tau = geo_dist / C_LIGHT_AU_DAY;
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd - tau, body_helio);
geo_ecl[0] = body_helio[0] - earth_xyz[0];
geo_ecl[1] = body_helio[1] - earth_xyz[1];
geo_ecl[2] = body_helio[2] - earth_xyz[2];
earth_velocity_de(jd, have_de, vel_ecl);
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric_aberrated(geo_ecl, jd, obs, vel_ecl, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* pg_orrery_ephemeris_info() -> RECORD
*

436
src/equatorial_funcs.c Normal file
View File

@ -0,0 +1,436 @@
/*
* equatorial_funcs.c -- Equatorial coordinate type and observation functions
*
* Provides the equatorial PostgreSQL type (RA/Dec/distance) and functions
* to compute equatorial coordinates for satellites, planets, Sun, Moon,
* small bodies, and stars.
*
* The type stores 3 doubles (24 bytes): ra (radians), dec (radians),
* distance (km). RA is displayed in hours [0,24), dec in degrees [-90,90].
*
* Two satellite paths:
* - Topocentric (with observer): TEME -> ECEF -> subtract observer -> back
* to TEME -> RA/Dec from the range vector.
* - Geocentric (no observer): RA/Dec is the direction of the TEME position.
*
* Solar system path: VSOP87/ELP82B/Kepler heliocentric ecliptic J2000 ->
* subtract Earth -> ecliptic-to-equatorial -> precess to date.
*/
#include "postgres.h"
#include "fmgr.h"
#include "utils/timestamp.h"
#include "utils/builtins.h"
#include "libpq/pqformat.h"
#include "types.h"
#include "astro_math.h"
#include "vsop87.h"
#include "elp82b.h"
#include <math.h>
/* Type I/O */
PG_FUNCTION_INFO_V1(equatorial_in);
PG_FUNCTION_INFO_V1(equatorial_out);
PG_FUNCTION_INFO_V1(equatorial_recv);
PG_FUNCTION_INFO_V1(equatorial_send);
/* Accessors */
PG_FUNCTION_INFO_V1(eq_ra);
PG_FUNCTION_INFO_V1(eq_dec);
PG_FUNCTION_INFO_V1(eq_distance);
/* Computation functions */
PG_FUNCTION_INFO_V1(eci_to_equatorial);
PG_FUNCTION_INFO_V1(eci_to_equatorial_geo);
PG_FUNCTION_INFO_V1(planet_equatorial);
PG_FUNCTION_INFO_V1(sun_equatorial);
PG_FUNCTION_INFO_V1(moon_equatorial);
/* Angular distance and cone search */
PG_FUNCTION_INFO_V1(eq_angular_distance);
PG_FUNCTION_INFO_V1(eq_within_cone);
/* ----------------------------------------------------------------
* Static helper -- observer geodetic to ECEF.
*
* Duplicated from pass_funcs.c / coord_funcs.c because both files
* define it as static. Too small to warrant a shared module, and
* keeping it static preserves the no-cross-TU-symbol convention.
* ----------------------------------------------------------------
*/
static void
observer_to_ecef(const pg_observer *obs, double *ecef)
{
double sinlat = sin(obs->lat);
double coslat = cos(obs->lat);
double sinlon = sin(obs->lon);
double coslon = cos(obs->lon);
double N; /* radius of curvature in the prime vertical */
double alt_km = obs->alt_m / 1000.0;
N = WGS84_A / sqrt(1.0 - WGS84_E2 * sinlat * sinlat);
ecef[0] = (N + alt_km) * coslat * coslon;
ecef[1] = (N + alt_km) * coslat * sinlon;
ecef[2] = (N * (1.0 - WGS84_E2) + alt_km) * sinlat;
}
/* ================================================================
* Type I/O
*
* Text format: (ra_hours, dec_degrees, distance_km)
*
* RA displayed in hours [0,24), stored in radians [0, 2*pi).
* Dec displayed in degrees [-90,90], stored in radians.
* Distance in km.
* ================================================================
*/
Datum
equatorial_in(PG_FUNCTION_ARGS)
{
char *str = PG_GETARG_CSTRING(0);
pg_equatorial *result;
double ra_hours, dec_deg, distance;
int nfields;
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
nfields = sscanf(str, " ( %lf , %lf , %lf )",
&ra_hours, &dec_deg, &distance);
if (nfields != 3)
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid input syntax for type equatorial: \"%s\"", str),
errhint("Expected (ra_hours,dec_degrees,distance_km).")));
if (ra_hours < 0.0 || ra_hours >= 24.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("right ascension out of range: %.6f", ra_hours),
errhint("RA must be in [0, 24) hours.")));
if (dec_deg < -90.0 || dec_deg > 90.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("declination out of range: %.6f", dec_deg),
errhint("Declination must be between -90 and +90 degrees.")));
result->ra = ra_hours * (M_PI / 12.0);
result->dec = dec_deg * DEG_TO_RAD;
result->distance = distance;
PG_RETURN_POINTER(result);
}
Datum
equatorial_out(PG_FUNCTION_ARGS)
{
pg_equatorial *eq = (pg_equatorial *) PG_GETARG_POINTER(0);
PG_RETURN_CSTRING(psprintf("(%.8f,%.8f,%.3f)",
eq->ra * (12.0 / M_PI),
eq->dec * RAD_TO_DEG,
eq->distance));
}
Datum
equatorial_recv(PG_FUNCTION_ARGS)
{
StringInfo buf = (StringInfo) PG_GETARG_POINTER(0);
pg_equatorial *result;
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
result->ra = pq_getmsgfloat8(buf);
result->dec = pq_getmsgfloat8(buf);
result->distance = pq_getmsgfloat8(buf);
PG_RETURN_POINTER(result);
}
Datum
equatorial_send(PG_FUNCTION_ARGS)
{
pg_equatorial *eq = (pg_equatorial *) PG_GETARG_POINTER(0);
StringInfoData buf;
pq_begintypsend(&buf);
pq_sendfloat8(&buf, eq->ra);
pq_sendfloat8(&buf, eq->dec);
pq_sendfloat8(&buf, eq->distance);
PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
}
/* ================================================================
* Accessor functions
*
* RA returns hours, Dec returns degrees (matching display convention).
* ================================================================
*/
Datum
eq_ra(PG_FUNCTION_ARGS)
{
pg_equatorial *eq = (pg_equatorial *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(eq->ra * (12.0 / M_PI));
}
Datum
eq_dec(PG_FUNCTION_ARGS)
{
pg_equatorial *eq = (pg_equatorial *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(eq->dec * RAD_TO_DEG);
}
Datum
eq_distance(PG_FUNCTION_ARGS)
{
pg_equatorial *eq = (pg_equatorial *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(eq->distance);
}
/* ================================================================
* eci_to_equatorial(eci_position, observer, timestamptz) -> equatorial
*
* Topocentric satellite RA/Dec. Subtracts observer parallax via
* ECEF round-trip, then extracts RA/Dec from the range vector.
* For LEO, topocentric vs geocentric parallax is ~1 degree.
* ================================================================
*/
Datum
eci_to_equatorial(PG_FUNCTION_ARGS)
{
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(1);
int64 ts = PG_GETARG_INT64(2);
double jd;
double pos_teme[3];
double obs_ecef[3];
double gmst;
pg_equatorial *result;
jd = timestamptz_to_jd(ts);
gmst = gmst_from_jd(jd);
pos_teme[0] = eci->x;
pos_teme[1] = eci->y;
pos_teme[2] = eci->z;
observer_to_ecef(obs, obs_ecef);
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
teme_to_equatorial_topo(pos_teme, gmst, obs_ecef, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* eci_to_equatorial_geo(eci_position, timestamptz) -> equatorial
*
* Geocentric satellite RA/Dec. No observer subtraction -- the
* position vector direction in TEME is the RA/Dec.
* ================================================================
*/
Datum
eci_to_equatorial_geo(PG_FUNCTION_ARGS)
{
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
int64 ts = PG_GETARG_INT64(1);
double pos_teme[3];
pg_equatorial *result;
/* ts used to establish the equatorial frame; teme_to_equatorial_geo()
* doesn't need jd because TEME is already ~equatorial of date. */
(void) ts;
pos_teme[0] = eci->x;
pos_teme[1] = eci->y;
pos_teme[2] = eci->z;
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
teme_to_equatorial_geo(pos_teme, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* planet_equatorial(body_id int, timestamptz) -> equatorial
*
* Geocentric RA/Dec of a planet via VSOP87.
* Body IDs: 1=Mercury, ..., 8=Neptune (not Sun, Earth, or Moon).
* ================================================================
*/
Datum
planet_equatorial(PG_FUNCTION_ARGS)
{
int32 body_id = PG_GETARG_INT32(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double earth_xyz[6];
double planet_xyz[6];
double geo_ecl[3];
int vsop_body;
pg_equatorial *result;
if (body_id < BODY_MERCURY || body_id > BODY_NEPTUNE)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("planet_equatorial: body_id %d must be 1-8 (Mercury-Neptune)",
body_id)));
if (body_id == BODY_EARTH)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot observe Earth from Earth")));
jd = timestamptz_to_jd(ts);
/* Earth's heliocentric position */
GetVsop87Coor(jd, 2, earth_xyz); /* VSOP87 body 2 = Earth */
/* Target planet heliocentric position */
vsop_body = body_id - 1;
GetVsop87Coor(jd, vsop_body, planet_xyz);
/* Geocentric ecliptic = planet - Earth */
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(geo_ecl, jd, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* sun_equatorial(timestamptz) -> equatorial
*
* Geocentric RA/Dec of the Sun. The Sun's geocentric position is
* the negation of Earth's heliocentric VSOP87 position.
* ================================================================
*/
Datum
sun_equatorial(PG_FUNCTION_ARGS)
{
int64 ts = PG_GETARG_INT64(0);
double jd;
double earth_xyz[6];
double geo_ecl[3];
pg_equatorial *result;
jd = timestamptz_to_jd(ts);
GetVsop87Coor(jd, 2, earth_xyz);
geo_ecl[0] = -earth_xyz[0];
geo_ecl[1] = -earth_xyz[1];
geo_ecl[2] = -earth_xyz[2];
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(geo_ecl, jd, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* moon_equatorial(timestamptz) -> equatorial
*
* Geocentric RA/Dec of the Moon via ELP2000-82B.
* ELP returns geocentric ecliptic J2000 in AU -- no Earth subtraction.
* ================================================================
*/
Datum
moon_equatorial(PG_FUNCTION_ARGS)
{
int64 ts = PG_GETARG_INT64(0);
double jd;
double moon_ecl[3];
pg_equatorial *result;
jd = timestamptz_to_jd(ts);
GetElp82bCoor(jd, moon_ecl);
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(moon_ecl, jd, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* eq_angular_distance(equatorial, equatorial) -> float8
*
* Angular separation in degrees between two equatorial positions.
* Uses the Vincenty formula (numerically stable at all separations
* including 0 and 180 degrees, unlike the haversine or dot product).
*
* Vincenty: atan2(num, den) where
* num = sqrt((cos d2 sin dRA)^2 + (cos d1 sin d2 - sin d1 cos d2 cos dRA)^2)
* den = sin d1 sin d2 + cos d1 cos d2 cos dRA
* ================================================================
*/
Datum
eq_angular_distance(PG_FUNCTION_ARGS)
{
pg_equatorial *a = (pg_equatorial *) PG_GETARG_POINTER(0);
pg_equatorial *b = (pg_equatorial *) PG_GETARG_POINTER(1);
double d_ra, cos_d_ra, sin_d_ra;
double sin_d1, cos_d1, sin_d2, cos_d2;
double num1, num2, num, den;
d_ra = b->ra - a->ra;
cos_d_ra = cos(d_ra);
sin_d_ra = sin(d_ra);
sin_d1 = sin(a->dec);
cos_d1 = cos(a->dec);
sin_d2 = sin(b->dec);
cos_d2 = cos(b->dec);
num1 = cos_d2 * sin_d_ra;
num2 = cos_d1 * sin_d2 - sin_d1 * cos_d2 * cos_d_ra;
num = sqrt(num1 * num1 + num2 * num2);
den = sin_d1 * sin_d2 + cos_d1 * cos_d2 * cos_d_ra;
PG_RETURN_FLOAT8(atan2(num, den) * RAD_TO_DEG);
}
/* ================================================================
* eq_within_cone(equatorial, equatorial, float8) -> bool
*
* Returns true if the first position is within `radius_deg` degrees
* of the second position. Cosine shortcut avoids the expensive
* atan2 in the Vincenty formula for most reject cases.
* ================================================================
*/
Datum
eq_within_cone(PG_FUNCTION_ARGS)
{
pg_equatorial *a = (pg_equatorial *) PG_GETARG_POINTER(0);
pg_equatorial *b = (pg_equatorial *) PG_GETARG_POINTER(1);
double radius = PG_GETARG_FLOAT8(2);
double cos_r, d_ra, cos_sep;
if (radius < 0.0)
PG_RETURN_BOOL(false);
cos_r = cos(radius * DEG_TO_RAD);
d_ra = b->ra - a->ra;
cos_sep = sin(a->dec) * sin(b->dec)
+ cos(a->dec) * cos(b->dec) * cos(d_ra);
PG_RETURN_BOOL(cos_sep >= cos_r);
}

View File

@ -44,6 +44,12 @@ PG_FUNCTION_INFO_V1(gist_tle_distance);
/* Floating-point comparison tolerance (km and radians) */
#define KEY_EPSILON 1.0e-9
/* Domain widths for normalizing penalty across dimensions */
#define ALT_DOMAIN 50000.0 /* km — covers GEO + HEO margins */
#define INC_DOMAIN M_PI /* radians — full inclination range */
/* sizeof(pg_tle) == 112, matching INTERNALLENGTH in CREATE TYPE. */
/*
* 2-D orbital key extracted from a TLE's mean elements.
* Altitude band (perigee/apogee) plus inclination range.
@ -154,7 +160,7 @@ key_merge(tle_orbital_key *dst, const tle_orbital_key *src)
* alt_separation -- minimum altitude gap between two keys
*
* Returns 0 if the altitude bands overlap.
* Used for KNN distance (altitude-dominant ordering).
* Used as one component of the 2-D orbital distance metric.
* ----------------------------------------------------------------
*/
static inline double
@ -168,6 +174,48 @@ alt_separation(const tle_orbital_key *a, const tle_orbital_key *b)
}
/* ----------------------------------------------------------------
* inc_separation -- minimum inclination gap between two keys
*
* Analogous to alt_separation, but for the inclination dimension.
* Returns 0 if the inclination ranges overlap.
* ----------------------------------------------------------------
*/
static inline double
inc_separation(const tle_orbital_key *a, const tle_orbital_key *b)
{
if (a->inc_high < b->inc_low)
return b->inc_low - a->inc_high;
if (b->inc_high < a->inc_low)
return a->inc_low - b->inc_high;
return 0.0;
}
/* ----------------------------------------------------------------
* orbital_distance -- 2-D distance combining altitude and inclination
*
* Converts the inclination gap (radians) to km via Earth radius,
* then returns L2 norm. At Earth's surface, 1 radian of inclination
* difference corresponds to ~6378 km of cross-track separation.
*
* For internal nodes, both alt_separation and inc_separation are
* lower bounds on the gap to any child. Since sqrt(a^2 + b^2) is
* monotonically increasing, the L2 combination is also a valid
* lower bound satisfying GiST's distance contract.
* ----------------------------------------------------------------
*/
static inline double
orbital_distance(const tle_orbital_key *a, const tle_orbital_key *b)
{
double alt_gap = alt_separation(a, b);
double inc_gap = inc_separation(a, b);
double inc_km = inc_gap * WGS72_AE; /* radians → km via Earth radius */
return sqrt(alt_gap * alt_gap + inc_km * inc_km);
}
/* ================================================================
* SQL-callable operators
* ================================================================
@ -198,13 +246,12 @@ tle_overlap(PG_FUNCTION_ARGS)
/*
* tle_alt_distance(tle, tle) -> float8 [the <-> operator]
*
* Minimum altitude-band separation in km. Returns 0 if the bands
* overlap. This is not the physical distance between the objects --
* it is the gap between their orbital shells, useful for ordering
* nearest-neighbor queries without propagation.
* 2-D orbital distance in km: combines altitude-band separation
* with inclination gap (converted to km via Earth radius).
* Returns 0 only if both altitude bands AND inclination ranges overlap.
*
* Altitude-only: inclination weighting adds complexity without
* meaningful benefit for KNN conjunction screening.
* The C symbol name is kept as tle_alt_distance to avoid SQL migration
* churn the SQL FUNCTION and OPERATOR definitions are unchanged.
*/
Datum
tle_alt_distance(PG_FUNCTION_ARGS)
@ -216,7 +263,7 @@ tle_alt_distance(PG_FUNCTION_ARGS)
tle_to_orbital_key(a, &ka);
tle_to_orbital_key(b, &kb);
PG_RETURN_FLOAT8(alt_separation(&ka, &kb));
PG_RETURN_FLOAT8(orbital_distance(&ka, &kb));
}
@ -231,6 +278,10 @@ tle_alt_distance(PG_FUNCTION_ARGS)
*
* Leaf entries carry the full pg_tle; we compress to tle_orbital_key.
* Internal entries are already tle_orbital_key from union operations.
*
* The allocation must be sizeof(pg_tle) bytes which matches
* INTERNALLENGTH not sizeof(tle_orbital_key). GiST's
* index_form_tuple() copies typlen bytes from the datum pointer.
*/
Datum
gist_tle_compress(PG_FUNCTION_ARGS)
@ -241,7 +292,7 @@ gist_tle_compress(PG_FUNCTION_ARGS)
if (entry->leafkey)
{
pg_tle *tle = (pg_tle *) DatumGetPointer(entry->key);
tle_orbital_key *key = (tle_orbital_key *) palloc(sizeof(tle_orbital_key));
tle_orbital_key *key = (tle_orbital_key *) palloc0(sizeof(pg_tle));
tle_to_orbital_key(tle, key);
@ -273,8 +324,10 @@ gist_tle_decompress(PG_FUNCTION_ARGS)
* gist_tle_consistent -- can this subtree contain matches for the query?
*
* Checks overlap in both altitude AND inclination dimensions.
* Always sets recheck = true because 2-D overlap is only a necessary
* condition -- the real conjunction test requires propagation.
*
* For leaf entries, recheck = false: the orbital key is computed
* identically to the SQL operator, so the GiST check is exact.
* For internal nodes, recheck is irrelevant (GiST ignores it).
*/
Datum
gist_tle_consistent(PG_FUNCTION_ARGS)
@ -290,7 +343,12 @@ gist_tle_consistent(PG_FUNCTION_ARGS)
tle_to_orbital_key(query, &query_key);
*recheck = true;
/*
* Leaf keys are exact (same tle_to_orbital_key as the operator),
* so no recheck needed. For internal nodes PostgreSQL ignores
* the flag, but we set true by convention.
*/
*recheck = !GIST_LEAF(entry);
switch (strategy)
{
@ -298,20 +356,6 @@ gist_tle_consistent(PG_FUNCTION_ARGS)
result = key_overlaps(key, &query_key);
break;
case RTContainedByStrategyNumber: /* <@ */
if (GIST_LEAF(entry))
result = key_contains(&query_key, key);
else
result = key_overlaps(key, &query_key);
break;
case RTContainsStrategyNumber: /* @> */
if (GIST_LEAF(entry))
result = key_contains(key, &query_key);
else
result = key_overlaps(key, &query_key);
break;
default:
elog(ERROR, "gist_tle_consistent: unrecognized strategy %d",
strategy);
@ -327,6 +371,9 @@ gist_tle_consistent(PG_FUNCTION_ARGS)
* gist_tle_union -- compute 2-D bounding box for a set of entries
*
* The union is [min(alt_low), max(alt_high)] x [min(inc_low), max(inc_high)].
*
* The entry vector is 0-based: valid indices are 0 .. entryvec->n - 1.
* This differs from picksplit's 1-based convention.
*/
Datum
gist_tle_union(PG_FUNCTION_ARGS)
@ -337,7 +384,7 @@ gist_tle_union(PG_FUNCTION_ARGS)
tle_orbital_key *result;
tle_orbital_key *cur;
result = (tle_orbital_key *) palloc(sizeof(tle_orbital_key));
result = (tle_orbital_key *) palloc0(sizeof(pg_tle));
cur = (tle_orbital_key *) DatumGetPointer(entryvec->vector[0].key);
*result = *cur;
@ -347,7 +394,7 @@ gist_tle_union(PG_FUNCTION_ARGS)
key_merge(result, cur);
}
*sizep = sizeof(tle_orbital_key);
*sizep = sizeof(pg_tle);
PG_RETURN_POINTER(result);
}
@ -372,10 +419,10 @@ gist_tle_penalty(PG_FUNCTION_ARGS)
double orig_margin;
double merged_margin;
orig_margin = (orig->alt_high - orig->alt_low)
+ (orig->inc_high - orig->inc_low);
merged_margin = (fmax(orig->alt_high, add->alt_high) - fmin(orig->alt_low, add->alt_low))
+ (fmax(orig->inc_high, add->inc_high) - fmin(orig->inc_low, add->inc_low));
orig_margin = (orig->alt_high - orig->alt_low) / ALT_DOMAIN
+ (orig->inc_high - orig->inc_low) / INC_DOMAIN;
merged_margin = (fmax(orig->alt_high, add->alt_high) - fmin(orig->alt_low, add->alt_low)) / ALT_DOMAIN
+ (fmax(orig->inc_high, add->inc_high) - fmin(orig->inc_low, add->inc_low)) / INC_DOMAIN;
*penalty = (float)(merged_margin - orig_margin);
@ -413,35 +460,44 @@ picksplit_cmp(const void *a, const void *b)
* Standard R-tree approach: compute spread in both dimensions, split
* along whichever dimension has the greater spread. This prevents
* the tree from becoming biased toward one dimension.
*
* GiST convention for picksplit: entryvec->vector[] is 1-based
* (FirstOffsetNumber), vector[0] is unused/uninitialized.
* entryvec->n includes the unused slot, so the actual entry count
* is (entryvec->n - 1) and valid indices are
* FirstOffsetNumber .. entryvec->n - 1. The OffsetNumbers placed
* into spl_left[] and spl_right[] must also be 1-based.
*/
Datum
gist_tle_picksplit(PG_FUNCTION_ARGS)
{
GistEntryVector *entryvec = (GistEntryVector *) PG_GETARG_POINTER(0);
GIST_SPLITVEC *splitvec = (GIST_SPLITVEC *) PG_GETARG_POINTER(1);
int nentries = entryvec->n;
OffsetNumber maxoff = entryvec->n - 1;
int nentries = maxoff - FirstOffsetNumber + 1;
picksplit_item *items;
tle_orbital_key *left_union;
tle_orbital_key *right_union;
tle_orbital_key *cur;
int split_at;
int i;
OffsetNumber off;
double alt_min, alt_max, inc_min, inc_max;
double alt_spread, inc_spread;
bool split_on_alt;
/* First pass: compute spread in both dimensions */
cur = (tle_orbital_key *) DatumGetPointer(entryvec->vector[0].key);
cur = (tle_orbital_key *) DatumGetPointer(entryvec->vector[FirstOffsetNumber].key);
alt_min = (cur->alt_low + cur->alt_high) / 2.0;
alt_max = alt_min;
inc_min = (cur->inc_low + cur->inc_high) / 2.0;
inc_max = inc_min;
for (i = 1; i < nentries; i++)
for (off = FirstOffsetNumber + 1; off <= maxoff; off++)
{
double alt_mid, inc_mid;
cur = (tle_orbital_key *) DatumGetPointer(entryvec->vector[i].key);
cur = (tle_orbital_key *) DatumGetPointer(entryvec->vector[off].key);
alt_mid = (cur->alt_low + cur->alt_high) / 2.0;
inc_mid = (cur->inc_low + cur->inc_high) / 2.0;
@ -462,10 +518,10 @@ gist_tle_picksplit(PG_FUNCTION_ARGS)
/* Second pass: compute sort values in the chosen dimension */
items = (picksplit_item *) palloc(sizeof(picksplit_item) * nentries);
for (i = 0; i < nentries; i++)
for (i = 0, off = FirstOffsetNumber; off <= maxoff; i++, off++)
{
cur = (tle_orbital_key *) DatumGetPointer(entryvec->vector[i].key);
items[i].index = i;
cur = (tle_orbital_key *) DatumGetPointer(entryvec->vector[off].key);
items[i].index = off; /* store 1-based OffsetNumber directly */
if (split_on_alt)
items[i].sortval = (cur->alt_low + cur->alt_high) / 2.0;
else
@ -476,15 +532,15 @@ gist_tle_picksplit(PG_FUNCTION_ARGS)
split_at = nentries / 2;
/* Allocate offset arrays (GiST uses OffsetNumber, 1-based) */
/* Allocate offset arrays */
splitvec->spl_left = (OffsetNumber *) palloc(sizeof(OffsetNumber) * nentries);
splitvec->spl_right = (OffsetNumber *) palloc(sizeof(OffsetNumber) * nentries);
splitvec->spl_nleft = 0;
splitvec->spl_nright = 0;
/* Compute union keys and assign entries */
left_union = (tle_orbital_key *) palloc(sizeof(tle_orbital_key));
right_union = (tle_orbital_key *) palloc(sizeof(tle_orbital_key));
left_union = (tle_orbital_key *) palloc0(sizeof(pg_tle));
right_union = (tle_orbital_key *) palloc0(sizeof(pg_tle));
/* Seed the unions from the first entry in each half */
cur = (tle_orbital_key *) DatumGetPointer(
@ -497,21 +553,19 @@ gist_tle_picksplit(PG_FUNCTION_ARGS)
for (i = 0; i < nentries; i++)
{
int idx = items[i].index;
OffsetNumber idx = items[i].index; /* already 1-based */
cur = (tle_orbital_key *) DatumGetPointer(
entryvec->vector[idx].key);
if (i < split_at)
{
splitvec->spl_left[splitvec->spl_nleft++] =
(OffsetNumber)(idx + 1); /* 1-based */
splitvec->spl_left[splitvec->spl_nleft++] = idx;
key_merge(left_union, cur);
}
else
{
splitvec->spl_right[splitvec->spl_nright++] =
(OffsetNumber)(idx + 1);
splitvec->spl_right[splitvec->spl_nright++] = idx;
key_merge(right_union, cur);
}
}
@ -550,12 +604,13 @@ gist_tle_same(PG_FUNCTION_ARGS)
/*
* gist_tle_distance -- GiST distance function for KNN ordering
*
* Returns the minimum altitude-band separation in km.
* For overlapping ranges this is 0, making the entry a candidate.
* The planner uses this to drive ORDER BY <-> queries.
* Returns the 2-D orbital distance: L2 norm of altitude separation
* and inclination gap (in km). For entries where both dimensions
* overlap this is 0, making the entry a candidate.
*
* Altitude-only: conjunction screening is altitude-dominant.
* Inclination weighting can be added later if needed.
* Both alt_separation and inc_separation are lower bounds for
* internal nodes, so the L2 combination is also a valid lower
* bound satisfying GiST's distance contract for correct KNN.
*/
Datum
gist_tle_distance(PG_FUNCTION_ARGS)
@ -568,5 +623,5 @@ gist_tle_distance(PG_FUNCTION_ARGS)
tle_to_orbital_key(query, &query_key);
PG_RETURN_FLOAT8(alt_separation(key, &query_key));
PG_RETURN_FLOAT8(orbital_distance(key, &query_key));
}

24
src/kepler.h Normal file
View File

@ -0,0 +1,24 @@
/*
* kepler.h -- Two-body Keplerian position from classical elements
*
* Exposes kepler_position() for use by orbital_elements_type.c
* and any future code that needs raw Keplerian propagation.
*/
#ifndef PG_ORRERY_KEPLER_H
#define PG_ORRERY_KEPLER_H
/*
* Two-body Keplerian position from classical orbital elements.
*
* q (AU), e, inc/omega/Omega (radians), T_peri (JD), jd (JD).
* Output: pos[3] in AU, ecliptic J2000 frame.
*
* Handles elliptic (e<0.99), near-parabolic (0.99<=e<=1.01),
* and hyperbolic (e>1.01) orbits.
*/
extern void kepler_position(double q, double e, double inc,
double omega, double Omega,
double T_peri, double jd, double pos[3]);
#endif /* PG_ORRERY_KEPLER_H */

View File

@ -17,6 +17,7 @@
#include "libpq/pqformat.h"
#include "types.h"
#include "astro_math.h"
#include "kepler.h"
#include <math.h>
/* Heliocentric type I/O */
@ -120,7 +121,7 @@ solve_kepler_parabolic(double M)
* Output: pos[3] in AU, ecliptic J2000 frame
* ================================================================
*/
static void
void
kepler_position(double q, double e, double inc, double omega, double Omega,
double T_peri, double jd, double pos[3])
{

683
src/orbital_elements_type.c Normal file
View File

@ -0,0 +1,683 @@
/*
* orbital_elements_type.c -- Classical Keplerian orbital elements type
*
* Provides the orbital_elements PostgreSQL type for comets and asteroids,
* a parser for MPC MPCORB.DAT fixed-width format, and small_body_observe()
* / small_body_heliocentric() which auto-fetch Earth's VSOP87 position.
*
* The type stores 9 doubles (72 bytes): epoch, q, e, inc, arg_peri, raan,
* tp, h_mag, g_slope. Angular elements stored in radians, displayed in
* degrees (same convention as the tle type).
*/
#include "postgres.h"
#include "fmgr.h"
#include "utils/timestamp.h"
#include "utils/builtins.h"
#include "libpq/pqformat.h"
#include "types.h"
#include "astro_math.h"
#include "kepler.h"
#include "vsop87.h"
#include <math.h>
#include <ctype.h>
/* Type I/O */
PG_FUNCTION_INFO_V1(orbital_elements_in);
PG_FUNCTION_INFO_V1(orbital_elements_out);
PG_FUNCTION_INFO_V1(orbital_elements_recv);
PG_FUNCTION_INFO_V1(orbital_elements_send);
/* Accessors */
PG_FUNCTION_INFO_V1(oe_epoch);
PG_FUNCTION_INFO_V1(oe_perihelion);
PG_FUNCTION_INFO_V1(oe_eccentricity);
PG_FUNCTION_INFO_V1(oe_inclination);
PG_FUNCTION_INFO_V1(oe_arg_perihelion);
PG_FUNCTION_INFO_V1(oe_raan);
PG_FUNCTION_INFO_V1(oe_tp);
PG_FUNCTION_INFO_V1(oe_h_mag);
PG_FUNCTION_INFO_V1(oe_g_slope);
PG_FUNCTION_INFO_V1(oe_semi_major_axis);
PG_FUNCTION_INFO_V1(oe_period_years);
/* MPC parser */
PG_FUNCTION_INFO_V1(oe_from_mpc);
/* Observation functions */
PG_FUNCTION_INFO_V1(small_body_heliocentric);
PG_FUNCTION_INFO_V1(small_body_observe);
PG_FUNCTION_INFO_V1(small_body_observe_apparent);
PG_FUNCTION_INFO_V1(small_body_equatorial);
PG_FUNCTION_INFO_V1(small_body_equatorial_apparent);
/* ================================================================
* MPC packed date decoding
*
* MPC format: 5 characters, e.g. "K24AM"
* [0] = century: I=1800, J=1900, K=2000
* [1-2] = year within century (00-99)
* [3] = month: 1-9 or A=10, B=11, C=12
* [4] = day: 1-9 or A=10, ..., V=31
*
* Returns Julian date at 0h UTC of that date.
* ================================================================
*/
static double
mpc_packed_to_jd(const char *p)
{
int year, month, day;
int a, b;
double jd;
switch (p[0])
{
case 'I': year = 1800; break;
case 'J': year = 1900; break;
case 'K': year = 2000; break;
default:
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid MPC century code '%c'", p[0])));
return 0.0; /* unreachable */
}
year += (p[1] - '0') * 10 + (p[2] - '0');
/* Month: 1-9 as digit, A=10, B=11, C=12 */
if (p[3] >= '1' && p[3] <= '9')
month = p[3] - '0';
else if (p[3] >= 'A' && p[3] <= 'C')
month = p[3] - 'A' + 10;
else
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid MPC month code '%c'", p[3])));
/* Day: 1-9 as digit, A=10, ..., V=31 */
if (p[4] >= '1' && p[4] <= '9')
day = p[4] - '0';
else if (p[4] >= 'A' && p[4] <= 'V')
day = p[4] - 'A' + 10;
else
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid MPC day code '%c'", p[4])));
/* Gregorian calendar -> JD (Meeus, Astronomical Algorithms, Ch. 7) */
if (month <= 2)
{
year -= 1;
month += 12;
}
a = year / 100;
b = 2 - a + a / 4;
jd = floor(365.25 * (year + 4716)) + floor(30.6001 * (month + 1))
+ day + b - 1524.5;
return jd;
}
/*
* Parse a float from a fixed-width substring.
* Returns NaN if the field is blank/whitespace.
*/
static double
parse_mpc_field(const char *line, int start, int len)
{
char buf[32];
char *end;
double val;
int i;
bool all_blank = true;
if (len > 31)
len = 31;
memcpy(buf, line + start, len);
buf[len] = '\0';
for (i = 0; i < len; i++)
{
if (!isspace((unsigned char)buf[i]))
{
all_blank = false;
break;
}
}
if (all_blank)
return NAN;
val = strtod(buf, &end);
if (end == buf)
return NAN;
return val;
}
/* ================================================================
* Type I/O
*
* Text format: (epoch_jd, q_au, e, inc_deg, omega_deg, Omega_deg,
* tp_jd, H, G)
*
* Angles are displayed in degrees, stored in radians.
* H and G display as "NaN" if unknown.
* ================================================================
*/
Datum
orbital_elements_in(PG_FUNCTION_ARGS)
{
char *str = PG_GETARG_CSTRING(0);
pg_orbital_elements *result;
double epoch, q, e, inc_d, omega_d, Omega_d, tp, h, g;
int nfields;
result = (pg_orbital_elements *) palloc(sizeof(pg_orbital_elements));
nfields = sscanf(str, " ( %lf , %lf , %lf , %lf , %lf , %lf , %lf , %lf , %lf )",
&epoch, &q, &e, &inc_d, &omega_d, &Omega_d, &tp, &h, &g);
if (nfields != 9)
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid input syntax for type orbital_elements: \"%s\"", str),
errhint("Expected (epoch_jd,q_au,e,inc_deg,omega_deg,Omega_deg,tp_jd,H,G).")));
if (q <= 0.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("perihelion distance must be positive: %.6f", q)));
if (e < 0.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("eccentricity must be non-negative: %.6f", e)));
result->epoch = epoch;
result->q = q;
result->e = e;
result->inc = inc_d * DEG_TO_RAD;
result->arg_peri = omega_d * DEG_TO_RAD;
result->raan = Omega_d * DEG_TO_RAD;
result->tp = tp;
result->h_mag = h;
result->g_slope = g;
PG_RETURN_POINTER(result);
}
Datum
orbital_elements_out(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_CSTRING(psprintf("(%.6f,%.10f,%.10f,%.6f,%.6f,%.6f,%.6f,%.2f,%.2f)",
oe->epoch,
oe->q,
oe->e,
oe->inc * RAD_TO_DEG,
oe->arg_peri * RAD_TO_DEG,
oe->raan * RAD_TO_DEG,
oe->tp,
oe->h_mag,
oe->g_slope));
}
Datum
orbital_elements_recv(PG_FUNCTION_ARGS)
{
StringInfo buf = (StringInfo) PG_GETARG_POINTER(0);
pg_orbital_elements *result;
result = (pg_orbital_elements *) palloc(sizeof(pg_orbital_elements));
result->epoch = pq_getmsgfloat8(buf);
result->q = pq_getmsgfloat8(buf);
result->e = pq_getmsgfloat8(buf);
result->inc = pq_getmsgfloat8(buf);
result->arg_peri = pq_getmsgfloat8(buf);
result->raan = pq_getmsgfloat8(buf);
result->tp = pq_getmsgfloat8(buf);
result->h_mag = pq_getmsgfloat8(buf);
result->g_slope = pq_getmsgfloat8(buf);
PG_RETURN_POINTER(result);
}
Datum
orbital_elements_send(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
StringInfoData buf;
pq_begintypsend(&buf);
pq_sendfloat8(&buf, oe->epoch);
pq_sendfloat8(&buf, oe->q);
pq_sendfloat8(&buf, oe->e);
pq_sendfloat8(&buf, oe->inc);
pq_sendfloat8(&buf, oe->arg_peri);
pq_sendfloat8(&buf, oe->raan);
pq_sendfloat8(&buf, oe->tp);
pq_sendfloat8(&buf, oe->h_mag);
pq_sendfloat8(&buf, oe->g_slope);
PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
}
/* ================================================================
* Accessor functions
*
* All angles return degrees (matching TLE accessor convention).
* ================================================================
*/
Datum
oe_epoch(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->epoch);
}
Datum
oe_perihelion(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->q);
}
Datum
oe_eccentricity(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->e);
}
Datum
oe_inclination(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->inc * RAD_TO_DEG);
}
Datum
oe_arg_perihelion(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->arg_peri * RAD_TO_DEG);
}
Datum
oe_raan(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->raan * RAD_TO_DEG);
}
Datum
oe_tp(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->tp);
}
Datum
oe_h_mag(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->h_mag);
}
Datum
oe_g_slope(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->g_slope);
}
/* Computed: semi-major axis = q / (1 - e). NULL if e >= 1 (parabolic/hyperbolic). */
Datum
oe_semi_major_axis(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
if (oe->e >= 1.0)
PG_RETURN_NULL();
PG_RETURN_FLOAT8(oe->q / (1.0 - oe->e));
}
/* Computed: orbital period in years = a^1.5 (Kepler's third law, a in AU -> period in years). */
Datum
oe_period_years(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
double a;
if (oe->e >= 1.0)
PG_RETURN_NULL();
a = oe->q / (1.0 - oe->e);
PG_RETURN_FLOAT8(a * sqrt(a));
}
/* ================================================================
* oe_from_mpc(text) -> orbital_elements
*
* Parse one line of MPC MPCORB.DAT fixed-width format.
*
* Column layout (0-indexed):
* 0-6 Number/designation (ignored by this function)
* 8-12 H magnitude
* 14-18 G slope
* 20-24 Epoch (MPC packed date)
* 26-35 Mean anomaly M (degrees)
* 37-45 Argument of perihelion omega (degrees)
* 48-56 Long. ascending node Omega (degrees)
* 59-67 Inclination i (degrees)
* 70-78 Eccentricity e
* 80-90 Mean daily motion n (degrees/day)
* 92-102 Semi-major axis a (AU)
*
* Converts at parse time:
* q = a * (1 - e)
* n_rad = GAUSS_K / (a * sqrt(a)) [radians/day]
* tp = epoch_jd - M_rad / n_rad
* ================================================================
*/
Datum
oe_from_mpc(PG_FUNCTION_ARGS)
{
text *input = PG_GETARG_TEXT_PP(0);
char *line = text_to_cstring(input);
int len = strlen(line);
pg_orbital_elements *result;
double h_mag, g_slope;
double epoch_jd, M_deg, omega_deg, Omega_deg, inc_deg;
double ecc, n_deg, a_au;
double q, n_rad, M_rad, tp;
if (len < 103)
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("MPC line too short: %d characters (need at least 103)", len)));
/* Parse fields — Fortran 1-indexed columns converted to C 0-indexed.
* MPC format: cols 27-35 = C 26-34 (M), 37-46 = C 36-45 (omega),
* 48-57 = C 47-56 (Omega), 59-68 = C 58-67 (inc), 70-79 = C 69-78 (e),
* 81-91 = C 80-90 (n), 93-103 = C 92-102 (a). */
h_mag = parse_mpc_field(line, 8, 5);
g_slope = parse_mpc_field(line, 14, 5);
epoch_jd = mpc_packed_to_jd(line + 20);
M_deg = parse_mpc_field(line, 26, 9);
omega_deg = parse_mpc_field(line, 36, 10);
Omega_deg = parse_mpc_field(line, 47, 10);
inc_deg = parse_mpc_field(line, 58, 10);
ecc = parse_mpc_field(line, 69, 10);
n_deg = parse_mpc_field(line, 80, 11);
a_au = parse_mpc_field(line, 92, 11);
if (isnan(a_au) || a_au <= 0.0)
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid or missing semi-major axis in MPC line")));
if (isnan(ecc) || ecc < 0.0)
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid or missing eccentricity in MPC line")));
if (isnan(M_deg))
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid or missing mean anomaly in MPC line")));
/* Derived quantities */
q = a_au * (1.0 - ecc);
/* Mean motion from Gauss's constant: n = k / a^(3/2) radians/day */
n_rad = GAUSS_K / (a_au * sqrt(a_au));
/* If MPC provides n, prefer it when sanity-checking, but compute tp from
* the Gauss-derived value to maintain constant consistency. */
(void) n_deg;
M_rad = M_deg * DEG_TO_RAD;
tp = epoch_jd - M_rad / n_rad;
result = (pg_orbital_elements *) palloc(sizeof(pg_orbital_elements));
result->epoch = epoch_jd;
result->q = q;
result->e = ecc;
result->inc = inc_deg * DEG_TO_RAD;
result->arg_peri = omega_deg * DEG_TO_RAD;
result->raan = Omega_deg * DEG_TO_RAD;
result->tp = tp;
result->h_mag = h_mag;
result->g_slope = g_slope;
pfree(line);
PG_RETURN_POINTER(result);
}
/* ================================================================
* small_body_heliocentric(orbital_elements, timestamptz) -> heliocentric
*
* Two-body Keplerian propagation from the stored elements.
* ================================================================
*/
Datum
small_body_heliocentric(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double pos[3];
pg_heliocentric *result;
jd = timestamptz_to_jd(ts);
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd, pos);
result = (pg_heliocentric *) palloc(sizeof(pg_heliocentric));
result->x = pos[0];
result->y = pos[1];
result->z = pos[2];
PG_RETURN_POINTER(result);
}
/* ================================================================
* small_body_observe(orbital_elements, observer, timestamptz) -> topocentric
*
* Full pipeline matching planet_observe():
* 1. Kepler position of the body (heliocentric ecliptic J2000)
* 2. VSOP87 Earth position (same frame)
* 3. Geocentric = body - Earth
* 4. observe_from_geocentric() -> topocentric az/el/range
* ================================================================
*/
Datum
small_body_observe(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(1);
int64 ts = PG_GETARG_INT64(2);
double jd;
double body_helio[3];
double earth_xyz[6];
double geo_ecl[3];
pg_topocentric *result;
jd = timestamptz_to_jd(ts);
/* Body's heliocentric ecliptic J2000 position */
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd, body_helio);
/* Earth's heliocentric position via VSOP87 (body 2 = Earth) */
GetVsop87Coor(jd, 2, earth_xyz);
/* Geocentric ecliptic = body - Earth */
geo_ecl[0] = body_helio[0] - earth_xyz[0];
geo_ecl[1] = body_helio[1] - earth_xyz[1];
geo_ecl[2] = body_helio[2] - earth_xyz[2];
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric(geo_ecl, jd, obs, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* small_body_observe_apparent(orbital_elements, observer, timestamptz) -> topocentric
*
* Light-time corrected observation of a comet or asteroid.
*
* Single-iteration: geometric geocentric distance -> tau -> recompute
* body at (jd - tau). Earth stays at observation time jd.
* ================================================================
*/
Datum
small_body_observe_apparent(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(1);
int64 ts = PG_GETARG_INT64(2);
double jd;
double body_helio[3];
double earth_xyz[6];
double geo_ecl[3];
double geo_dist, tau;
pg_topocentric *result;
jd = timestamptz_to_jd(ts);
/* Body's geometric heliocentric position */
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd, body_helio);
/* Earth at observation time (fixed) */
GetVsop87Coor(jd, 2, earth_xyz);
/* Geometric geocentric distance */
geo_ecl[0] = body_helio[0] - earth_xyz[0];
geo_ecl[1] = body_helio[1] - earth_xyz[1];
geo_ecl[2] = body_helio[2] - earth_xyz[2];
geo_dist = sqrt(geo_ecl[0]*geo_ecl[0] + geo_ecl[1]*geo_ecl[1] + geo_ecl[2]*geo_ecl[2]);
/* Recompute body at retarded time */
tau = geo_dist / C_LIGHT_AU_DAY;
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd - tau, body_helio);
/* Apparent geocentric = retarded body - Earth(jd) */
geo_ecl[0] = body_helio[0] - earth_xyz[0];
geo_ecl[1] = body_helio[1] - earth_xyz[1];
geo_ecl[2] = body_helio[2] - earth_xyz[2];
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric_aberrated(geo_ecl, jd, obs, &earth_xyz[3], result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* small_body_equatorial(orbital_elements, timestamptz) -> equatorial
*
* Geocentric RA/Dec of a comet or asteroid (no light-time correction).
*
* Pipeline: Kepler heliocentric -> subtract Earth -> equatorial.
* ================================================================
*/
Datum
small_body_equatorial(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double body_helio[3];
double earth_xyz[6];
double geo_ecl[3];
pg_equatorial *result;
jd = timestamptz_to_jd(ts);
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd, body_helio);
GetVsop87Coor(jd, 2, earth_xyz);
geo_ecl[0] = body_helio[0] - earth_xyz[0];
geo_ecl[1] = body_helio[1] - earth_xyz[1];
geo_ecl[2] = body_helio[2] - earth_xyz[2];
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(geo_ecl, jd, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* small_body_equatorial_apparent(orbital_elements, timestamptz) -> equatorial
*
* Light-time corrected geocentric RA/Dec of a comet or asteroid.
* Same retarded-time correction as small_body_observe_apparent()
* but returns equatorial coordinates instead of topocentric az/el.
* ================================================================
*/
Datum
small_body_equatorial_apparent(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double body_helio[3];
double earth_xyz[6];
double geo_ecl[3];
double geo_dist, tau;
pg_equatorial *result;
jd = timestamptz_to_jd(ts);
/* Geometric body position */
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd, body_helio);
/* Earth at observation time (fixed) */
GetVsop87Coor(jd, 2, earth_xyz);
/* Geometric geocentric distance */
geo_ecl[0] = body_helio[0] - earth_xyz[0];
geo_ecl[1] = body_helio[1] - earth_xyz[1];
geo_ecl[2] = body_helio[2] - earth_xyz[2];
geo_dist = sqrt(geo_ecl[0]*geo_ecl[0] + geo_ecl[1]*geo_ecl[1] + geo_ecl[2]*geo_ecl[2]);
/* Retarded body position */
tau = geo_dist / C_LIGHT_AU_DAY;
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd - tau, body_helio);
/* Apparent geocentric */
geo_ecl[0] = body_helio[0] - earth_xyz[0];
geo_ecl[1] = body_helio[1] - earth_xyz[1];
geo_ecl[2] = body_helio[2] - earth_xyz[2];
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial_aberrated(geo_ecl, jd, &earth_xyz[3], result);
PG_RETURN_POINTER(result);
}

View File

@ -36,6 +36,7 @@ PG_FUNCTION_INFO_V1(pass_duration);
PG_FUNCTION_INFO_V1(next_pass);
PG_FUNCTION_INFO_V1(predict_passes);
PG_FUNCTION_INFO_V1(pass_visible);
PG_FUNCTION_INFO_V1(predict_passes_refracted);
#define DEG_TO_RAD (M_PI / 180.0)
#define RAD_TO_DEG (180.0 / M_PI)
@ -297,7 +298,7 @@ elevation_at_jd(const pg_tle *tle, const pg_observer *obs,
static bool
find_next_pass(const pg_tle *tle, const pg_observer *obs,
double start_jd, double stop_jd,
double min_el_rad,
double min_el_rad, double threshold_rad,
double *aos_jd, double *los_jd,
double *max_el_jd, double *max_el,
double *aos_az, double *los_az)
@ -316,8 +317,8 @@ find_next_pass(const pg_tle *tle, const pg_observer *obs,
curr_el = elevation_at_jd(tle, obs, jd, NULL);
/* Rising edge: was below horizon, now above */
if (prev_el <= 0.0 && curr_el > 0.0)
/* Rising edge: was below threshold, now above */
if (prev_el <= threshold_rad && curr_el > threshold_rad)
{
double lo, hi, mid;
double peak_el;
@ -329,7 +330,7 @@ find_next_pass(const pg_tle *tle, const pg_observer *obs,
while (hi - lo > BISECT_TOL_JD)
{
mid = (lo + hi) / 2.0;
if (elevation_at_jd(tle, obs, mid, NULL) > 0.0)
if (elevation_at_jd(tle, obs, mid, NULL) > threshold_rad)
hi = mid;
else
lo = mid;
@ -350,7 +351,7 @@ find_next_pass(const pg_tle *tle, const pg_observer *obs,
if (scan_el > peak_el)
peak_el = scan_el;
if (scan_el <= 0.0)
if (scan_el <= threshold_rad)
{
/* Bisect to find LOS */
lo = scan_jd - COARSE_STEP_JD;
@ -358,7 +359,7 @@ find_next_pass(const pg_tle *tle, const pg_observer *obs,
while (hi - lo > BISECT_TOL_JD)
{
mid = (lo + hi) / 2.0;
if (elevation_at_jd(tle, obs, mid, NULL) > 0.0)
if (elevation_at_jd(tle, obs, mid, NULL) > threshold_rad)
lo = mid;
else
hi = mid;
@ -376,7 +377,7 @@ find_next_pass(const pg_tle *tle, const pg_observer *obs,
if (*los_jd - *aos_jd < MIN_PASS_DURATION_JD)
{
jd = *los_jd;
prev_el = 0.0;
prev_el = threshold_rad - 0.01;
continue;
}
@ -404,7 +405,7 @@ find_next_pass(const pg_tle *tle, const pg_observer *obs,
if (*max_el < min_el_rad)
{
jd = *los_jd;
prev_el = 0.0;
prev_el = threshold_rad - 0.01;
continue;
}
@ -630,6 +631,7 @@ next_pass(PG_FUNCTION_ARGS)
if (!find_next_pass(tle, obs, start_jd, stop_jd,
0.0, /* minimum elevation = 0 degrees */
0.0, /* threshold = geometric horizon */
&aos_jd, &los_jd,
&max_el_jd, &max_el,
&aos_az, &los_az))
@ -722,6 +724,7 @@ predict_passes(PG_FUNCTION_ARGS)
if (!find_next_pass(&ctx->tle, &ctx->obs,
ctx->current_jd, ctx->stop_jd,
ctx->min_el_rad,
0.0, /* threshold = geometric horizon */
&aos_jd, &los_jd,
&max_el_jd, &max_el,
&aos_az, &los_az))
@ -767,7 +770,111 @@ pass_visible(PG_FUNCTION_ARGS)
PG_RETURN_BOOL(find_next_pass(tle, obs, start_jd, stop_jd,
0.0,
0.0, /* threshold = geometric horizon */
&aos_jd, &los_jd,
&max_el_jd, &max_el,
&aos_az, &los_az));
}
/* ----------------------------------------------------------------
* predict_passes_refracted(tle, observer, start, stop [, min_elevation])
* -> SETOF pass_event
*
* Same as predict_passes but uses refracted horizon threshold.
* Bennett's refraction at 0 deg geometric elevation is ~0.569 deg,
* so the threshold is -0.569 deg = -0.00993 rad. This means AOS
* triggers when the satellite's geometric elevation crosses -0.569
* deg (the point at which refraction bends it to the apparent
* horizon).
* ----------------------------------------------------------------
*/
#define REFRACTED_HORIZON_RAD (-0.00993) /* -0.569 deg, Bennett at h=0 */
typedef struct
{
pg_tle tle;
pg_observer obs;
double current_jd;
double stop_jd;
double min_el_rad;
} predict_passes_refracted_ctx;
Datum
predict_passes_refracted(PG_FUNCTION_ARGS)
{
FuncCallContext *funcctx;
predict_passes_refracted_ctx *ctx;
if (SRF_IS_FIRSTCALL())
{
MemoryContext oldctx;
pg_tle *tle;
pg_observer *obs;
int64 start_ts;
int64 stop_ts;
double min_el_deg;
funcctx = SRF_FIRSTCALL_INIT();
oldctx = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
tle = (pg_tle *) PG_GETARG_POINTER(0);
obs = (pg_observer *) PG_GETARG_POINTER(1);
start_ts = PG_GETARG_INT64(2);
stop_ts = PG_GETARG_INT64(3);
min_el_deg = (PG_NARGS() > 4 && !PG_ARGISNULL(4))
? PG_GETARG_FLOAT8(4)
: 0.0;
if (stop_ts <= start_ts)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("stop time must be after start time")));
ctx = (predict_passes_refracted_ctx *)
palloc0(sizeof(predict_passes_refracted_ctx));
memcpy(&ctx->tle, tle, sizeof(pg_tle));
memcpy(&ctx->obs, obs, sizeof(pg_observer));
ctx->current_jd = timestamptz_to_jd(start_ts);
ctx->stop_jd = timestamptz_to_jd(stop_ts);
ctx->min_el_rad = min_el_deg * DEG_TO_RAD;
funcctx->user_fctx = ctx;
MemoryContextSwitchTo(oldctx);
}
funcctx = SRF_PERCALL_SETUP();
ctx = (predict_passes_refracted_ctx *) funcctx->user_fctx;
{
double aos_jd, los_jd, max_el_jd, max_el;
double aos_az, los_az;
pg_pass_event *result;
if (!find_next_pass(&ctx->tle, &ctx->obs,
ctx->current_jd, ctx->stop_jd,
ctx->min_el_rad,
REFRACTED_HORIZON_RAD,
&aos_jd, &los_jd,
&max_el_jd, &max_el,
&aos_az, &los_az))
SRF_RETURN_DONE(funcctx);
result = (pg_pass_event *) palloc(sizeof(pg_pass_event));
result->aos_time = jd_to_timestamptz(aos_jd);
result->max_el_time = jd_to_timestamptz(max_el_jd);
result->los_time = jd_to_timestamptz(los_jd);
result->max_elevation = max_el * RAD_TO_DEG;
result->aos_azimuth = aos_az * RAD_TO_DEG;
result->los_azimuth = los_az * RAD_TO_DEG;
/* Advance past this pass before the next call */
ctx->current_jd = los_jd + POST_LOS_GAP_JD;
SRF_RETURN_NEXT(funcctx, PointerGetDatum(result));
}
}

View File

@ -29,6 +29,10 @@ PG_FUNCTION_INFO_V1(planet_heliocentric);
PG_FUNCTION_INFO_V1(planet_observe);
PG_FUNCTION_INFO_V1(sun_observe);
PG_FUNCTION_INFO_V1(moon_observe);
PG_FUNCTION_INFO_V1(planet_observe_apparent);
PG_FUNCTION_INFO_V1(sun_observe_apparent);
PG_FUNCTION_INFO_V1(planet_equatorial_apparent);
PG_FUNCTION_INFO_V1(moon_equatorial_apparent);
/*
@ -197,3 +201,212 @@ moon_observe(PG_FUNCTION_ARGS)
PG_RETURN_POINTER(result);
}
/* ================================================================
* planet_observe_apparent(body_id int, observer, timestamptz) -> topocentric
*
* Light-time corrected planet observation.
*
* Single-iteration correction: compute geometric geocentric distance,
* derive tau = dist / c, recompute planet at (jd - tau).
* Earth stays at observation time jd. Sufficient for ~arcsecond
* accuracy (second iteration would gain ~milliarcseconds).
*
* Body IDs: 1=Mercury, ..., 8=Neptune (not Sun, Earth, or Moon)
* ================================================================
*/
Datum
planet_observe_apparent(PG_FUNCTION_ARGS)
{
int32 body_id = PG_GETARG_INT32(0);
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(1);
int64 ts = PG_GETARG_INT64(2);
double jd;
double earth_xyz[6];
double planet_xyz[6];
double geo_ecl[3];
double geo_dist, tau;
int vsop_body;
pg_topocentric *result;
if (body_id < BODY_MERCURY || body_id > BODY_NEPTUNE)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("planet_observe_apparent: body_id %d must be 1-8 (Mercury-Neptune)",
body_id)));
if (body_id == BODY_EARTH)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot observe Earth from Earth")));
jd = timestamptz_to_jd(ts);
vsop_body = body_id - 1;
/* Earth at observation time (stays fixed throughout) */
GetVsop87Coor(jd, 2, earth_xyz);
/* Geometric planet position for initial distance estimate */
GetVsop87Coor(jd, vsop_body, planet_xyz);
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
geo_dist = sqrt(geo_ecl[0]*geo_ecl[0] + geo_ecl[1]*geo_ecl[1] + geo_ecl[2]*geo_ecl[2]);
/* Recompute planet at retarded time (jd - tau) */
tau = geo_dist / C_LIGHT_AU_DAY;
GetVsop87Coor(jd - tau, vsop_body, planet_xyz);
/* Apparent geocentric = retarded planet - Earth(jd) */
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric_aberrated(geo_ecl, jd, obs, &earth_xyz[3], result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* sun_observe_apparent(observer, timestamptz) -> topocentric
*
* Light-time corrected Sun observation.
*
* The Sun is at the heliocentric origin (0,0,0) at all times.
* Sun(jd - tau) = (0,0,0), so geocentric_apparent = -Earth(jd).
* Identical to sun_observe() -- included for API symmetry so
* callers don't need to special-case the Sun.
* ================================================================
*/
Datum
sun_observe_apparent(PG_FUNCTION_ARGS)
{
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double earth_xyz[6];
double geo_ecl[3];
pg_topocentric *result;
jd = timestamptz_to_jd(ts);
/* Sun at origin -> geocentric = -Earth(jd) */
GetVsop87Coor(jd, 2, earth_xyz);
geo_ecl[0] = -earth_xyz[0];
geo_ecl[1] = -earth_xyz[1];
geo_ecl[2] = -earth_xyz[2];
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric_aberrated(geo_ecl, jd, obs, &earth_xyz[3], result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* planet_equatorial_apparent(body_id int, timestamptz) -> equatorial
*
* Light-time corrected geocentric RA/Dec of a planet.
* Same retarded-time correction as planet_observe_apparent() but
* returns equatorial coordinates instead of topocentric az/el.
*
* Body IDs: 1=Mercury, ..., 8=Neptune (not Sun, Earth, or Moon)
* ================================================================
*/
Datum
planet_equatorial_apparent(PG_FUNCTION_ARGS)
{
int32 body_id = PG_GETARG_INT32(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double earth_xyz[6];
double planet_xyz[6];
double geo_ecl[3];
double geo_dist, tau;
int vsop_body;
pg_equatorial *result;
if (body_id < BODY_MERCURY || body_id > BODY_NEPTUNE)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("planet_equatorial_apparent: body_id %d must be 1-8 (Mercury-Neptune)",
body_id)));
if (body_id == BODY_EARTH)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot observe Earth from Earth")));
jd = timestamptz_to_jd(ts);
vsop_body = body_id - 1;
/* Earth at observation time */
GetVsop87Coor(jd, 2, earth_xyz);
/* Geometric planet for initial distance */
GetVsop87Coor(jd, vsop_body, planet_xyz);
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
geo_dist = sqrt(geo_ecl[0]*geo_ecl[0] + geo_ecl[1]*geo_ecl[1] + geo_ecl[2]*geo_ecl[2]);
/* Retarded planet position */
tau = geo_dist / C_LIGHT_AU_DAY;
GetVsop87Coor(jd - tau, vsop_body, planet_xyz);
/* Apparent geocentric */
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial_aberrated(geo_ecl, jd, &earth_xyz[3], result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* moon_equatorial_apparent(timestamptz) -> equatorial
*
* Light-time corrected geocentric RA/Dec of the Moon.
*
* ELP2000-82B returns geocentric ecliptic directly (no Earth
* subtraction needed). Moon is ~1.3 light-seconds away, so
* tau ~ 0.000015 days. Small but included for consistency.
* ================================================================
*/
Datum
moon_equatorial_apparent(PG_FUNCTION_ARGS)
{
int64 ts = PG_GETARG_INT64(0);
double jd;
double moon_ecl[3];
double earth_xyz[6];
double geo_dist, tau;
pg_equatorial *result;
jd = timestamptz_to_jd(ts);
/* Geometric Moon geocentric ecliptic J2000 (AU) */
GetElp82bCoor(jd, moon_ecl);
geo_dist = sqrt(moon_ecl[0]*moon_ecl[0] + moon_ecl[1]*moon_ecl[1] + moon_ecl[2]*moon_ecl[2]);
/* Retarded Moon position */
tau = geo_dist / C_LIGHT_AU_DAY;
GetElp82bCoor(jd - tau, moon_ecl);
/* Earth velocity for aberration (ELP doesn't provide it) */
GetVsop87Coor(jd, 2, earth_xyz);
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial_aberrated(moon_ecl, jd, &earth_xyz[3], result);
PG_RETURN_POINTER(result);
}

120
src/refraction_funcs.c Normal file
View File

@ -0,0 +1,120 @@
/*
* refraction_funcs.c -- Atmospheric refraction for pg_orrery
*
* Bennett's (1982) formula for standard atmosphere, with optional
* pressure/temperature correction per Meeus (1991).
*
* Domain guard: clamp geometric elevation to -1 deg before tan()
* evaluation. Below that, Bennett's formula is outside its valid
* range and we return 0.
*/
#include "postgres.h"
#include "fmgr.h"
#include "types.h"
#include <math.h>
PG_FUNCTION_INFO_V1(atmospheric_refraction);
PG_FUNCTION_INFO_V1(atmospheric_refraction_ext);
PG_FUNCTION_INFO_V1(topo_elevation_apparent);
#define RAD_TO_DEG (180.0 / M_PI)
#define DEG_TO_RAD (M_PI / 180.0)
/*
* bennett_refraction -- core Bennett (1982) formula
*
* Input: geometric elevation in degrees
* Output: refraction correction in degrees (>= 0)
*
* R = 1/tan(h + 7.31/(h + 4.4)) arcminutes
* where h is the geometric elevation in degrees.
*
* The formula diverges near h = -4.4 deg; clamp to -1 deg
* (below which the atmosphere model is meaningless anyway).
*/
static double
bennett_refraction(double h_deg)
{
double h, R;
if (h_deg < -1.0)
return 0.0;
h = fmax(h_deg, -1.0);
/* Bennett's formula: arcminutes */
R = 1.0 / tan((h + 7.31 / (h + 4.4)) * DEG_TO_RAD);
if (!isfinite(R))
return 0.0;
/* Convert arcminutes to degrees */
R /= 60.0;
if (R < 0.0)
R = 0.0;
return R;
}
/*
* atmospheric_refraction(elevation_deg) -> refraction_deg
*
* Standard atmosphere: P = 1010 mbar, T = 10 C.
*/
Datum
atmospheric_refraction(PG_FUNCTION_ARGS)
{
double h_deg = PG_GETARG_FLOAT8(0);
PG_RETURN_FLOAT8(bennett_refraction(h_deg));
}
/*
* atmospheric_refraction_ext(elevation_deg, pressure_mbar, temp_celsius)
* -> refraction_deg
*
* Meeus correction for non-standard atmosphere:
* R_corrected = R * (P / 1010.0) * (283.0 / (273.0 + T))
*/
Datum
atmospheric_refraction_ext(PG_FUNCTION_ARGS)
{
double h_deg = PG_GETARG_FLOAT8(0);
double P = PG_GETARG_FLOAT8(1);
double T = PG_GETARG_FLOAT8(2);
double R;
R = bennett_refraction(h_deg);
/* Meeus pressure/temperature correction */
R *= (P / 1010.0) * (283.0 / (273.0 + T));
if (!isfinite(R) || R < 0.0)
R = 0.0;
PG_RETURN_FLOAT8(R);
}
/*
* topo_elevation_apparent(topocentric) -> apparent_elevation_deg
*
* Geometric elevation from the topocentric type plus Bennett's
* atmospheric refraction correction.
*/
Datum
topo_elevation_apparent(PG_FUNCTION_ARGS)
{
pg_topocentric *topo = (pg_topocentric *) PG_GETARG_POINTER(0);
double el_deg;
double refr;
el_deg = topo->elevation * RAD_TO_DEG;
refr = bennett_refraction(el_deg);
PG_RETURN_FLOAT8(el_deg + refr);
}

814
src/spgist_tle.c Normal file
View File

@ -0,0 +1,814 @@
/*
* spgist_tle.c -- SP-GiST operator class for orbital trie on TLE
*
* 2-level space-partitioning trie:
* L0: Semi-major axis (altitude regime, from Kepler's 3rd law)
* L1: Inclination (ground-track latitude coverage)
*
* Query-time RAAN filter at leaf level projects ascending node to
* the query midpoint via J2 secular precession and checks alignment
* with the observer's local sidereal position.
*
* The &? operator answers "could this satellite be visible from this
* observer during this time window?" -- a conservative superset of
* the actual answer. Survivors go through SGP4 propagation.
*
* Equal-population splits: picksplit sorts by the level's element
* and divides into floor(sqrt(n)) bins, clamped [2,16]. Dense LEO
* gets finer SMA bins than sparse MEO/GEO.
*/
#include "postgres.h"
#include "fmgr.h"
#include "access/spgist.h"
#include "access/htup_details.h"
#include "catalog/pg_type_d.h"
#include "utils/builtins.h"
#include "utils/timestamp.h"
#include "executor/executor.h"
#include "types.h"
#include "astro_math.h"
#include <math.h>
#include <float.h>
PG_FUNCTION_INFO_V1(spgist_tle_config);
PG_FUNCTION_INFO_V1(spgist_tle_choose);
PG_FUNCTION_INFO_V1(spgist_tle_picksplit);
PG_FUNCTION_INFO_V1(spgist_tle_inner_consistent);
PG_FUNCTION_INFO_V1(spgist_tle_leaf_consistent);
PG_FUNCTION_INFO_V1(tle_visibility_possible);
/* Max trie depth: L0 (SMA) + L1 (inclination) */
#define SPGIST_TLE_MAX_LEVEL 2
/* Earth angular rotation rate in radians/day */
#define EARTH_ROT_RAD_PER_DAY (2.0 * M_PI)
/* Seconds per day */
#define SECONDS_PER_DAY 86400.0
/* Minutes per day */
#define MINUTES_PER_DAY 1440.0
/* ----------------------------------------------------------------
* Helper: semi-major axis in km from mean motion
*
* Kepler's 3rd law with WGS-72: a = (KE / n)^(2/3) * AE
* where n is in radians/minute (TLE internal units).
* ----------------------------------------------------------------
*/
static inline double
tle_sma_km(const pg_tle *tle)
{
double n = tle->mean_motion;
if (n <= 0.0)
return 0.0;
return pow(WGS72_KE / n, 2.0 / 3.0) * WGS72_AE;
}
/* ----------------------------------------------------------------
* Helper: perigee altitude in km above Earth's surface
* ----------------------------------------------------------------
*/
static inline double
tle_perigee_alt_km(const pg_tle *tle)
{
double a = tle_sma_km(tle);
return a * (1.0 - tle->eccentricity) - WGS72_AE;
}
/* ----------------------------------------------------------------
* Helper: apogee altitude in km above Earth's surface
* ----------------------------------------------------------------
*/
static inline double
tle_apogee_alt_km(const pg_tle *tle)
{
double a = tle_sma_km(tle);
return a * (1.0 + tle->eccentricity) - WGS72_AE;
}
/* ----------------------------------------------------------------
* Helper: maximum satellite altitude visible at a given min elevation
*
* At min_el degrees elevation, the observer can see a satellite at
* most this far above the surface. Conservative upper bound using
* the Earth-center angle geometry:
* h_max = Re * (1/cos(el) - 1) roughly, but for a safe upper
* bound we use the slant range limit.
*
* For min_el = 10 deg, practical limit is ~2500 km for LEO passes.
* For min_el = 0 deg, theoretical limit extends to GEO+.
* We use a generous bound: if min_el < 5, return 50000 (no filter).
* Otherwise compute from geometry.
* ----------------------------------------------------------------
*/
static inline double
max_visible_altitude_km(double min_el_deg)
{
double el_rad, sin_el;
double rho, h_max;
/*
* Below 5 deg elevation the horizon geometry allows visibility to
* GEO+ altitudes. Disable the altitude filter rather than compute
* an impractically large bound. 50000 km exceeds GEO (35786 km).
*/
if (min_el_deg < 5.0)
return 50000.0;
el_rad = min_el_deg * DEG_TO_RAD;
sin_el = sin(el_rad);
/*
* Maximum slant range rho where a satellite at altitude h is visible
* at elevation el. From the geometry:
* rho = Re * (sqrt((h/Re + 1)^2 - cos^2(el)) - sin(el))
* Invert for h given a max practical rho. We take rho_max = 5000 km
* (well beyond any LEO pass) and solve for h.
*
* Simpler conservative bound: h_max = rho_max / sin(el) for el > 0.
*/
rho = 5000.0; /* max practical slant range; well beyond LEO/MEO */
h_max = sqrt(rho * rho + 2.0 * WGS72_AE * rho * sin_el
+ WGS72_AE * WGS72_AE) - WGS72_AE;
return h_max;
}
/* ----------------------------------------------------------------
* Helper: angular radius of ground visibility footprint
*
* For a satellite at altitude h km, the half-angle of the visibility
* cone (Earth-center angle) at min_el elevation is:
* lambda = arccos(Re / (Re + h) * cos(el)) - el
* Returns degrees.
* ----------------------------------------------------------------
*/
static inline double
ground_footprint_deg(double sma_km, double min_el_deg)
{
double h_km = sma_km - WGS72_AE;
double el_rad, cos_ratio, lambda;
if (h_km <= 0.0)
return 0.0;
el_rad = min_el_deg * DEG_TO_RAD;
cos_ratio = WGS72_AE / (WGS72_AE + h_km) * cos(el_rad);
if (cos_ratio >= 1.0)
return 0.0;
lambda = acos(cos_ratio) - el_rad;
return lambda * RAD_TO_DEG;
}
/* ----------------------------------------------------------------
* Helper: J2 secular RAAN precession rate in radians/day
*
* dOmega/dt = -1.5 * n * J2 * (Re/a)^2 * cos(i)
*
* where n is mean motion in rad/s, J2 and Re are WGS-72.
* Result in rad/day (multiply rad/s by 86400).
*
* Uses only the J2 zonal harmonic (no J3/J4 short-period terms).
* For LEO this can accumulate ~0.5 deg/day error from J3 short-
* period oscillations. Acceptable because (a) the RAAN window
* includes footprint + Earth rotation padding, and (b) any query
* window >= ~12 hours bypasses the RAAN filter entirely.
* ----------------------------------------------------------------
*/
static inline double
j2_raan_rate(double sma_km, double inc_rad)
{
double a = sma_km;
double ratio, n_rad_s;
if (a <= 0.0)
return 0.0;
ratio = WGS72_AE / a;
n_rad_s = sqrt(WGS72_MU / (a * a * a));
return -1.5 * n_rad_s * WGS72_J2 * ratio * ratio * cos(inc_rad)
* SECONDS_PER_DAY;
}
/* ----------------------------------------------------------------
* Traversal state carried down the tree during index scans.
* Accumulates SMA and inclination ranges from L0 and L1.
* ----------------------------------------------------------------
*/
typedef struct OrbitalTraversal
{
double sma_low;
double sma_high;
double inc_low;
double inc_high;
} OrbitalTraversal;
/* ----------------------------------------------------------------
* Query parameter extraction from observer_window composite type
*
* observer_window is: (obs observer, t_start timestamptz,
* t_end timestamptz, min_el float8)
* ----------------------------------------------------------------
*/
typedef struct ObserverWindow
{
pg_observer obs;
double jd_start;
double jd_end;
double jd_mid;
double min_el_deg;
} ObserverWindow;
static void
extract_observer_window(HeapTupleHeader composite, ObserverWindow *win)
{
bool isnull;
Datum val;
int64 ts_start, ts_end;
/*
* Field 1: obs (observer -- fixed-size 24B, pass-by-reference).
* GetAttributeByNum returns a Datum that is a direct pointer to
* the pg_observer bytes in the composite tuple. No varlena header.
*/
val = GetAttributeByNum(composite, 1, &isnull);
if (isnull)
ereport(ERROR,
(errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED),
errmsg("observer_window.obs must not be NULL")));
memcpy(&win->obs, DatumGetPointer(val), sizeof(pg_observer));
/* Field 2: t_start (timestamptz) */
val = GetAttributeByNum(composite, 2, &isnull);
if (isnull)
ereport(ERROR,
(errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED),
errmsg("observer_window.t_start must not be NULL")));
ts_start = DatumGetTimestampTz(val);
win->jd_start = timestamptz_to_jd(ts_start);
/* Field 3: t_end (timestamptz) */
val = GetAttributeByNum(composite, 3, &isnull);
if (isnull)
ereport(ERROR,
(errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED),
errmsg("observer_window.t_end must not be NULL")));
ts_end = DatumGetTimestampTz(val);
win->jd_end = timestamptz_to_jd(ts_end);
/* Validate time window ordering */
if (win->jd_end < win->jd_start)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("observer_window.t_end must not precede t_start")));
win->jd_mid = (win->jd_start + win->jd_end) / 2.0;
/* Field 4: min_el (float8), clamped to [0, 90] */
val = GetAttributeByNum(composite, 4, &isnull);
if (isnull)
win->min_el_deg = 10.0; /* default */
else
{
win->min_el_deg = DatumGetFloat8(val);
if (win->min_el_deg < 0.0)
win->min_el_deg = 0.0;
if (win->min_el_deg > 90.0)
win->min_el_deg = 90.0;
}
}
/* ----------------------------------------------------------------
* Comparison function for picksplit qsort.
* Sorts by extracted key value (SMA or inclination).
* ----------------------------------------------------------------
*/
typedef struct
{
int orig_index;
double key;
} PicksplitEntry;
static int
picksplit_entry_cmp(const void *a, const void *b)
{
double ka = ((const PicksplitEntry *) a)->key;
double kb = ((const PicksplitEntry *) b)->key;
if (ka < kb)
return -1;
if (ka > kb)
return 1;
return 0;
}
/* ================================================================
* SP-GiST support functions (5 required)
* ================================================================
*/
/*
* spgist_tle_config -- declare trie type system
*
* No prefix data (VOIDOID): bin ranges encoded entirely in node labels.
* Labels are float8 (bin boundary values, sorted ascending).
* Leaves store full TLE unchanged.
*/
Datum
spgist_tle_config(PG_FUNCTION_ARGS)
{
spgConfigIn *in = (spgConfigIn *) PG_GETARG_POINTER(0);
spgConfigOut *out = (spgConfigOut *) PG_GETARG_POINTER(1);
out->prefixType = VOIDOID;
out->labelType = FLOAT8OID;
out->leafType = in->attType; /* tle type */
out->canReturnData = true;
out->longValuesOK = false;
PG_RETURN_VOID();
}
/*
* spgist_tle_choose -- route a new TLE to the correct child node
*
* L0: route by SMA. L1: route by inclination.
* restDatum = leafDatum (full TLE unchanged), matching the quad-tree
* precedent where the tree terminates by depth, not value exhaustion.
*
* At level >= 2, all nodes are allTheSame (no further partitioning).
*/
Datum
spgist_tle_choose(PG_FUNCTION_ARGS)
{
spgChooseIn *in = (spgChooseIn *) PG_GETARG_POINTER(0);
spgChooseOut *out = (spgChooseOut *) PG_GETARG_POINTER(1);
pg_tle *tle = (pg_tle *) DatumGetPointer(in->leafDatum);
int level = in->level;
double val;
/* Extract the routing key for this level */
if (level == 0)
val = tle_sma_km(tle);
else
val = tle->inclination;
if (in->allTheSame || level >= SPGIST_TLE_MAX_LEVEL)
{
out->resultType = spgMatchNode;
out->result.matchNode.nodeN = 0;
out->result.matchNode.levelAdd = 1;
out->result.matchNode.restDatum = in->leafDatum;
PG_RETURN_VOID();
}
/*
* Find the bin: labels are sorted ascending, each label marks the
* lower bound of a bin. We want the last bin whose label <= val.
*/
{
int best = 0;
int i;
for (i = 1; i < in->nNodes; i++)
{
if (DatumGetFloat8(in->nodeLabels[i]) <= val)
best = i;
else
break;
}
out->resultType = spgMatchNode;
out->result.matchNode.nodeN = best;
out->result.matchNode.levelAdd = 1;
out->result.matchNode.restDatum = in->leafDatum;
}
PG_RETURN_VOID();
}
/*
* spgist_tle_picksplit -- split a leaf page using equal-population strategy
*
* Sort by the current level's element (SMA at L0, inclination at L1),
* divide into floor(sqrt(nTuples)) bins clamped to [2, 16].
* At level >= 2, emit a single allTheSame node.
*/
Datum
spgist_tle_picksplit(PG_FUNCTION_ARGS)
{
spgPickSplitIn *in = (spgPickSplitIn *) PG_GETARG_POINTER(0);
spgPickSplitOut *out = (spgPickSplitOut *) PG_GETARG_POINTER(1);
int level = in->level;
int nTuples = in->nTuples;
int nBins, perBin, remainder;
int i, bin, pos;
PicksplitEntry *entries;
/*
* At level >= 2 we have no further partitioning dimension.
* Emit a single allTheSame node that accepts everything.
*/
if (level >= SPGIST_TLE_MAX_LEVEL)
{
out->nNodes = 1;
out->hasPrefix = false;
out->prefixDatum = (Datum) 0;
out->nodeLabels = (Datum *) palloc(sizeof(Datum));
out->nodeLabels[0] = Float8GetDatum(0.0);
out->mapTuplesToNodes = (int *) palloc(sizeof(int) * nTuples);
out->leafTupleDatums = (Datum *) palloc(sizeof(Datum) * nTuples);
for (i = 0; i < nTuples; i++)
{
out->mapTuplesToNodes[i] = 0;
out->leafTupleDatums[i] = in->datums[i];
}
PG_RETURN_VOID();
}
/* Extract and sort by the current level's element */
entries = (PicksplitEntry *) palloc(sizeof(PicksplitEntry) * nTuples);
for (i = 0; i < nTuples; i++)
{
pg_tle *tle = (pg_tle *) DatumGetPointer(in->datums[i]);
entries[i].orig_index = i;
if (level == 0)
entries[i].key = tle_sma_km(tle);
else
entries[i].key = tle->inclination;
}
qsort(entries, nTuples, sizeof(PicksplitEntry), picksplit_entry_cmp);
/* Equal-population split: floor(sqrt(n)) bins, clamped [2, 16] */
nBins = (int) floor(sqrt((double) nTuples));
if (nBins < 2)
nBins = 2;
if (nBins > 16)
nBins = 16;
/* Prevent over-read: never more bins than tuples */
if (nBins > nTuples)
nBins = nTuples;
perBin = nTuples / nBins;
remainder = nTuples % nBins;
out->nNodes = nBins;
out->hasPrefix = false;
out->prefixDatum = (Datum) 0;
out->nodeLabels = (Datum *) palloc(sizeof(Datum) * nBins);
out->mapTuplesToNodes = (int *) palloc(sizeof(int) * nTuples);
out->leafTupleDatums = (Datum *) palloc(sizeof(Datum) * nTuples);
pos = 0;
for (bin = 0; bin < nBins; bin++)
{
int count = perBin + (bin < remainder ? 1 : 0);
/* Node label = key value of the first entry in this bin */
out->nodeLabels[bin] = Float8GetDatum(entries[pos].key);
for (i = 0; i < count; i++)
{
int orig = entries[pos + i].orig_index;
out->mapTuplesToNodes[orig] = bin;
out->leafTupleDatums[orig] = in->datums[orig];
}
pos += count;
}
pfree(entries);
PG_RETURN_VOID();
}
/*
* spgist_tle_inner_consistent -- prune child nodes during index scan
*
* L0: skip bins whose perigee altitude exceeds max visible altitude.
* The bin's SMA range is [label[i], label[i+1]).
* L1: skip bins whose inclination is too low to reach observer latitude.
* A satellite with inclination i has ground track bounded by [-i, +i].
* Observer at latitude phi needs i + footprint >= |phi|.
*
* Propagates OrbitalTraversal state to surviving children via
* traversalMemoryContext for the RAAN filter at leaf level.
*/
Datum
spgist_tle_inner_consistent(PG_FUNCTION_ARGS)
{
spgInnerConsistentIn *in = (spgInnerConsistentIn *) PG_GETARG_POINTER(0);
spgInnerConsistentOut *out = (spgInnerConsistentOut *) PG_GETARG_POINTER(1);
int level = in->level;
int nkeys = in->nkeys;
int i;
ObserverWindow win;
bool have_query = false;
/* Extract query from scankeys -- we need the &? operator's arg */
for (i = 0; i < nkeys; i++)
{
if (in->scankeys[i].sk_strategy == 1)
{
HeapTupleHeader composite;
composite = DatumGetHeapTupleHeader(in->scankeys[i].sk_argument);
extract_observer_window(composite, &win);
have_query = true;
break;
}
}
/* Allocate output arrays */
out->nodeNumbers = (int *) palloc(sizeof(int) * in->nNodes);
out->levelAdds = (int *) palloc(sizeof(int) * in->nNodes);
out->reconstructedValues = NULL;
out->traversalValues = (void **) palloc(sizeof(void *) * in->nNodes);
out->nNodes = 0;
for (i = 0; i < in->nNodes; i++)
{
OrbitalTraversal *parent_trav;
OrbitalTraversal *child_trav;
double bin_low, bin_high;
bool dominated = false;
/* Decode bin range from labels */
bin_low = DatumGetFloat8(in->nodeLabels[i]);
if (i < in->nNodes - 1)
bin_high = DatumGetFloat8(in->nodeLabels[i + 1]);
else
bin_high = INFINITY;
/* Inherit parent traversal state or initialize */
if (in->traversalValue)
parent_trav = (OrbitalTraversal *) in->traversalValue;
else
parent_trav = NULL;
/* Pruning logic per level */
if (have_query && level == 0)
{
/*
* L0: SMA range narrowing only no altitude pruning.
*
* We cannot prune SMA bins by altitude because eccentricity
* is not available at the inner node level. A satellite
* at SMA 70,000 km with e=0.88 has perigee ~2,000 km
* well within typical max_alt. Without knowing e, any SMA
* bin could contain satellites with perigee near Earth's
* surface.
*
* L0 still helps by narrowing the SMA range passed to L1
* for computing a tighter ground footprint.
*/
}
else if (have_query && level == 1)
{
/*
* L1: Inclination pruning.
* bin_high is the upper bound on inclination in this bin.
* A satellite with inclination i has ground track [-i, +i].
* The observer at latitude phi can see it if:
* i + footprint >= |phi|
*
* Use the parent SMA range to compute a conservative footprint.
* The largest footprint comes from the HIGHEST altitude (footprint
* grows with altitude: GEO sees 71+ degrees, LEO sees ~7 degrees).
* Use sma_high for conservatism never prune objects that the
* leaf filter would accept.
*/
double obs_lat = fabs(win.obs.lat);
double sma_for_footprint;
double footprint;
if (parent_trav)
sma_for_footprint = parent_trav->sma_high;
else
sma_for_footprint = 50000.0; /* above GEO — maximum footprint */
footprint = ground_footprint_deg(sma_for_footprint,
win.min_el_deg) * DEG_TO_RAD;
if (bin_high + footprint < obs_lat)
dominated = true;
}
if (!dominated)
{
int idx = out->nNodes;
/* Build child traversal state */
child_trav = (OrbitalTraversal *)
MemoryContextAlloc(in->traversalMemoryContext,
sizeof(OrbitalTraversal));
if (parent_trav)
memcpy(child_trav, parent_trav, sizeof(OrbitalTraversal));
else
{
child_trav->sma_low = 0.0;
child_trav->sma_high = INFINITY;
child_trav->inc_low = 0.0;
child_trav->inc_high = M_PI;
}
/* Narrow bounds based on current level */
if (level == 0)
{
child_trav->sma_low = bin_low;
child_trav->sma_high = bin_high;
}
else if (level == 1)
{
child_trav->inc_low = bin_low;
child_trav->inc_high = bin_high;
}
out->nodeNumbers[idx] = i;
out->levelAdds[idx] = 1;
out->traversalValues[idx] = child_trav;
out->nNodes++;
}
}
PG_RETURN_VOID();
}
/* ----------------------------------------------------------------
* Shared filter: three-stage visibility check on a single TLE.
*
* 1. Perigee altitude check (with eccentricity)
* 2. Inclination + ground footprint vs observer latitude
* 3. RAAN query-time filter (J2 precession to query midpoint)
*
* Called from both leaf_consistent (index scan) and
* tle_visibility_possible (sequential scan / standalone operator).
* ----------------------------------------------------------------
*/
static bool
tle_passes_visibility_filter(const pg_tle *tle, const ObserverWindow *win)
{
double sma, perigee_alt, max_alt;
double obs_lat_abs, footprint_rad;
double dt_days, raan_projected, lst;
double earth_rot_rad, raan_window_half, raan_diff;
/* Reject degenerate TLEs (decay, error data) */
if (tle->mean_motion <= 0.0)
return false;
sma = tle_sma_km(tle);
/* Filter 1: perigee altitude */
perigee_alt = sma * (1.0 - tle->eccentricity) - WGS72_AE;
max_alt = max_visible_altitude_km(win->min_el_deg);
if (perigee_alt > max_alt)
return false;
/* Filter 2: inclination + footprint vs observer latitude */
obs_lat_abs = fabs(win->obs.lat);
footprint_rad = ground_footprint_deg(sma, win->min_el_deg) * DEG_TO_RAD;
if (tle->inclination + footprint_rad < obs_lat_abs)
return false;
/* Filter 3: RAAN alignment via J2 secular precession */
dt_days = win->jd_mid - tle->epoch;
raan_projected = tle->raan
+ j2_raan_rate(sma, tle->inclination) * dt_days;
raan_projected = fmod(raan_projected, 2.0 * M_PI);
if (raan_projected < 0.0)
raan_projected += 2.0 * M_PI;
/* Observer LST at query midpoint */
lst = gmst_from_jd(win->jd_mid) + win->obs.lon;
lst = fmod(lst, 2.0 * M_PI);
if (lst < 0.0)
lst += 2.0 * M_PI;
/* RAAN window: Earth rotation during query + footprint pad */
earth_rot_rad = (win->jd_end - win->jd_start) * EARTH_ROT_RAD_PER_DAY;
raan_window_half = earth_rot_rad / 2.0
+ ground_footprint_deg(sma, win->min_el_deg) * DEG_TO_RAD;
if (raan_window_half >= M_PI)
return true; /* full rotation -- pass everything */
raan_diff = fabs(raan_projected - lst);
if (raan_diff > M_PI)
raan_diff = 2.0 * M_PI - raan_diff;
return (raan_diff <= raan_window_half);
}
/*
* spgist_tle_leaf_consistent -- final check on a leaf TLE
*
* Delegates to tle_passes_visibility_filter() for the &? operator.
* recheck = false: the &? operator IS the superset filter.
* The user runs predict_passes() on survivors for SGP4 ground truth.
*/
Datum
spgist_tle_leaf_consistent(PG_FUNCTION_ARGS)
{
spgLeafConsistentIn *in = (spgLeafConsistentIn *) PG_GETARG_POINTER(0);
spgLeafConsistentOut *out = (spgLeafConsistentOut *) PG_GETARG_POINTER(1);
pg_tle *tle;
int i;
bool result = true;
tle = (pg_tle *) DatumGetPointer(in->leafDatum);
out->leafValue = in->leafDatum;
out->recheck = false;
for (i = 0; i < in->nkeys; i++)
{
if (in->scankeys[i].sk_strategy == 1)
{
ObserverWindow win;
HeapTupleHeader composite;
composite = DatumGetHeapTupleHeader(in->scankeys[i].sk_argument);
extract_observer_window(composite, &win);
if (!tle_passes_visibility_filter(tle, &win))
{
result = false;
break;
}
}
}
PG_RETURN_BOOL(result);
}
/* ================================================================
* Operator function: &? (visibility cone check)
* ================================================================
*/
/*
* tle_visibility_possible(tle, observer_window) -> bool
*
* Standalone operator: can the satellite possibly be visible from
* this observer during this time window? Combines altitude check,
* latitude/inclination check, and RAAN filter.
*
* This is the same logic as leaf_consistent, callable directly
* as a SQL operator for sequential scans or WHERE clauses.
*
* The indexed column (tle) MUST be the left argument so that
* PostgreSQL can form a ScanKey and pass it to inner_consistent
* for tree-level pruning. See skey.h:23-26.
*/
Datum
tle_visibility_possible(PG_FUNCTION_ARGS)
{
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
HeapTupleHeader composite = PG_GETARG_HEAPTUPLEHEADER(1);
ObserverWindow win;
extract_observer_window(composite, &win);
PG_RETURN_BOOL(tle_passes_visibility_filter(tle, &win));
}

View File

@ -6,8 +6,10 @@
* local hour angle, and converts to topocentric azimuth/elevation.
*
* Range and range_rate are zero -- stars are effectively at infinity.
* For objects with known proper motion, apply it to (RA, Dec) before
* calling star_observe.
*
* star_observe / star_observe_safe: catalog J2000 coords only.
* star_observe_pm / star_equatorial_pm: proper motion, parallax, RV.
* star_equatorial: catalog J2000 to apparent equatorial of date.
*/
#include "postgres.h"
@ -15,9 +17,13 @@
#include "utils/timestamp.h"
#include "types.h"
#include "astro_math.h"
#include "vsop87.h"
PG_FUNCTION_INFO_V1(star_observe);
PG_FUNCTION_INFO_V1(star_observe_safe);
PG_FUNCTION_INFO_V1(star_observe_pm);
PG_FUNCTION_INFO_V1(star_equatorial_pm);
PG_FUNCTION_INFO_V1(star_equatorial);
/*
* star_observe(ra_hours, dec_degrees, observer, timestamptz) -> topocentric
@ -120,3 +126,274 @@ star_observe_safe(PG_FUNCTION_ARGS)
PG_RETURN_POINTER(result);
}
/*
* star_observe_pm(ra_hours, dec_deg, pm_ra_masyr, pm_dec_masyr,
* parallax_mas, rv_kms, observer, timestamptz) -> topocentric
*
* Full star observation with proper motion correction applied before
* IAU 1976 precession. pm_ra is mu_alpha*cos(delta) in mas/yr
* (Hipparcos/Gaia convention). Parallax and radial velocity accepted
* for API symmetry with star_equatorial_pm but not yet applied to
* the topocentric result (would require Earth's heliocentric position).
*/
Datum
star_observe_pm(PG_FUNCTION_ARGS)
{
double ra_hours = PG_GETARG_FLOAT8(0);
double dec_deg = PG_GETARG_FLOAT8(1);
double pm_ra_masyr = PG_GETARG_FLOAT8(2);
double pm_dec_masyr = PG_GETARG_FLOAT8(3);
double parallax_mas = PG_GETARG_FLOAT8(4);
double rv_kms = PG_GETARG_FLOAT8(5);
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(6);
int64 ts = PG_GETARG_INT64(7);
double jd, dt_years;
double ra_j2000, dec_j2000;
double cos_dec, ra_corrected, dec_corrected;
double ra_date, dec_date;
double gmst, lst, ha;
double az, el;
pg_topocentric *result;
if (ra_hours < 0.0 || ra_hours >= 24.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("right ascension out of range: %.6f", ra_hours),
errhint("RA must be in [0, 24) hours.")));
if (dec_deg < -90.0 || dec_deg > 90.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("declination out of range: %.6f", dec_deg),
errhint("Declination must be between -90 and +90 degrees.")));
jd = timestamptz_to_jd(ts);
dt_years = (jd - J2000_JD) / 365.25;
if (fabs(dt_years) > 200.0)
ereport(NOTICE,
(errmsg("proper motion extrapolation %.0f years from J2000 — accuracy degrades beyond ~200 years", dt_years)));
ra_j2000 = ra_hours * (M_PI / 12.0);
dec_j2000 = dec_deg * DEG_TO_RAD;
/* Apply proper motion (linear in J2000 frame).
* pm_ra is mu_alpha*cos(delta), so divide by cos(dec) to get dRA. */
cos_dec = cos(dec_j2000);
if (fabs(cos_dec) < cos(89.99 * DEG_TO_RAD))
cos_dec = (cos_dec >= 0.0 ? 1.0 : -1.0) * cos(89.99 * DEG_TO_RAD);
ra_corrected = ra_j2000 + (pm_ra_masyr / 3.6e6) * DEG_TO_RAD / cos_dec * dt_years;
dec_corrected = dec_j2000 + (pm_dec_masyr / 3.6e6) * DEG_TO_RAD * dt_years;
if (dec_corrected > M_PI / 2.0)
dec_corrected = M_PI / 2.0;
if (dec_corrected < -M_PI / 2.0)
dec_corrected = -M_PI / 2.0;
ra_corrected = fmod(ra_corrected, 2.0 * M_PI);
if (ra_corrected < 0.0)
ra_corrected += 2.0 * M_PI;
/* Annual parallax: displace star position by Earth's heliocentric
* position projected onto the star direction.
* Green (1985), Eq. 11.3: d_RA and d_Dec from parallax. */
if (parallax_mas > 0.0)
{
double earth_xyz[6], earth_equ[3];
double p_rad = (parallax_mas / 1000.0) * ARCSEC_TO_RAD;
double sin_ra_c = sin(ra_corrected);
double cos_ra_c = cos(ra_corrected);
double sin_dec_c = sin(dec_corrected);
double cos_dec_c = cos(dec_corrected);
GetVsop87Coor(jd, 2, earth_xyz);
ecliptic_to_equatorial(earth_xyz, earth_equ);
/* Parallax displacement (Green 1985 Eq. 11.3) */
if (fabs(cos_dec_c) > 1e-12)
{
ra_corrected += p_rad * (earth_equ[0] * sin_ra_c
- earth_equ[1] * cos_ra_c) / cos_dec_c;
dec_corrected += p_rad * (earth_equ[0] * cos_ra_c * sin_dec_c
+ earth_equ[1] * sin_ra_c * sin_dec_c
- earth_equ[2] * cos_dec_c);
}
ra_corrected = fmod(ra_corrected, 2.0 * M_PI);
if (ra_corrected < 0.0)
ra_corrected += 2.0 * M_PI;
}
(void) rv_kms;
precess_j2000_to_date(jd, ra_corrected, dec_corrected, &ra_date, &dec_date);
gmst = gmst_from_jd(jd);
lst = gmst + obs->lon;
ha = lst - ra_date;
equatorial_to_horizontal(ha, dec_date, obs->lat, &az, &el);
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
result->azimuth = az;
result->elevation = el;
result->range_km = 0.0;
result->range_rate = 0.0;
PG_RETURN_POINTER(result);
}
/*
* star_equatorial_pm(ra_hours, dec_deg, pm_ra_masyr, pm_dec_masyr,
* parallax_mas, rv_kms, timestamptz) -> equatorial
*
* Proper-motion-corrected apparent equatorial coordinates of date.
* No observer needed (geocentric). If parallax > 0, distance is
* derived as 1000/parallax_mas parsecs converted to km.
*/
Datum
star_equatorial_pm(PG_FUNCTION_ARGS)
{
double ra_hours = PG_GETARG_FLOAT8(0);
double dec_deg = PG_GETARG_FLOAT8(1);
double pm_ra_masyr = PG_GETARG_FLOAT8(2);
double pm_dec_masyr = PG_GETARG_FLOAT8(3);
double parallax_mas = PG_GETARG_FLOAT8(4);
double rv_kms = PG_GETARG_FLOAT8(5);
int64 ts = PG_GETARG_INT64(6);
double jd, dt_years;
double ra_j2000, dec_j2000;
double cos_dec, ra_corrected, dec_corrected;
double ra_date, dec_date;
pg_equatorial *result;
(void) rv_kms;
if (ra_hours < 0.0 || ra_hours >= 24.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("right ascension out of range: %.6f", ra_hours),
errhint("RA must be in [0, 24) hours.")));
if (dec_deg < -90.0 || dec_deg > 90.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("declination out of range: %.6f", dec_deg),
errhint("Declination must be between -90 and +90 degrees.")));
jd = timestamptz_to_jd(ts);
dt_years = (jd - J2000_JD) / 365.25;
if (fabs(dt_years) > 200.0)
ereport(NOTICE,
(errmsg("proper motion extrapolation %.0f years from J2000 — accuracy degrades beyond ~200 years", dt_years)));
ra_j2000 = ra_hours * (M_PI / 12.0);
dec_j2000 = dec_deg * DEG_TO_RAD;
cos_dec = cos(dec_j2000);
if (fabs(cos_dec) < cos(89.99 * DEG_TO_RAD))
cos_dec = (cos_dec >= 0.0 ? 1.0 : -1.0) * cos(89.99 * DEG_TO_RAD);
ra_corrected = ra_j2000 + (pm_ra_masyr / 3.6e6) * DEG_TO_RAD / cos_dec * dt_years;
dec_corrected = dec_j2000 + (pm_dec_masyr / 3.6e6) * DEG_TO_RAD * dt_years;
if (dec_corrected > M_PI / 2.0)
dec_corrected = M_PI / 2.0;
if (dec_corrected < -M_PI / 2.0)
dec_corrected = -M_PI / 2.0;
ra_corrected = fmod(ra_corrected, 2.0 * M_PI);
if (ra_corrected < 0.0)
ra_corrected += 2.0 * M_PI;
/* Annual parallax displacement (Green 1985 Eq. 11.3) */
if (parallax_mas > 0.0)
{
double earth_xyz[6], earth_equ[3];
double p_rad = (parallax_mas / 1000.0) * ARCSEC_TO_RAD;
double sin_ra_c = sin(ra_corrected);
double cos_ra_c = cos(ra_corrected);
double sin_dec_c = sin(dec_corrected);
double cos_dec_c = cos(dec_corrected);
GetVsop87Coor(jd, 2, earth_xyz);
ecliptic_to_equatorial(earth_xyz, earth_equ);
if (fabs(cos_dec_c) > 1e-12)
{
ra_corrected += p_rad * (earth_equ[0] * sin_ra_c
- earth_equ[1] * cos_ra_c) / cos_dec_c;
dec_corrected += p_rad * (earth_equ[0] * cos_ra_c * sin_dec_c
+ earth_equ[1] * sin_ra_c * sin_dec_c
- earth_equ[2] * cos_dec_c);
}
ra_corrected = fmod(ra_corrected, 2.0 * M_PI);
if (ra_corrected < 0.0)
ra_corrected += 2.0 * M_PI;
}
precess_j2000_to_date(jd, ra_corrected, dec_corrected, &ra_date, &dec_date);
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
result->ra = ra_date;
result->dec = dec_date;
/* Distance from parallax: d_pc = 1000/parallax_mas, d_AU = d_pc * 206265 */
if (parallax_mas > 0.0)
result->distance = (1000.0 / parallax_mas) * 206265.0 * AU_KM;
else
result->distance = 0.0;
PG_RETURN_POINTER(result);
}
/*
* star_equatorial(ra_hours, dec_deg, timestamptz) -> equatorial
*
* Precess J2000 catalog coordinates to apparent equatorial of date.
* Distance is zero (no parallax information).
*/
Datum
star_equatorial(PG_FUNCTION_ARGS)
{
double ra_hours = PG_GETARG_FLOAT8(0);
double dec_deg = PG_GETARG_FLOAT8(1);
int64 ts = PG_GETARG_INT64(2);
double jd, ra_j2000, dec_j2000, ra_date, dec_date;
pg_equatorial *result;
if (ra_hours < 0.0 || ra_hours >= 24.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("right ascension out of range: %.6f", ra_hours),
errhint("RA must be in [0, 24) hours.")));
if (dec_deg < -90.0 || dec_deg > 90.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("declination out of range: %.6f", dec_deg),
errhint("Declination must be between -90 and +90 degrees.")));
jd = timestamptz_to_jd(ts);
ra_j2000 = ra_hours * (M_PI / 12.0);
dec_j2000 = dec_deg * DEG_TO_RAD;
precess_j2000_to_date(jd, ra_j2000, dec_j2000, &ra_date, &dec_date);
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
result->ra = ra_date;
result->dec = dec_date;
result->distance = 0.0;
PG_RETURN_POINTER(result);
}

View File

@ -75,10 +75,13 @@ typedef struct pg_tle
char classification; /* U = unclassified */
char ephemeris_type; /* 0 = SGP4/SDP4 default */
char intl_desig[9]; /* international designator, null-terminated */
char _pad; /* alignment */
char _pad; /* alignment to int32 boundary */
char _reserved[8]; /* match INTERNALLENGTH = 112 */
} pg_tle;
/* Size: 11 doubles (88 bytes) + 3 int32 (12 bytes) + 12 chars = 112 bytes */
/* Size: 10 doubles (80) + 3 int32 (12) + 12 chars + 8 reserved = 112 bytes
* Must match INTERNALLENGTH in CREATE TYPE. PostgreSQL's datumCopy() and
* heap_form_tuple() copy exactly typlen bytes from any pg_tle pointer. */
/*
@ -200,6 +203,50 @@ typedef struct pg_heliocentric
} pg_heliocentric;
/*
* Orbital elements -- classical Keplerian elements for comets/asteroids
*
* Stores osculation epoch, perihelion distance, eccentricity,
* three angular elements (radians), perihelion passage time,
* and optional photometric parameters (H magnitude, G slope).
* NaN in h_mag/g_slope means "unknown".
*/
typedef struct pg_orbital_elements
{
double epoch; /* osculation epoch, JD UTC */
double q; /* perihelion distance, AU */
double e; /* eccentricity */
double inc; /* inclination, radians */
double arg_peri; /* argument of perihelion (omega), radians */
double raan; /* longitude of ascending node (Omega), radians */
double tp; /* time of perihelion passage, JD UTC */
double h_mag; /* absolute magnitude H (NaN if unknown) */
double g_slope; /* slope parameter G (NaN if unknown) */
} pg_orbital_elements;
/* 9 doubles = 72 bytes, must match INTERNALLENGTH in CREATE TYPE */
/*
* Equatorial coordinates -- apparent RA/Dec of date
*
* Right ascension and declination in radians (internal), displayed as
* hours [0,24) and degrees [-90,90]. Distance in km.
*
* Frame: apparent (of date). Solar system: J2000 precessed via IAU 1976.
* Satellites: TEME (~mean equator of date, residual nutation ~arcsec).
* Matches what GoTo telescope mounts and sky apps expect.
*/
typedef struct pg_equatorial
{
double ra; /* radians, [0, 2*pi) */
double dec; /* radians, [-pi/2, pi/2] */
double distance; /* km (solar system: geo_dist * AU_KM; stars: 0.0) */
} pg_equatorial;
/* 3 doubles = 24 bytes, must match INTERNALLENGTH in CREATE TYPE */
/*
* Astronomical constants
*/
@ -207,6 +254,7 @@ typedef struct pg_heliocentric
#define GAUSS_K 0.01720209895 /* gravitational constant, AU^(3/2)/day */
#define GAUSS_K2 (GAUSS_K * GAUSS_K)
#define OBLIQUITY_J2000 0.40909280422232897 /* 23.4392911 deg in radians */
#define C_LIGHT_AU_DAY 173.1446327 /* speed of light, AU/day (299792.458 * 86400 / 149597870.7) */
/*
* Solar system body IDs (VSOP87 convention, extended)

View File

@ -0,0 +1,247 @@
-- aberration regression tests
--
-- Tests annual aberration in _apparent() functions, DE apparent variants,
-- equatorial angular distance/cone search, and stellar annual parallax.
\set boulder '''40.015N 105.270W 1655m'''::observer
-- ============================================================
-- Test 1: Aberration magnitude — planet_equatorial_apparent
-- vs planet_equatorial (geometric). Jupiter aberration should
-- be in the range 0-20 arcsec (~0.001 hours at Jupiter's dec).
-- ============================================================
SELECT 'aberration_planet' AS test,
round((abs(
eq_ra(planet_equatorial_apparent(5, '2024-06-21 12:00:00+00'))
- eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))
) * 3600 * 15)::numeric, 0) AS diff_arcsec,
abs(
eq_ra(planet_equatorial_apparent(5, '2024-06-21 12:00:00+00'))
- eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))
) * 3600 * 15 BETWEEN 1 AND 50 AS magnitude_valid;
test | diff_arcsec | magnitude_valid
-------------------+-------------+-----------------
aberration_planet | 29 | t
(1 row)
-- ============================================================
-- Test 2: Aberration magnitude — sun_observe_apparent vs sun_observe
-- Sun aberration should be ~20 arcsec (Earth orbital velocity).
-- Compare elevations (both from same observer, same time).
-- ============================================================
SELECT 'aberration_sun' AS test,
round((abs(
topo_elevation(sun_observe_apparent(:boulder, '2024-06-21 12:00:00+00'))
- topo_elevation(sun_observe(:boulder, '2024-06-21 12:00:00+00'))
) * 3600)::numeric, 0) AS diff_arcsec,
abs(
topo_elevation(sun_observe_apparent(:boulder, '2024-06-21 12:00:00+00'))
- topo_elevation(sun_observe(:boulder, '2024-06-21 12:00:00+00'))
) * 3600 BETWEEN 1 AND 25 AS magnitude_valid;
test | diff_arcsec | magnitude_valid
----------------+-------------+-----------------
aberration_sun | 15 | t
(1 row)
-- ============================================================
-- Test 3: Moon aberration should be present (same ~20 arcsec
-- as all other objects — aberration depends on observer velocity,
-- not object distance).
-- ============================================================
SELECT 'aberration_moon' AS test,
round((abs(
eq_ra(moon_equatorial_apparent('2024-06-21 12:00:00+00'))
- eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))
) * 3600 * 15)::numeric, 0) AS diff_arcsec,
abs(
eq_ra(moon_equatorial_apparent('2024-06-21 12:00:00+00'))
- eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))
) * 3600 * 15 BETWEEN 1 AND 25 AS magnitude_valid;
test | diff_arcsec | magnitude_valid
-----------------+-------------+-----------------
aberration_moon | 22 | t
(1 row)
-- ============================================================
-- Test 4: DE apparent fallback — without DE configured,
-- _apparent_de() should match _apparent() exactly.
-- ============================================================
SELECT 'de_apparent_fallback' AS test,
round(eq_ra(planet_equatorial_apparent_de(5, '2024-06-21 12:00:00+00'))::numeric, 6) =
round(eq_ra(planet_equatorial_apparent(5, '2024-06-21 12:00:00+00'))::numeric, 6) AS planet_match,
round(eq_ra(moon_equatorial_apparent_de('2024-06-21 12:00:00+00'))::numeric, 6) =
round(eq_ra(moon_equatorial_apparent('2024-06-21 12:00:00+00'))::numeric, 6) AS moon_match;
test | planet_match | moon_match
----------------------+--------------+------------
de_apparent_fallback | t | t
(1 row)
-- ============================================================
-- Test 5: DE apparent topocentric fallback
-- ============================================================
SELECT 'de_topo_fallback' AS test,
round(topo_elevation(planet_observe_apparent_de(5, :boulder, '2024-06-21 12:00:00+00'))::numeric, 4) =
round(topo_elevation(planet_observe_apparent(5, :boulder, '2024-06-21 12:00:00+00'))::numeric, 4) AS planet_match,
round(topo_elevation(sun_observe_apparent_de(:boulder, '2024-06-21 12:00:00+00'))::numeric, 4) =
round(topo_elevation(sun_observe_apparent(:boulder, '2024-06-21 12:00:00+00'))::numeric, 4) AS sun_match,
topo_elevation(moon_observe_apparent_de(:boulder, '2024-06-21 12:00:00+00')) BETWEEN -90 AND 90 AS moon_valid;
test | planet_match | sun_match | moon_valid
------------------+--------------+-----------+------------
de_topo_fallback | t | t | t
(1 row)
-- ============================================================
-- Test 6: Small body DE apparent fallback
-- ============================================================
SELECT 'de_smallbody_fallback' AS test,
round(topo_elevation(small_body_observe_apparent_de(
'(2460400.5,2.5577,0.0785,0.1849,1.2836,1.4013,2460500.0,3.53,0.12)'::orbital_elements,
:boulder, '2024-06-21 12:00:00+00'))::numeric, 4) =
round(topo_elevation(small_body_observe_apparent(
'(2460400.5,2.5577,0.0785,0.1849,1.2836,1.4013,2460500.0,3.53,0.12)'::orbital_elements,
:boulder, '2024-06-21 12:00:00+00'))::numeric, 4) AS match;
test | match
-----------------------+-------
de_smallbody_fallback | t
(1 row)
-- ============================================================
-- Test 7: Angular distance — Dubhe and Merak (Big Dipper pointers)
-- Dubhe: RA 11.062h, Dec 61.751 deg
-- Merak: RA 11.031h, Dec 56.382 deg
-- Expected separation: ~5.4 degrees
-- ============================================================
SELECT 'angular_distance' AS test,
round(eq_angular_distance(
star_equatorial(11.062, 61.751, '2024-06-21 12:00:00+00'),
star_equatorial(11.031, 56.382, '2024-06-21 12:00:00+00')
)::numeric, 1) AS sep_deg;
test | sep_deg
------------------+---------
angular_distance | 5.4
(1 row)
-- ============================================================
-- Test 8: Angular distance — same position should be 0
-- ============================================================
SELECT 'angular_distance_zero' AS test,
round(eq_angular_distance(
'(12.00000000,45.00000000,0.000)'::equatorial,
'(12.00000000,45.00000000,0.000)'::equatorial
)::numeric, 6) AS sep_deg;
test | sep_deg
-----------------------+----------
angular_distance_zero | 0.000000
(1 row)
-- ============================================================
-- Test 9: Angular distance — opposite poles should be 180
-- ============================================================
SELECT 'angular_distance_poles' AS test,
round(eq_angular_distance(
'(0.00000000,90.00000000,0.000)'::equatorial,
'(0.00000000,-90.00000000,0.000)'::equatorial
)::numeric, 1) AS sep_deg;
test | sep_deg
------------------------+---------
angular_distance_poles | 180.0
(1 row)
-- ============================================================
-- Test 10: <-> operator (same as eq_angular_distance)
-- ============================================================
SELECT 'operator_arrow' AS test,
round((
star_equatorial(11.062, 61.751, '2024-06-21 12:00:00+00')
<->
star_equatorial(11.031, 56.382, '2024-06-21 12:00:00+00')
)::numeric, 1) AS sep_deg;
test | sep_deg
----------------+---------
operator_arrow | 5.4
(1 row)
-- ============================================================
-- Test 11: Cone search — Polaris within 5 deg of NCP
-- ============================================================
SELECT 'cone_inside' AS test,
eq_within_cone(
star_equatorial(2.530303, 89.2641, '2024-06-21 12:00:00+00'),
'(0.00000000,90.00000000,0.000)'::equatorial,
5.0
) AS inside;
test | inside
-------------+--------
cone_inside | t
(1 row)
-- ============================================================
-- Test 12: Cone search — Sirius not within 5 deg of NCP
-- ============================================================
SELECT 'cone_outside' AS test,
eq_within_cone(
star_equatorial(6.7525, -16.7161, '2024-06-21 12:00:00+00'),
'(0.00000000,90.00000000,0.000)'::equatorial,
5.0
) AS inside;
test | inside
--------------+--------
cone_outside | f
(1 row)
-- ============================================================
-- Test 13: Stellar parallax — Proxima Centauri (768 mas)
-- Compare with-parallax vs without-parallax at the SAME epoch
-- to isolate the parallax displacement from proper motion and
-- precession. Expected: ~0.2-1.5 arcsec depending on Earth's
-- orbital phase (max near quadrature for this RA).
-- Proxima: RA 14.495h, Dec -62.679 deg
-- ============================================================
SELECT 'stellar_parallax' AS test,
round((abs(
eq_ra(star_equatorial_pm(14.495, -62.679, -3775.40, 769.33, 768.07, -21.7,
'2024-03-20 12:00:00+00'))
- eq_ra(star_equatorial_pm(14.495, -62.679, -3775.40, 769.33, 0.0, -21.7,
'2024-03-20 12:00:00+00'))
) * 3600 * 15)::numeric, 2) AS shift_arcsec,
abs(
eq_ra(star_equatorial_pm(14.495, -62.679, -3775.40, 769.33, 768.07, -21.7,
'2024-03-20 12:00:00+00'))
- eq_ra(star_equatorial_pm(14.495, -62.679, -3775.40, 769.33, 0.0, -21.7,
'2024-03-20 12:00:00+00'))
) * 3600 * 15 BETWEEN 0.01 AND 2.0 AS magnitude_valid;
test | shift_arcsec | magnitude_valid
------------------+--------------+-----------------
stellar_parallax | 1.02 | t
(1 row)
-- ============================================================
-- Test 14: Parallax = 0 should not change star position
-- (same as without parallax)
-- ============================================================
SELECT 'parallax_zero' AS test,
round(eq_ra(star_equatorial_pm(14.495, -62.679, -3775.40, 769.33, 0.0, -21.7,
'2024-06-21 12:00:00+00'))::numeric, 6) =
round(eq_ra(star_equatorial_pm(14.495, -62.679, -3775.40, 769.33, 0.0, -21.7,
'2024-06-21 12:00:00+00'))::numeric, 6) AS match;
test | match
---------------+-------
parallax_zero | t
(1 row)
-- ============================================================
-- Test 15: star_observe_pm parallax affects topocentric result
-- Barnard's Star with parallax should differ from without
-- ============================================================
SELECT 'parallax_topo' AS test,
abs(
topo_elevation(star_observe_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
:boulder, '2024-07-15 04:00:00+00'))
- topo_elevation(star_observe_pm(
17.963472, 4.6933, -798.58, 10328.12, 0.0, -110.51,
:boulder, '2024-07-15 04:00:00+00'))
) * 3600 BETWEEN 0.01 AND 2.0 AS displacement_valid;
test | displacement_valid
---------------+--------------------
parallax_topo | t
(1 row)

View File

@ -0,0 +1,280 @@
-- equatorial type regression tests
--
-- Tests equatorial type I/O, accessor functions, satellite RA/Dec,
-- planet/Sun/Moon/small body equatorial coordinates, stellar
-- precession, proper motion, light-time correction, and DE variants.
\set boulder '''40.015N 105.270W 1655m'''::observer
-- ============================================================
-- Test 1: equatorial type I/O round-trip
-- ============================================================
SELECT 'eq_io' AS test,
'(12.00000000,45.00000000,0.000)'::equatorial::text AS val;
test | val
-------+---------------------------------
eq_io | (12.00000000,45.00000000,0.000)
(1 row)
-- ============================================================
-- Test 2: equatorial accessor functions
-- ============================================================
SELECT 'eq_accessors' AS test,
round(eq_ra(e)::numeric, 4) AS ra_hours,
round(eq_dec(e)::numeric, 4) AS dec_deg,
round(eq_distance(e)::numeric, 1) AS dist_km
FROM (SELECT '(6.50000000,-23.00000000,149597870.700)'::equatorial AS e) sub;
test | ra_hours | dec_deg | dist_km
--------------+----------+----------+-------------
eq_accessors | 6.5000 | -23.0000 | 149597870.7
(1 row)
-- ============================================================
-- Test 3: equatorial input validation - RA out of range
-- ============================================================
SELECT 'eq_bad_ra' AS test, '(25.0,0.0,0.0)'::equatorial;
ERROR: right ascension out of range: 25.000000
LINE 1: SELECT 'eq_bad_ra' AS test, '(25.0,0.0,0.0)'::equatorial;
^
HINT: RA must be in [0, 24) hours.
-- ============================================================
-- Test 4: equatorial input validation - Dec out of range
-- ============================================================
SELECT 'eq_bad_dec' AS test, '(12.0,91.0,0.0)'::equatorial;
ERROR: declination out of range: 91.000000
LINE 1: SELECT 'eq_bad_dec' AS test, '(12.0,91.0,0.0)'::equatorial;
^
HINT: Declination must be between -90 and +90 degrees.
-- ============================================================
-- Test 5: Sun equatorial RA/Dec at J2000.0
-- At 2000-01-01 12:00:00 UTC the Sun should be near RA ~18.75h, Dec ~-23 deg
-- (winter solstice was ~10 days earlier)
-- ============================================================
SELECT 'sun_eq' AS test,
round(eq_ra(sun_equatorial('2000-01-01 12:00:00+00'))::numeric, 1) AS ra_h,
round(eq_dec(sun_equatorial('2000-01-01 12:00:00+00'))::numeric, 0) AS dec_deg;
test | ra_h | dec_deg
--------+------+---------
sun_eq | 18.8 | -23
(1 row)
-- ============================================================
-- Test 6: Sun equatorial at summer solstice ~2024
-- RA should be ~6h, Dec ~+23.4 deg
-- ============================================================
SELECT 'sun_eq_solstice' AS test,
round(eq_ra(sun_equatorial('2024-06-21 12:00:00+00'))::numeric, 0) AS ra_h,
round(eq_dec(sun_equatorial('2024-06-21 12:00:00+00'))::numeric, 0) AS dec_deg;
test | ra_h | dec_deg
-----------------+------+---------
sun_eq_solstice | 6 | 23
(1 row)
-- ============================================================
-- Test 7: Moon equatorial returns valid coordinates
-- Range should be 356k-407k km
-- ============================================================
SELECT 'moon_eq' AS test,
eq_ra(moon_equatorial('2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid,
eq_dec(moon_equatorial('2024-06-21 12:00:00+00')) BETWEEN -90 AND 90 AS dec_valid,
eq_distance(moon_equatorial('2024-06-21 12:00:00+00')) BETWEEN 350000 AND 410000 AS range_valid;
test | ra_valid | dec_valid | range_valid
---------+----------+-----------+-------------
moon_eq | t | t | t
(1 row)
-- ============================================================
-- Test 8: Jupiter equatorial returns valid coordinates
-- Distance should be ~588M-968M km (3.93-6.47 AU)
-- ============================================================
SELECT 'jupiter_eq' AS test,
eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid,
eq_dec(planet_equatorial(5, '2024-06-21 12:00:00+00')) BETWEEN -90 AND 90 AS dec_valid,
eq_distance(planet_equatorial(5, '2024-06-21 12:00:00+00')) BETWEEN 500000000 AND 1000000000 AS range_valid;
test | ra_valid | dec_valid | range_valid
------------+----------+-----------+-------------
jupiter_eq | t | t | t
(1 row)
-- ============================================================
-- Test 9: planet_equatorial error - cannot observe Earth
-- ============================================================
SELECT 'earth_eq_error' AS test, planet_equatorial(3, now());
ERROR: cannot observe Earth from Earth
-- ============================================================
-- Test 10: star_equatorial at J2000.0 (precession = identity)
-- Polaris RA 2.530303h Dec 89.2641 deg should be unchanged
-- ============================================================
SELECT 'star_eq_j2000' AS test,
round(eq_ra(star_equatorial(2.530303, 89.2641, '2000-01-01 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_dec(star_equatorial(2.530303, 89.2641, '2000-01-01 12:00:00+00'))::numeric, 4) AS dec_deg,
round(eq_distance(star_equatorial(2.530303, 89.2641, '2000-01-01 12:00:00+00'))::numeric, 1) AS dist;
test | ra_h | dec_deg | dist
---------------+--------+---------+------
star_eq_j2000 | 2.5303 | 89.2641 | 0.0
(1 row)
-- ============================================================
-- Test 11: star_equatorial with precession (25 years from J2000)
-- RA should shift slightly due to precession
-- ============================================================
SELECT 'star_eq_precessed' AS test,
round(eq_ra(star_equatorial(2.530303, 89.2641, '2025-06-15 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_dec(star_equatorial(2.530303, 89.2641, '2025-06-15 12:00:00+00'))::numeric, 4) AS dec_deg;
test | ra_h | dec_deg
-------------------+--------+---------
star_eq_precessed | 3.0836 | 89.3695
(1 row)
-- ============================================================
-- Test 12: star_equatorial_pm for Barnard's Star
-- Barnard's Star: RA 17.963472h, Dec 4.6933 deg
-- pm_ra = -798.58 mas/yr, pm_dec = 10328.12 mas/yr
-- parallax = 545.4 mas, rv = -110.51 km/s
-- After 25 years, Dec should shift by ~0.26 deg
-- ============================================================
SELECT 'barnard_pm' AS test,
round(eq_dec(star_equatorial_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
'2025-01-01 12:00:00+00'))::numeric, 2) AS dec_deg,
round(eq_distance(star_equatorial_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
'2025-01-01 12:00:00+00'))::numeric, -6) AS dist_km;
test | dec_deg | dist_km
------------+---------+----------------
barnard_pm | 4.76 | 56576466000000
(1 row)
-- ============================================================
-- Test 13: star_observe_pm returns valid topocentric
-- ============================================================
SELECT 'barnard_topo' AS test,
round(topo_azimuth(star_observe_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
:boulder, '2024-07-15 04:00:00+00'))::numeric, 0) AS az_deg,
round(topo_elevation(star_observe_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
:boulder, '2024-07-15 04:00:00+00'))::numeric, 0) AS el_deg;
test | az_deg | el_deg
--------------+--------+--------
barnard_topo | 146 | 50
(1 row)
-- ============================================================
-- Test 14: Satellite equatorial (geocentric) from ECI
-- ISS at known ECI: should get valid RA/Dec
-- ============================================================
SELECT 'sat_eq_geo' AS test,
eq_ra(eci_to_equatorial_geo(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
'2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid,
eq_dec(eci_to_equatorial_geo(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
'2024-06-21 12:00:00+00')) BETWEEN -90 AND 90 AS dec_valid;
test | ra_valid | dec_valid
------------+----------+-----------
sat_eq_geo | t | t
(1 row)
-- ============================================================
-- Test 15: Satellite equatorial (topocentric vs geocentric)
-- Topocentric should differ from geocentric by ~0.5-1 deg for LEO
-- ============================================================
SELECT 'sat_parallax' AS test,
round(abs(
eq_ra(eci_to_equatorial(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
:boulder, '2024-06-21 12:00:00+00'))
- eq_ra(eci_to_equatorial_geo(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
'2024-06-21 12:00:00+00'))
)::numeric, 2) AS parallax_hours;
test | parallax_hours
--------------+----------------
sat_parallax | 0.16
(1 row)
-- ============================================================
-- Test 16: All planets return valid equatorial coordinates
-- ============================================================
SELECT 'all_planets_eq' AS test,
body_id,
round(eq_ra(planet_equatorial(body_id, '2024-06-21 12:00:00+00'))::numeric, 2) AS ra_h,
round(eq_dec(planet_equatorial(body_id, '2024-06-21 12:00:00+00'))::numeric, 1) AS dec_deg
FROM generate_series(1, 8) AS body_id
WHERE body_id != 3;
test | body_id | ra_h | dec_deg
----------------+---------+-------+---------
all_planets_eq | 1 | 6.65 | 24.9
all_planets_eq | 2 | 6.38 | 23.9
all_planets_eq | 4 | 2.47 | 13.5
all_planets_eq | 5 | 4.29 | 20.6
all_planets_eq | 6 | 23.40 | -6.0
all_planets_eq | 7 | 3.53 | 18.8
all_planets_eq | 8 | 0.03 | -1.2
(7 rows)
-- ============================================================
-- Test 17: planet_equatorial_apparent (light-time corrected)
-- Jupiter's light-time is ~35-52 min; apparent RA should differ
-- from geometric by a small amount
-- ============================================================
SELECT 'jupiter_apparent' AS test,
round(abs(
eq_ra(planet_equatorial_apparent(5, '2024-06-21 12:00:00+00'))
- eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))
)::numeric, 4) AS ra_diff_hours;
test | ra_diff_hours
------------------+---------------
jupiter_apparent | 0.0005
(1 row)
-- ============================================================
-- Test 18: moon_equatorial_apparent (light-time ~1.3 sec)
-- Difference from geometric should be tiny
-- ============================================================
SELECT 'moon_apparent' AS test,
round(abs(
eq_ra(moon_equatorial_apparent('2024-06-21 12:00:00+00'))
- eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))
)::numeric, 6) AS ra_diff_hours;
test | ra_diff_hours
---------------+---------------
moon_apparent | 0.000405
(1 row)
-- ============================================================
-- Test 19: Small body equatorial with Ceres
-- Ceres orbital elements (approximate)
-- ============================================================
SELECT 'ceres_eq' AS test,
eq_ra(small_body_equatorial(
'(2460400.5,2.5577,0.0785,0.1849,1.2836,1.4013,2460500.0,3.53,0.12)'::orbital_elements,
'2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid;
test | ra_valid
----------+----------
ceres_eq | t
(1 row)
-- ============================================================
-- Test 20: DE equatorial variants (fall back to VSOP87)
-- Without DE configured, these should produce same results as VSOP87
-- ============================================================
SELECT 'de_planet_eq' AS test,
round(eq_ra(planet_equatorial_de(5, '2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h_vsop,
round(eq_ra(planet_equatorial_de(5, '2024-06-21 12:00:00+00'))::numeric, 4) =
round(eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))::numeric, 4) AS match;
test | ra_h | ra_h_vsop | match
--------------+--------+-----------+-------
de_planet_eq | 4.2922 | 4.2922 | t
(1 row)
SELECT 'de_moon_eq' AS test,
round(eq_ra(moon_equatorial_de('2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h_elp,
round(eq_ra(moon_equatorial_de('2024-06-21 12:00:00+00'))::numeric, 4) =
round(eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))::numeric, 4) AS match;
test | ra_h | ra_h_elp | match
------------+---------+----------+-------
de_moon_eq | 17.5281 | 17.5281 | t
(1 row)

View File

@ -42,20 +42,21 @@ ORDER BY a.name, b.name;
ISS | Hubble | f
(6 rows)
-- Altitude distance: ISS <-> Equatorial-LEO should be ~0 (same altitude shell)
-- Orbital distance: 2-D metric (altitude + inclination in km)
-- ISS <-> Equatorial-LEO should be ~5192 (0 km alt gap + 47° inc diff × 6378 km)
SELECT a.name AS sat_a, b.name AS sat_b,
round((a.tle <-> b.tle)::numeric, 0) AS alt_dist_km
round((a.tle <-> b.tle)::numeric, 0) AS orbital_dist_km
FROM test_orbits a, test_orbits b
WHERE a.id < b.id
ORDER BY a.name, b.name;
sat_a | sat_b | alt_dist_km
---------+----------------+-------------
GPS-IIR | Equatorial-LEO | 19451
Hubble | Equatorial-LEO | 115
Hubble | GPS-IIR | 19332
ISS | Equatorial-LEO | 0
ISS | GPS-IIR | 19451
ISS | Hubble | 115
sat_a | sat_b | orbital_dist_km
---------+----------------+-----------------
GPS-IIR | Equatorial-LEO | 20245
Hubble | Equatorial-LEO | 2615
Hubble | GPS-IIR | 19564
ISS | Equatorial-LEO | 5192
ISS | GPS-IIR | 19456
ISS | Hubble | 2582
(6 rows)
-- GiST index scan: find all sats overlapping ISS (altitude AND inclination)
@ -70,7 +71,7 @@ ORDER BY name;
(1 row)
RESET enable_seqscan;
-- Nearest-neighbor via GiST: order by altitude distance to ISS
-- Nearest-neighbor via GiST: order by 2-D orbital distance to ISS
SET enable_seqscan = off;
SELECT name, round((tle <-> (SELECT tle FROM test_orbits WHERE name = 'ISS'))::numeric, 0) AS dist
FROM test_orbits
@ -78,9 +79,9 @@ WHERE name != 'ISS'
ORDER BY tle <-> (SELECT tle FROM test_orbits WHERE name = 'ISS');
name | dist
----------------+-------
Equatorial-LEO | 0
Hubble | 115
GPS-IIR | 19451
Hubble | 2582
Equatorial-LEO | 5192
GPS-IIR | 19456
(3 rows)
RESET enable_seqscan;

View File

@ -0,0 +1,321 @@
-- orbital_elements regression tests
--
-- Tests orbital_elements type I/O, accessors, MPC parser,
-- small_body_heliocentric(), and small_body_observe().
CREATE EXTENSION IF NOT EXISTS pg_orrery;
NOTICE: extension "pg_orrery" already exists, skipping
\set boulder '''40.015N 105.270W 1655m'''::observer
-- ============================================================
-- Test 1: Type I/O round-trip
-- Construct orbital_elements from text literal, verify output matches.
-- Angles in degrees, stored as radians internally.
-- ============================================================
SELECT 'io_roundtrip' AS test,
'(2460600.000000,2.5478000000,0.0789126000,10.586640,73.429370,80.268600,2460319.000000,3.33,0.12)'::orbital_elements AS oe;
test | oe
--------------+---------------------------------------------------------------------------------------------------
io_roundtrip | (2460600.000000,2.5478000000,0.0789126000,10.586640,73.429370,80.268600,2460319.000000,3.33,0.12)
(1 row)
-- ============================================================
-- Test 2: Accessor functions (direct field access)
-- ============================================================
SELECT 'accessor_epoch' AS test,
round(oe_epoch('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 1) AS epoch_jd;
test | epoch_jd
----------------+-----------
accessor_epoch | 2460600.0
(1 row)
SELECT 'accessor_q' AS test,
round(oe_perihelion('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 4) AS q_au;
test | q_au
------------+--------
accessor_q | 2.5478
(1 row)
SELECT 'accessor_e' AS test,
round(oe_eccentricity('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 7) AS ecc;
test | ecc
------------+-----------
accessor_e | 0.0789126
(1 row)
SELECT 'accessor_inc' AS test,
round(oe_inclination('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 5) AS inc_deg;
test | inc_deg
--------------+----------
accessor_inc | 10.58664
(1 row)
SELECT 'accessor_omega' AS test,
round(oe_arg_perihelion('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 5) AS omega_deg;
test | omega_deg
----------------+-----------
accessor_omega | 73.42937
(1 row)
SELECT 'accessor_Omega' AS test,
round(oe_raan('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 4) AS Omega_deg;
test | omega_deg
----------------+-----------
accessor_Omega | 80.2686
(1 row)
SELECT 'accessor_tp' AS test,
round(oe_tp('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 1) AS tp_jd;
test | tp_jd
-------------+-----------
accessor_tp | 2460319.0
(1 row)
SELECT 'accessor_h' AS test,
round(oe_h_mag('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 2) AS h_mag;
test | h_mag
------------+-------
accessor_h | 3.33
(1 row)
SELECT 'accessor_g' AS test,
round(oe_g_slope('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 2) AS g_slope;
test | g_slope
------------+---------
accessor_g | 0.12
(1 row)
-- ============================================================
-- Test 3: Computed accessors
-- ============================================================
-- Semi-major axis: a = q / (1 - e) = 2.5478 / (1 - 0.0789126) ~ 2.766 AU
SELECT 'sma_elliptic' AS test,
round(oe_semi_major_axis('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 3) AS a_au;
test | a_au
--------------+-------
sma_elliptic | 2.766
(1 row)
-- Period: a^1.5 years. a ~ 2.766, period ~ 4.599 years
SELECT 'period_elliptic' AS test,
round(oe_period_years('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 2) AS period_yr;
test | period_yr
-----------------+-----------
period_elliptic | 4.60
(1 row)
-- Hyperbolic orbit (e=1.5): semi-major axis and period should be NULL
SELECT 'sma_hyperbolic' AS test,
oe_semi_major_axis('(2460600.0,1.0,1.5,10.0,73.0,80.0,2460319.0,NaN,NaN)'::orbital_elements) IS NULL AS is_null;
test | is_null
----------------+---------
sma_hyperbolic | t
(1 row)
SELECT 'period_hyperbolic' AS test,
oe_period_years('(2460600.0,1.0,1.5,10.0,73.0,80.0,2460319.0,NaN,NaN)'::orbital_elements) IS NULL AS is_null;
test | is_null
-------------------+---------
period_hyperbolic | t
(1 row)
-- ============================================================
-- Test 4: MPC parser -- (1) Ceres
--
-- MPCORB.DAT line for Ceres (epoch 2024 Oct 17.0 = K24AM):
-- Packed epoch K24AM -> K=2000, 24, A=Oct, M=22 -> 2024-10-22
-- (Actually: K=2000, year=24, month=A=10, day=M=22)
-- JD for 2024-10-22 = 2460605.5
--
-- a = 2.7660961 AU, e = 0.0789126
-- q = a*(1-e) = 2.7660961*(1-0.0789126) = 2.5478...
-- M = 60.07966 deg
-- ============================================================
SELECT 'mpc_ceres_sma' AS test,
round(oe_semi_major_axis(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 4) AS a_au;
test | a_au
---------------+--------
mpc_ceres_sma | 2.7661
(1 row)
SELECT 'mpc_ceres_q' AS test,
round(oe_perihelion(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 4) AS q_au;
test | q_au
-------------+--------
mpc_ceres_q | 2.5478
(1 row)
SELECT 'mpc_ceres_ecc' AS test,
round(oe_eccentricity(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 7) AS ecc;
test | ecc
---------------+-----------
mpc_ceres_ecc | 0.0789126
(1 row)
SELECT 'mpc_ceres_inc' AS test,
round(oe_inclination(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 5) AS inc_deg;
test | inc_deg
---------------+----------
mpc_ceres_inc | 10.58664
(1 row)
SELECT 'mpc_ceres_h' AS test,
round(oe_h_mag(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 2) AS h_mag;
test | h_mag
-------------+-------
mpc_ceres_h | 3.33
(1 row)
-- ============================================================
-- Test 5: small_body_heliocentric -- compare to kepler_propagate
-- Both should produce the same heliocentric position for Ceres.
-- ============================================================
SELECT 'helio_vs_kepler' AS test,
round(helio_x(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 6) AS x_au,
round(helio_y(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 6) AS y_au,
round(helio_z(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 6) AS z_au;
test | x_au | y_au | z_au
-----------------+-----------+-----------+----------
helio_vs_kepler | -1.430911 | -2.313853 | 0.190494
(1 row)
-- Verify heliocentric distance is reasonable (Ceres orbits ~2.5-3.0 AU)
SELECT 'helio_distance' AS test,
round(helio_distance(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 2) AS dist_au,
helio_distance(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
)) BETWEEN 2.5 AND 3.0 AS in_range;
test | dist_au | in_range
----------------+---------+----------
helio_distance | 2.73 | t
(1 row)
-- ============================================================
-- Test 6: small_body_observe -- topocentric observation
-- Verify we get reasonable az/el/range for Ceres from Boulder.
-- Range should be on the order of 1.5-4.5 AU in km.
-- ============================================================
SELECT 'observe_range' AS test,
topo_range(small_body_observe(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
:boulder, '2025-01-01 00:00:00+00'
)) BETWEEN 1.5 * 149597870.7 AND 4.5 * 149597870.7 AS range_reasonable;
test | range_reasonable
---------------+------------------
observe_range | t
(1 row)
-- Elevation should be a finite number
SELECT 'observe_elevation' AS test,
topo_elevation(small_body_observe(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
:boulder, '2025-01-01 00:00:00+00'
)) BETWEEN -90 AND 90 AS el_reasonable;
test | el_reasonable
-------------------+---------------
observe_elevation | t
(1 row)
-- ============================================================
-- Test 7: Parabolic/hyperbolic elements via text I/O
-- Verify type handles e >= 1 without error.
-- ============================================================
SELECT 'parabolic_io' AS test,
oe_eccentricity('(2460600.0,1.0,1.0,45.0,90.0,180.0,2460500.0,12.0,0.15)'::orbital_elements) AS ecc;
test | ecc
--------------+-----
parabolic_io | 1
(1 row)
SELECT 'hyperbolic_io' AS test,
oe_eccentricity('(2460600.0,0.5,2.5,30.0,60.0,120.0,2460400.0,8.0,0.04)'::orbital_elements) AS ecc;
test | ecc
---------------+-----
hyperbolic_io | 2.5
(1 row)
-- ============================================================
-- Test 8: Error paths
-- ============================================================
-- Invalid text input (wrong number of fields)
SELECT 'bad_text' AS test, '(1.0,2.0,3.0)'::orbital_elements;
ERROR: invalid input syntax for type orbital_elements: "(1.0,2.0,3.0)"
LINE 1: SELECT 'bad_text' AS test, '(1.0,2.0,3.0)'::orbital_elements...
^
HINT: Expected (epoch_jd,q_au,e,inc_deg,omega_deg,Omega_deg,tp_jd,H,G).
-- Negative perihelion distance
SELECT 'negative_q' AS test, '(2460600.0,-1.0,0.5,10.0,73.0,80.0,2460319.0,3.33,0.12)'::orbital_elements;
ERROR: perihelion distance must be positive: -1.000000
LINE 1: SELECT 'negative_q' AS test, '(2460600.0,-1.0,0.5,10.0,73.0,...
^
-- Negative eccentricity
SELECT 'negative_e' AS test, '(2460600.0,1.0,-0.1,10.0,73.0,80.0,2460319.0,3.33,0.12)'::orbital_elements;
ERROR: eccentricity must be non-negative: -0.100000
LINE 1: SELECT 'negative_e' AS test, '(2460600.0,1.0,-0.1,10.0,73.0,...
^
-- MPC line too short
SELECT 'mpc_short' AS test, oe_from_mpc('too short');
ERROR: MPC line too short: 9 characters (need at least 103)
-- ============================================================
-- Test 9: MPC packed date decoding
-- Verify K24AM -> 2024-10-22 -> JD 2460605.5
-- ============================================================
SELECT 'mpc_epoch' AS test,
round(oe_epoch(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 1) AS epoch_jd;
test | epoch_jd
-----------+-----------
mpc_epoch | 2460605.5
(1 row)
-- ============================================================
-- Test 10: Consistency -- small_body_observe vs comet_observe
-- Both pipelines should produce the same topocentric result
-- when fed the same elements and Earth position.
-- ============================================================
WITH ceres AS (
SELECT oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
) AS oe
), earth AS (
SELECT planet_heliocentric(3, '2025-06-15 12:00:00+00') AS pos
)
SELECT 'pipeline_match' AS test,
round(abs(
topo_elevation(small_body_observe(c.oe, :boulder, '2025-06-15 12:00:00+00'))
- topo_elevation(comet_observe(
oe_perihelion(c.oe), oe_eccentricity(c.oe),
oe_inclination(c.oe), oe_arg_perihelion(c.oe), oe_raan(c.oe),
oe_tp(c.oe),
helio_x(e.pos), helio_y(e.pos), helio_z(e.pos),
:boulder, '2025-06-15 12:00:00+00'
))
)::numeric, 6) AS el_diff_deg
FROM ceres c, earth e;
test | el_diff_deg
----------------+-------------
pipeline_match | 0.000000
(1 row)

View File

@ -0,0 +1,197 @@
-- refraction regression tests
--
-- Tests atmospheric refraction (Bennett 1982), pressure/temperature
-- correction, apparent elevation, and refracted pass prediction.
\set boulder '''40.015N 105.270W 1655m'''::observer
-- ISS TLE for pass prediction tests (inline in CTEs below)
-- ============================================================
-- Test 1: Refraction at horizon (0 deg) ~ 0.57 deg
-- Bennett: R = 1/tan(0 + 7.31/4.4) arcmin ~ 34.5 arcmin ~ 0.575 deg
-- ============================================================
SELECT 'refr_horizon' AS test,
round(atmospheric_refraction(0.0)::numeric, 2) AS refr_deg;
test | refr_deg
--------------+----------
refr_horizon | 0.57
(1 row)
-- ============================================================
-- Test 2: Refraction at 30 deg ~ 0.03 deg
-- ============================================================
SELECT 'refr_30deg' AS test,
round(atmospheric_refraction(30.0)::numeric, 3) AS refr_deg;
test | refr_deg
------------+----------
refr_30deg | 0.029
(1 row)
-- ============================================================
-- Test 3: Refraction at zenith (90 deg) ~ 0 deg
-- ============================================================
SELECT 'refr_zenith' AS test,
round(atmospheric_refraction(90.0)::numeric, 4) AS refr_deg;
test | refr_deg
-------------+----------
refr_zenith | 0.0000
(1 row)
-- ============================================================
-- Test 4: Refraction at 10 deg ~ 0.09 deg
-- ============================================================
SELECT 'refr_10deg' AS test,
round(atmospheric_refraction(10.0)::numeric, 3) AS refr_deg;
test | refr_deg
------------+----------
refr_10deg | 0.090
(1 row)
-- ============================================================
-- Test 5: Domain guard - refraction at -5 deg should return 0
-- (below -1 deg validity range)
-- ============================================================
SELECT 'refr_below_range' AS test,
atmospheric_refraction(-5.0) AS refr_deg;
test | refr_deg
------------------+----------
refr_below_range | 0
(1 row)
-- ============================================================
-- Test 6: Domain guard - refraction at -10 deg returns 0 (no NaN)
-- ============================================================
SELECT 'refr_deep_neg' AS test,
atmospheric_refraction(-10.0) AS refr_deg,
atmospheric_refraction(-10.0) = atmospheric_refraction(-10.0) AS is_finite;
test | refr_deg | is_finite
---------------+----------+-----------
refr_deep_neg | 0 | t
(1 row)
-- ============================================================
-- Test 7: Refraction at exactly -1 deg (edge of domain)
-- Should return a small positive value
-- ============================================================
SELECT 'refr_minus1' AS test,
atmospheric_refraction(-1.0) > 0 AS positive;
test | positive
-------------+----------
refr_minus1 | t
(1 row)
-- ============================================================
-- Test 8: Extended refraction with P/T correction
-- Standard: P=1010, T=10 should match basic function
-- ============================================================
SELECT 'refr_ext_standard' AS test,
round(atmospheric_refraction_ext(0.0, 1010.0, 10.0)::numeric, 2) AS refr_deg,
round(atmospheric_refraction(0.0)::numeric, 2) AS refr_basic,
round(atmospheric_refraction_ext(0.0, 1010.0, 10.0)::numeric, 4) =
round(atmospheric_refraction(0.0)::numeric, 4) AS match;
test | refr_deg | refr_basic | match
-------------------+----------+------------+-------
refr_ext_standard | 0.57 | 0.57 | t
(1 row)
-- ============================================================
-- Test 9: Extended refraction - cold high-altitude
-- At P=700 mbar, T=-20 C, refraction should be reduced
-- ============================================================
SELECT 'refr_ext_cold' AS test,
round(atmospheric_refraction_ext(0.0, 700.0, -20.0)::numeric, 2) AS refr_deg,
atmospheric_refraction_ext(0.0, 700.0, -20.0) <
atmospheric_refraction(0.0) AS less_than_standard;
test | refr_deg | less_than_standard
---------------+----------+--------------------
refr_ext_cold | 0.45 | t
(1 row)
-- ============================================================
-- Test 10: Extended refraction - hot sea level
-- At P=1013, T=35 C, refraction should be slightly different
-- ============================================================
SELECT 'refr_ext_hot' AS test,
round(atmospheric_refraction_ext(0.0, 1013.0, 35.0)::numeric, 2) AS refr_deg;
test | refr_deg
--------------+----------
refr_ext_hot | 0.53
(1 row)
-- ============================================================
-- Test 11: Apparent elevation for a topocentric observation
-- Sun near horizon: geometric el small -> apparent el higher
-- ============================================================
SELECT 'apparent_el' AS test,
round(topo_elevation(sun_observe(:boulder, '2024-03-20 00:30:00+00'))::numeric, 1) AS geometric,
round(topo_elevation_apparent(sun_observe(:boulder, '2024-03-20 00:30:00+00'))::numeric, 1) AS apparent,
topo_elevation_apparent(sun_observe(:boulder, '2024-03-20 00:30:00+00')) >
topo_elevation(sun_observe(:boulder, '2024-03-20 00:30:00+00')) AS refraction_positive;
test | geometric | apparent | refraction_positive
-------------+-----------+----------+---------------------
apparent_el | 7.3 | 7.5 | t
(1 row)
-- ============================================================
-- Test 12: Apparent elevation for star high up (refraction small)
-- Polaris from Boulder has el ~49 deg; refraction ~0.01 deg
-- ============================================================
SELECT 'apparent_el_high' AS test,
round(topo_elevation_apparent(
star_observe(2.530303, 89.2641, :boulder, '2024-06-15 04:00:00+00'))::numeric, 1) AS apparent_deg;
test | apparent_deg
------------------+--------------
apparent_el_high | 39.4
(1 row)
-- ============================================================
-- Test 13: Refracted pass prediction returns results
-- Using the ISS TLE, should find passes in a week window
-- ============================================================
WITH iss AS (
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
)
SELECT 'refracted_passes' AS test,
count(*) > 0 AS has_passes
FROM iss, predict_passes_refracted(
t, :boulder,
'2024-01-02 00:00:00+00', '2024-01-09 00:00:00+00');
test | has_passes
------------------+------------
refracted_passes | t
(1 row)
-- ============================================================
-- Test 14: Refracted passes find at least as many as standard
-- Because refracted horizon is -0.569 deg, satellites visible ~35s earlier
-- ============================================================
SELECT 'refracted_more_passes' AS test,
(SELECT count(*) FROM predict_passes_refracted(
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
:boulder,
'2024-01-02 00:00:00+00', '2024-01-09 00:00:00+00'))
>=
(SELECT count(*) FROM predict_passes(
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
:boulder,
'2024-01-02 00:00:00+00', '2024-01-09 00:00:00+00'))
AS refracted_ge_standard;
test | refracted_ge_standard
-----------------------+-----------------------
refracted_more_passes | t
(1 row)
-- ============================================================
-- Test 15: Monotonicity - refraction decreases with elevation
-- ============================================================
SELECT 'refr_monotonic' AS test,
atmospheric_refraction(0.0) > atmospheric_refraction(10.0) AS r0_gt_r10,
atmospheric_refraction(10.0) > atmospheric_refraction(30.0) AS r10_gt_r30,
atmospheric_refraction(30.0) > atmospheric_refraction(60.0) AS r30_gt_r60,
atmospheric_refraction(60.0) > atmospheric_refraction(90.0) AS r60_gt_r90;
test | r0_gt_r10 | r10_gt_r30 | r30_gt_r60 | r60_gt_r90
----------------+-----------+------------+------------+------------
refr_monotonic | t | t | t | t
(1 row)

View File

@ -0,0 +1,354 @@
-- Test SP-GiST orbital trie index and &? visibility cone operator
SET client_min_messages = warning;
CREATE EXTENSION IF NOT EXISTS pg_orrery;
RESET client_min_messages;
-- ============================================================
-- Test table with mixed orbital regimes
-- ============================================================
CREATE TABLE test_spgist (
id serial,
name text,
tle tle
);
-- ISS (LEO, ~400km, 51.64 deg)
INSERT INTO test_spgist (name, tle) VALUES ('ISS',
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001');
-- Hubble (LEO, ~540km, 28.47 deg)
INSERT INTO test_spgist (name, tle) VALUES ('Hubble',
'1 20580U 90037B 24001.50000000 .00000790 00000+0 39573-4 0 9992
2 20580 28.4705 61.4398 0002797 317.3115 42.7577 15.09395228 00008');
-- GPS IIR-M (MEO, ~20200km, 55.44 deg)
INSERT INTO test_spgist (name, tle) VALUES ('GPS-IIR',
'1 28874U 05038A 24001.50000000 .00000012 00000+0 00000+0 0 9993
2 28874 55.4408 300.3467 0117034 51.6543 309.5420 2.00557079 00006');
-- Equatorial-LEO (same altitude as ISS, 5 deg inclination)
INSERT INTO test_spgist (name, tle) VALUES ('Equatorial-LEO',
'1 99901U 24999A 24001.50000000 .00016717 00000-0 10270-3 0 9990
2 99901 5.0000 208.9163 0006703 30.1694 61.7520 15.50100486 00001');
-- SSO-800 (Sun-synchronous, ~800km, 98.7 deg)
INSERT INTO test_spgist (name, tle) VALUES ('SSO-800',
'1 99902U 24999B 24001.50000000 .00000100 00000+0 50000-4 0 9991
2 99902 98.7000 120.0000 0001000 90.0000 270.0000 14.19553000 00001');
-- GEO-SAT (Geostationary, ~35786km, 0.04 deg)
INSERT INTO test_spgist (name, tle) VALUES ('GEO-SAT',
'1 99903U 24999C 24001.50000000 .00000000 00000+0 00000+0 0 9992
2 99903 0.0400 270.0000 0003000 0.0000 180.0000 1.00273791 00001');
-- ============================================================
-- Test 1: Operator standalone — ISS from Eagle Idaho (2h window)
-- Eagle Idaho: 43.6977N 116.3535W, 760m elevation
-- ISS passes altitude and inclination checks, but RAAN filter
-- rejects it — the orbital plane isn't overhead during this
-- specific 2-hour window (correct physics, see Test 5 for 24h).
-- ============================================================
SELECT name,
tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-01 02:00:00+00'::timestamptz,
10.0
)::observer_window AS visible
FROM test_spgist
WHERE name = 'ISS';
name | visible
------+---------
ISS | f
(1 row)
-- ============================================================
-- Test 2: Equatorial-LEO NOT visible from Eagle Idaho
-- 5 deg inc + ~12 deg footprint = 17 deg < 43.7 deg latitude
-- ============================================================
SELECT name,
tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-01 02:00:00+00'::timestamptz,
10.0
)::observer_window AS visible
FROM test_spgist
WHERE name = 'Equatorial-LEO';
name | visible
----------------+---------
Equatorial-LEO | f
(1 row)
-- ============================================================
-- Test 3: Create SP-GiST index, verify index scan with positive
-- results. Equatorial observer at 0E — SSO-800 RAAN (120 deg)
-- aligns with LST near 0E at this epoch, so it passes.
-- ============================================================
CREATE INDEX test_spgist_idx ON test_spgist USING spgist (tle tle_spgist_ops);
SET enable_seqscan = off;
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-01 02:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
name
---------
SSO-800
(1 row)
RESET enable_seqscan;
-- ============================================================
-- Test 4: Seqscan vs index scan consistency — same query must
-- return identical results regardless of scan method.
-- ============================================================
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-01 02:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
name
---------
SSO-800
(1 row)
RESET enable_indexscan;
RESET enable_bitmapscan;
-- ============================================================
-- Test 5: 24-hour window — RAAN filter bypassed (full Earth
-- rotation). Only ISS and SSO-800 pass inclination from Eagle
-- Idaho (43.7 deg). Hubble (28.5+14.8=43.3 deg) barely fails.
-- GPS-IIR and GEO-SAT filtered by altitude.
-- ============================================================
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
name
---------
ISS
SSO-800
(2 rows)
-- ============================================================
-- Test 6: High min_el (45 deg) changes footprint — wider
-- footprint lets more inclinations through. Same 24h window.
-- ============================================================
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
45.0
)::observer_window
ORDER BY name;
name
---------
ISS
SSO-800
(2 rows)
-- ============================================================
-- Test 7: GiST coexistence — both index types on same table
-- ============================================================
CREATE INDEX test_gist_idx ON test_spgist USING gist (tle);
-- GiST overlap query still works
SELECT a.name AS sat_a, b.name AS sat_b, a.tle && b.tle AS overlaps
FROM test_spgist a, test_spgist b
WHERE a.name = 'ISS' AND b.name = 'Hubble';
sat_a | sat_b | overlaps
-------+--------+----------
ISS | Hubble | f
(1 row)
-- SP-GiST query still works alongside GiST
SET enable_seqscan = off;
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
name
---------
ISS
SSO-800
(2 rows)
RESET enable_seqscan;
-- ============================================================
-- Test 8: NULL TLE handling — NULLs should be excluded
-- ============================================================
INSERT INTO test_spgist (name, tle) VALUES ('NULL-SAT', NULL);
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
name
---------
ISS
SSO-800
(2 rows)
-- ============================================================
-- Test 9: Degenerate TLE (mean_motion = 0) — rejected by filter
-- ============================================================
INSERT INTO test_spgist (name, tle) VALUES ('DECAYED',
'1 99904U 24999D 24001.50000000 .00000000 00000+0 00000+0 0 9993
2 99904 0.0000 0.0000 0000000 0.0000 0.0000 0.00000000 00001');
SELECT name,
tle &? ROW(
observer('0.0N 0.0E 0m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window AS visible
FROM test_spgist
WHERE name = 'DECAYED';
name | visible
---------+---------
DECAYED | f
(1 row)
-- ============================================================
-- Test 10: Polar observer (90N) — only ISS and SSO-800 reach
-- the pole. ISS (51.6 + footprint) < 90, so only SSO-800
-- (retrograde, 98.7 deg inc > 90 deg) passes. 24h window.
-- ============================================================
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('90.0N 0.0E 0m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
name
---------
SSO-800
(1 row)
-- ============================================================
-- Test 11: Zero-duration window — sees only what is directly
-- overhead at the instant. RAAN window = footprint only.
-- ============================================================
SELECT name,
tle &? ROW(
observer('0.0N 0.0E 0m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-01 00:00:00+00'::timestamptz,
10.0
)::observer_window AS visible
FROM test_spgist
WHERE name = 'ISS';
name | visible
------+---------
ISS | f
(1 row)
-- ============================================================
-- Test 12: Index-vs-seqscan consistency on 24h Eagle Idaho
-- (the primary correctness test, now after all inserts)
-- ============================================================
SET enable_seqscan = off;
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
name
---------
ISS
SSO-800
(2 rows)
RESET enable_seqscan;
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
name
---------
ISS
SSO-800
(2 rows)
RESET enable_indexscan;
RESET enable_bitmapscan;
-- ============================================================
-- Test 13: HEO at high latitude — GTO-class orbit (low inc,
-- high SMA, high eccentricity) from Tromsø (69.6°N).
-- The large SMA gives a huge footprint that compensates for the
-- low inclination. Must pass the seqscan operator check.
-- Regression test for the L1 pruning bug (sma_low vs sma_high).
-- ============================================================
-- GTO debris: inc 5 deg, perigee ~250 km, apogee ~35786 km
INSERT INTO test_spgist (name, tle) VALUES ('GTO-DEBRIS',
'1 99905U 24999E 24001.50000000 .00000100 00000+0 10000-3 0 9994
2 99905 5.0000 210.0000 7300000 30.0000 61.0000 2.25600000 00001');
-- Seqscan: GTO-DEBRIS from Tromsø — must be visible
-- inc 5 deg + footprint(SMA ~25000) ~65 deg = 70 > 69.6
SELECT name,
tle &? ROW(
observer('69.6N 19.0E 0m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window AS visible
FROM test_spgist
WHERE name = 'GTO-DEBRIS';
name | visible
------------+---------
GTO-DEBRIS | t
(1 row)
-- Index scan: same query, must return the same result
SET enable_seqscan = off;
SELECT name,
tle &? ROW(
observer('69.6N 19.0E 0m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window AS visible
FROM test_spgist
WHERE name = 'GTO-DEBRIS';
name | visible
------------+---------
GTO-DEBRIS | t
(1 row)
RESET enable_seqscan;
-- ============================================================
-- Cleanup
-- ============================================================
DROP TABLE test_spgist;

188
test/sql/aberration.sql Normal file
View File

@ -0,0 +1,188 @@
-- aberration regression tests
--
-- Tests annual aberration in _apparent() functions, DE apparent variants,
-- equatorial angular distance/cone search, and stellar annual parallax.
\set boulder '''40.015N 105.270W 1655m'''::observer
-- ============================================================
-- Test 1: Aberration magnitude — planet_equatorial_apparent
-- vs planet_equatorial (geometric). Jupiter aberration should
-- be in the range 0-20 arcsec (~0.001 hours at Jupiter's dec).
-- ============================================================
SELECT 'aberration_planet' AS test,
round((abs(
eq_ra(planet_equatorial_apparent(5, '2024-06-21 12:00:00+00'))
- eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))
) * 3600 * 15)::numeric, 0) AS diff_arcsec,
abs(
eq_ra(planet_equatorial_apparent(5, '2024-06-21 12:00:00+00'))
- eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))
) * 3600 * 15 BETWEEN 1 AND 50 AS magnitude_valid;
-- ============================================================
-- Test 2: Aberration magnitude — sun_observe_apparent vs sun_observe
-- Sun aberration should be ~20 arcsec (Earth orbital velocity).
-- Compare elevations (both from same observer, same time).
-- ============================================================
SELECT 'aberration_sun' AS test,
round((abs(
topo_elevation(sun_observe_apparent(:boulder, '2024-06-21 12:00:00+00'))
- topo_elevation(sun_observe(:boulder, '2024-06-21 12:00:00+00'))
) * 3600)::numeric, 0) AS diff_arcsec,
abs(
topo_elevation(sun_observe_apparent(:boulder, '2024-06-21 12:00:00+00'))
- topo_elevation(sun_observe(:boulder, '2024-06-21 12:00:00+00'))
) * 3600 BETWEEN 1 AND 25 AS magnitude_valid;
-- ============================================================
-- Test 3: Moon aberration should be present (same ~20 arcsec
-- as all other objects — aberration depends on observer velocity,
-- not object distance).
-- ============================================================
SELECT 'aberration_moon' AS test,
round((abs(
eq_ra(moon_equatorial_apparent('2024-06-21 12:00:00+00'))
- eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))
) * 3600 * 15)::numeric, 0) AS diff_arcsec,
abs(
eq_ra(moon_equatorial_apparent('2024-06-21 12:00:00+00'))
- eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))
) * 3600 * 15 BETWEEN 1 AND 25 AS magnitude_valid;
-- ============================================================
-- Test 4: DE apparent fallback — without DE configured,
-- _apparent_de() should match _apparent() exactly.
-- ============================================================
SELECT 'de_apparent_fallback' AS test,
round(eq_ra(planet_equatorial_apparent_de(5, '2024-06-21 12:00:00+00'))::numeric, 6) =
round(eq_ra(planet_equatorial_apparent(5, '2024-06-21 12:00:00+00'))::numeric, 6) AS planet_match,
round(eq_ra(moon_equatorial_apparent_de('2024-06-21 12:00:00+00'))::numeric, 6) =
round(eq_ra(moon_equatorial_apparent('2024-06-21 12:00:00+00'))::numeric, 6) AS moon_match;
-- ============================================================
-- Test 5: DE apparent topocentric fallback
-- ============================================================
SELECT 'de_topo_fallback' AS test,
round(topo_elevation(planet_observe_apparent_de(5, :boulder, '2024-06-21 12:00:00+00'))::numeric, 4) =
round(topo_elevation(planet_observe_apparent(5, :boulder, '2024-06-21 12:00:00+00'))::numeric, 4) AS planet_match,
round(topo_elevation(sun_observe_apparent_de(:boulder, '2024-06-21 12:00:00+00'))::numeric, 4) =
round(topo_elevation(sun_observe_apparent(:boulder, '2024-06-21 12:00:00+00'))::numeric, 4) AS sun_match,
topo_elevation(moon_observe_apparent_de(:boulder, '2024-06-21 12:00:00+00')) BETWEEN -90 AND 90 AS moon_valid;
-- ============================================================
-- Test 6: Small body DE apparent fallback
-- ============================================================
SELECT 'de_smallbody_fallback' AS test,
round(topo_elevation(small_body_observe_apparent_de(
'(2460400.5,2.5577,0.0785,0.1849,1.2836,1.4013,2460500.0,3.53,0.12)'::orbital_elements,
:boulder, '2024-06-21 12:00:00+00'))::numeric, 4) =
round(topo_elevation(small_body_observe_apparent(
'(2460400.5,2.5577,0.0785,0.1849,1.2836,1.4013,2460500.0,3.53,0.12)'::orbital_elements,
:boulder, '2024-06-21 12:00:00+00'))::numeric, 4) AS match;
-- ============================================================
-- Test 7: Angular distance — Dubhe and Merak (Big Dipper pointers)
-- Dubhe: RA 11.062h, Dec 61.751 deg
-- Merak: RA 11.031h, Dec 56.382 deg
-- Expected separation: ~5.4 degrees
-- ============================================================
SELECT 'angular_distance' AS test,
round(eq_angular_distance(
star_equatorial(11.062, 61.751, '2024-06-21 12:00:00+00'),
star_equatorial(11.031, 56.382, '2024-06-21 12:00:00+00')
)::numeric, 1) AS sep_deg;
-- ============================================================
-- Test 8: Angular distance — same position should be 0
-- ============================================================
SELECT 'angular_distance_zero' AS test,
round(eq_angular_distance(
'(12.00000000,45.00000000,0.000)'::equatorial,
'(12.00000000,45.00000000,0.000)'::equatorial
)::numeric, 6) AS sep_deg;
-- ============================================================
-- Test 9: Angular distance — opposite poles should be 180
-- ============================================================
SELECT 'angular_distance_poles' AS test,
round(eq_angular_distance(
'(0.00000000,90.00000000,0.000)'::equatorial,
'(0.00000000,-90.00000000,0.000)'::equatorial
)::numeric, 1) AS sep_deg;
-- ============================================================
-- Test 10: <-> operator (same as eq_angular_distance)
-- ============================================================
SELECT 'operator_arrow' AS test,
round((
star_equatorial(11.062, 61.751, '2024-06-21 12:00:00+00')
<->
star_equatorial(11.031, 56.382, '2024-06-21 12:00:00+00')
)::numeric, 1) AS sep_deg;
-- ============================================================
-- Test 11: Cone search — Polaris within 5 deg of NCP
-- ============================================================
SELECT 'cone_inside' AS test,
eq_within_cone(
star_equatorial(2.530303, 89.2641, '2024-06-21 12:00:00+00'),
'(0.00000000,90.00000000,0.000)'::equatorial,
5.0
) AS inside;
-- ============================================================
-- Test 12: Cone search — Sirius not within 5 deg of NCP
-- ============================================================
SELECT 'cone_outside' AS test,
eq_within_cone(
star_equatorial(6.7525, -16.7161, '2024-06-21 12:00:00+00'),
'(0.00000000,90.00000000,0.000)'::equatorial,
5.0
) AS inside;
-- ============================================================
-- Test 13: Stellar parallax — Proxima Centauri (768 mas)
-- Compare with-parallax vs without-parallax at the SAME epoch
-- to isolate the parallax displacement from proper motion and
-- precession. Expected: ~0.2-1.5 arcsec depending on Earth's
-- orbital phase (max near quadrature for this RA).
-- Proxima: RA 14.495h, Dec -62.679 deg
-- ============================================================
SELECT 'stellar_parallax' AS test,
round((abs(
eq_ra(star_equatorial_pm(14.495, -62.679, -3775.40, 769.33, 768.07, -21.7,
'2024-03-20 12:00:00+00'))
- eq_ra(star_equatorial_pm(14.495, -62.679, -3775.40, 769.33, 0.0, -21.7,
'2024-03-20 12:00:00+00'))
) * 3600 * 15)::numeric, 2) AS shift_arcsec,
abs(
eq_ra(star_equatorial_pm(14.495, -62.679, -3775.40, 769.33, 768.07, -21.7,
'2024-03-20 12:00:00+00'))
- eq_ra(star_equatorial_pm(14.495, -62.679, -3775.40, 769.33, 0.0, -21.7,
'2024-03-20 12:00:00+00'))
) * 3600 * 15 BETWEEN 0.01 AND 2.0 AS magnitude_valid;
-- ============================================================
-- Test 14: Parallax = 0 should not change star position
-- (same as without parallax)
-- ============================================================
SELECT 'parallax_zero' AS test,
round(eq_ra(star_equatorial_pm(14.495, -62.679, -3775.40, 769.33, 0.0, -21.7,
'2024-06-21 12:00:00+00'))::numeric, 6) =
round(eq_ra(star_equatorial_pm(14.495, -62.679, -3775.40, 769.33, 0.0, -21.7,
'2024-06-21 12:00:00+00'))::numeric, 6) AS match;
-- ============================================================
-- Test 15: star_observe_pm parallax affects topocentric result
-- Barnard's Star with parallax should differ from without
-- ============================================================
SELECT 'parallax_topo' AS test,
abs(
topo_elevation(star_observe_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
:boulder, '2024-07-15 04:00:00+00'))
- topo_elevation(star_observe_pm(
17.963472, 4.6933, -798.58, 10328.12, 0.0, -110.51,
:boulder, '2024-07-15 04:00:00+00'))
) * 3600 BETWEEN 0.01 AND 2.0 AS displacement_valid;

197
test/sql/equatorial.sql Normal file
View File

@ -0,0 +1,197 @@
-- equatorial type regression tests
--
-- Tests equatorial type I/O, accessor functions, satellite RA/Dec,
-- planet/Sun/Moon/small body equatorial coordinates, stellar
-- precession, proper motion, light-time correction, and DE variants.
\set boulder '''40.015N 105.270W 1655m'''::observer
-- ============================================================
-- Test 1: equatorial type I/O round-trip
-- ============================================================
SELECT 'eq_io' AS test,
'(12.00000000,45.00000000,0.000)'::equatorial::text AS val;
-- ============================================================
-- Test 2: equatorial accessor functions
-- ============================================================
SELECT 'eq_accessors' AS test,
round(eq_ra(e)::numeric, 4) AS ra_hours,
round(eq_dec(e)::numeric, 4) AS dec_deg,
round(eq_distance(e)::numeric, 1) AS dist_km
FROM (SELECT '(6.50000000,-23.00000000,149597870.700)'::equatorial AS e) sub;
-- ============================================================
-- Test 3: equatorial input validation - RA out of range
-- ============================================================
SELECT 'eq_bad_ra' AS test, '(25.0,0.0,0.0)'::equatorial;
-- ============================================================
-- Test 4: equatorial input validation - Dec out of range
-- ============================================================
SELECT 'eq_bad_dec' AS test, '(12.0,91.0,0.0)'::equatorial;
-- ============================================================
-- Test 5: Sun equatorial RA/Dec at J2000.0
-- At 2000-01-01 12:00:00 UTC the Sun should be near RA ~18.75h, Dec ~-23 deg
-- (winter solstice was ~10 days earlier)
-- ============================================================
SELECT 'sun_eq' AS test,
round(eq_ra(sun_equatorial('2000-01-01 12:00:00+00'))::numeric, 1) AS ra_h,
round(eq_dec(sun_equatorial('2000-01-01 12:00:00+00'))::numeric, 0) AS dec_deg;
-- ============================================================
-- Test 6: Sun equatorial at summer solstice ~2024
-- RA should be ~6h, Dec ~+23.4 deg
-- ============================================================
SELECT 'sun_eq_solstice' AS test,
round(eq_ra(sun_equatorial('2024-06-21 12:00:00+00'))::numeric, 0) AS ra_h,
round(eq_dec(sun_equatorial('2024-06-21 12:00:00+00'))::numeric, 0) AS dec_deg;
-- ============================================================
-- Test 7: Moon equatorial returns valid coordinates
-- Range should be 356k-407k km
-- ============================================================
SELECT 'moon_eq' AS test,
eq_ra(moon_equatorial('2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid,
eq_dec(moon_equatorial('2024-06-21 12:00:00+00')) BETWEEN -90 AND 90 AS dec_valid,
eq_distance(moon_equatorial('2024-06-21 12:00:00+00')) BETWEEN 350000 AND 410000 AS range_valid;
-- ============================================================
-- Test 8: Jupiter equatorial returns valid coordinates
-- Distance should be ~588M-968M km (3.93-6.47 AU)
-- ============================================================
SELECT 'jupiter_eq' AS test,
eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid,
eq_dec(planet_equatorial(5, '2024-06-21 12:00:00+00')) BETWEEN -90 AND 90 AS dec_valid,
eq_distance(planet_equatorial(5, '2024-06-21 12:00:00+00')) BETWEEN 500000000 AND 1000000000 AS range_valid;
-- ============================================================
-- Test 9: planet_equatorial error - cannot observe Earth
-- ============================================================
SELECT 'earth_eq_error' AS test, planet_equatorial(3, now());
-- ============================================================
-- Test 10: star_equatorial at J2000.0 (precession = identity)
-- Polaris RA 2.530303h Dec 89.2641 deg should be unchanged
-- ============================================================
SELECT 'star_eq_j2000' AS test,
round(eq_ra(star_equatorial(2.530303, 89.2641, '2000-01-01 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_dec(star_equatorial(2.530303, 89.2641, '2000-01-01 12:00:00+00'))::numeric, 4) AS dec_deg,
round(eq_distance(star_equatorial(2.530303, 89.2641, '2000-01-01 12:00:00+00'))::numeric, 1) AS dist;
-- ============================================================
-- Test 11: star_equatorial with precession (25 years from J2000)
-- RA should shift slightly due to precession
-- ============================================================
SELECT 'star_eq_precessed' AS test,
round(eq_ra(star_equatorial(2.530303, 89.2641, '2025-06-15 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_dec(star_equatorial(2.530303, 89.2641, '2025-06-15 12:00:00+00'))::numeric, 4) AS dec_deg;
-- ============================================================
-- Test 12: star_equatorial_pm for Barnard's Star
-- Barnard's Star: RA 17.963472h, Dec 4.6933 deg
-- pm_ra = -798.58 mas/yr, pm_dec = 10328.12 mas/yr
-- parallax = 545.4 mas, rv = -110.51 km/s
-- After 25 years, Dec should shift by ~0.26 deg
-- ============================================================
SELECT 'barnard_pm' AS test,
round(eq_dec(star_equatorial_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
'2025-01-01 12:00:00+00'))::numeric, 2) AS dec_deg,
round(eq_distance(star_equatorial_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
'2025-01-01 12:00:00+00'))::numeric, -6) AS dist_km;
-- ============================================================
-- Test 13: star_observe_pm returns valid topocentric
-- ============================================================
SELECT 'barnard_topo' AS test,
round(topo_azimuth(star_observe_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
:boulder, '2024-07-15 04:00:00+00'))::numeric, 0) AS az_deg,
round(topo_elevation(star_observe_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
:boulder, '2024-07-15 04:00:00+00'))::numeric, 0) AS el_deg;
-- ============================================================
-- Test 14: Satellite equatorial (geocentric) from ECI
-- ISS at known ECI: should get valid RA/Dec
-- ============================================================
SELECT 'sat_eq_geo' AS test,
eq_ra(eci_to_equatorial_geo(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
'2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid,
eq_dec(eci_to_equatorial_geo(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
'2024-06-21 12:00:00+00')) BETWEEN -90 AND 90 AS dec_valid;
-- ============================================================
-- Test 15: Satellite equatorial (topocentric vs geocentric)
-- Topocentric should differ from geocentric by ~0.5-1 deg for LEO
-- ============================================================
SELECT 'sat_parallax' AS test,
round(abs(
eq_ra(eci_to_equatorial(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
:boulder, '2024-06-21 12:00:00+00'))
- eq_ra(eci_to_equatorial_geo(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
'2024-06-21 12:00:00+00'))
)::numeric, 2) AS parallax_hours;
-- ============================================================
-- Test 16: All planets return valid equatorial coordinates
-- ============================================================
SELECT 'all_planets_eq' AS test,
body_id,
round(eq_ra(planet_equatorial(body_id, '2024-06-21 12:00:00+00'))::numeric, 2) AS ra_h,
round(eq_dec(planet_equatorial(body_id, '2024-06-21 12:00:00+00'))::numeric, 1) AS dec_deg
FROM generate_series(1, 8) AS body_id
WHERE body_id != 3;
-- ============================================================
-- Test 17: planet_equatorial_apparent (light-time corrected)
-- Jupiter's light-time is ~35-52 min; apparent RA should differ
-- from geometric by a small amount
-- ============================================================
SELECT 'jupiter_apparent' AS test,
round(abs(
eq_ra(planet_equatorial_apparent(5, '2024-06-21 12:00:00+00'))
- eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))
)::numeric, 4) AS ra_diff_hours;
-- ============================================================
-- Test 18: moon_equatorial_apparent (light-time ~1.3 sec)
-- Difference from geometric should be tiny
-- ============================================================
SELECT 'moon_apparent' AS test,
round(abs(
eq_ra(moon_equatorial_apparent('2024-06-21 12:00:00+00'))
- eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))
)::numeric, 6) AS ra_diff_hours;
-- ============================================================
-- Test 19: Small body equatorial with Ceres
-- Ceres orbital elements (approximate)
-- ============================================================
SELECT 'ceres_eq' AS test,
eq_ra(small_body_equatorial(
'(2460400.5,2.5577,0.0785,0.1849,1.2836,1.4013,2460500.0,3.53,0.12)'::orbital_elements,
'2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid;
-- ============================================================
-- Test 20: DE equatorial variants (fall back to VSOP87)
-- Without DE configured, these should produce same results as VSOP87
-- ============================================================
SELECT 'de_planet_eq' AS test,
round(eq_ra(planet_equatorial_de(5, '2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h_vsop,
round(eq_ra(planet_equatorial_de(5, '2024-06-21 12:00:00+00'))::numeric, 4) =
round(eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))::numeric, 4) AS match;
SELECT 'de_moon_eq' AS test,
round(eq_ra(moon_equatorial_de('2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h_elp,
round(eq_ra(moon_equatorial_de('2024-06-21 12:00:00+00'))::numeric, 4) =
round(eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))::numeric, 4) AS match;

View File

@ -39,9 +39,10 @@ FROM test_orbits a, test_orbits b
WHERE a.id < b.id
ORDER BY a.name, b.name;
-- Altitude distance: ISS <-> Equatorial-LEO should be ~0 (same altitude shell)
-- Orbital distance: 2-D metric (altitude + inclination in km)
-- ISS <-> Equatorial-LEO should be ~5192 (0 km alt gap + 47° inc diff × 6378 km)
SELECT a.name AS sat_a, b.name AS sat_b,
round((a.tle <-> b.tle)::numeric, 0) AS alt_dist_km
round((a.tle <-> b.tle)::numeric, 0) AS orbital_dist_km
FROM test_orbits a, test_orbits b
WHERE a.id < b.id
ORDER BY a.name, b.name;
@ -54,7 +55,7 @@ WHERE tle && (SELECT tle FROM test_orbits WHERE name = 'ISS')
ORDER BY name;
RESET enable_seqscan;
-- Nearest-neighbor via GiST: order by altitude distance to ISS
-- Nearest-neighbor via GiST: order by 2-D orbital distance to ISS
SET enable_seqscan = off;
SELECT name, round((tle <-> (SELECT tle FROM test_orbits WHERE name = 'ISS'))::numeric, 0) AS dist
FROM test_orbits

View File

@ -0,0 +1,208 @@
-- orbital_elements regression tests
--
-- Tests orbital_elements type I/O, accessors, MPC parser,
-- small_body_heliocentric(), and small_body_observe().
CREATE EXTENSION IF NOT EXISTS pg_orrery;
\set boulder '''40.015N 105.270W 1655m'''::observer
-- ============================================================
-- Test 1: Type I/O round-trip
-- Construct orbital_elements from text literal, verify output matches.
-- Angles in degrees, stored as radians internally.
-- ============================================================
SELECT 'io_roundtrip' AS test,
'(2460600.000000,2.5478000000,0.0789126000,10.586640,73.429370,80.268600,2460319.000000,3.33,0.12)'::orbital_elements AS oe;
-- ============================================================
-- Test 2: Accessor functions (direct field access)
-- ============================================================
SELECT 'accessor_epoch' AS test,
round(oe_epoch('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 1) AS epoch_jd;
SELECT 'accessor_q' AS test,
round(oe_perihelion('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 4) AS q_au;
SELECT 'accessor_e' AS test,
round(oe_eccentricity('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 7) AS ecc;
SELECT 'accessor_inc' AS test,
round(oe_inclination('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 5) AS inc_deg;
SELECT 'accessor_omega' AS test,
round(oe_arg_perihelion('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 5) AS omega_deg;
SELECT 'accessor_Omega' AS test,
round(oe_raan('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 4) AS Omega_deg;
SELECT 'accessor_tp' AS test,
round(oe_tp('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 1) AS tp_jd;
SELECT 'accessor_h' AS test,
round(oe_h_mag('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 2) AS h_mag;
SELECT 'accessor_g' AS test,
round(oe_g_slope('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 2) AS g_slope;
-- ============================================================
-- Test 3: Computed accessors
-- ============================================================
-- Semi-major axis: a = q / (1 - e) = 2.5478 / (1 - 0.0789126) ~ 2.766 AU
SELECT 'sma_elliptic' AS test,
round(oe_semi_major_axis('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 3) AS a_au;
-- Period: a^1.5 years. a ~ 2.766, period ~ 4.599 years
SELECT 'period_elliptic' AS test,
round(oe_period_years('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 2) AS period_yr;
-- Hyperbolic orbit (e=1.5): semi-major axis and period should be NULL
SELECT 'sma_hyperbolic' AS test,
oe_semi_major_axis('(2460600.0,1.0,1.5,10.0,73.0,80.0,2460319.0,NaN,NaN)'::orbital_elements) IS NULL AS is_null;
SELECT 'period_hyperbolic' AS test,
oe_period_years('(2460600.0,1.0,1.5,10.0,73.0,80.0,2460319.0,NaN,NaN)'::orbital_elements) IS NULL AS is_null;
-- ============================================================
-- Test 4: MPC parser -- (1) Ceres
--
-- MPCORB.DAT line for Ceres (epoch 2024 Oct 17.0 = K24AM):
-- Packed epoch K24AM -> K=2000, 24, A=Oct, M=22 -> 2024-10-22
-- (Actually: K=2000, year=24, month=A=10, day=M=22)
-- JD for 2024-10-22 = 2460605.5
--
-- a = 2.7660961 AU, e = 0.0789126
-- q = a*(1-e) = 2.7660961*(1-0.0789126) = 2.5478...
-- M = 60.07966 deg
-- ============================================================
SELECT 'mpc_ceres_sma' AS test,
round(oe_semi_major_axis(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 4) AS a_au;
SELECT 'mpc_ceres_q' AS test,
round(oe_perihelion(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 4) AS q_au;
SELECT 'mpc_ceres_ecc' AS test,
round(oe_eccentricity(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 7) AS ecc;
SELECT 'mpc_ceres_inc' AS test,
round(oe_inclination(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 5) AS inc_deg;
SELECT 'mpc_ceres_h' AS test,
round(oe_h_mag(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 2) AS h_mag;
-- ============================================================
-- Test 5: small_body_heliocentric -- compare to kepler_propagate
-- Both should produce the same heliocentric position for Ceres.
-- ============================================================
SELECT 'helio_vs_kepler' AS test,
round(helio_x(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 6) AS x_au,
round(helio_y(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 6) AS y_au,
round(helio_z(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 6) AS z_au;
-- Verify heliocentric distance is reasonable (Ceres orbits ~2.5-3.0 AU)
SELECT 'helio_distance' AS test,
round(helio_distance(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 2) AS dist_au,
helio_distance(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
)) BETWEEN 2.5 AND 3.0 AS in_range;
-- ============================================================
-- Test 6: small_body_observe -- topocentric observation
-- Verify we get reasonable az/el/range for Ceres from Boulder.
-- Range should be on the order of 1.5-4.5 AU in km.
-- ============================================================
SELECT 'observe_range' AS test,
topo_range(small_body_observe(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
:boulder, '2025-01-01 00:00:00+00'
)) BETWEEN 1.5 * 149597870.7 AND 4.5 * 149597870.7 AS range_reasonable;
-- Elevation should be a finite number
SELECT 'observe_elevation' AS test,
topo_elevation(small_body_observe(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
:boulder, '2025-01-01 00:00:00+00'
)) BETWEEN -90 AND 90 AS el_reasonable;
-- ============================================================
-- Test 7: Parabolic/hyperbolic elements via text I/O
-- Verify type handles e >= 1 without error.
-- ============================================================
SELECT 'parabolic_io' AS test,
oe_eccentricity('(2460600.0,1.0,1.0,45.0,90.0,180.0,2460500.0,12.0,0.15)'::orbital_elements) AS ecc;
SELECT 'hyperbolic_io' AS test,
oe_eccentricity('(2460600.0,0.5,2.5,30.0,60.0,120.0,2460400.0,8.0,0.04)'::orbital_elements) AS ecc;
-- ============================================================
-- Test 8: Error paths
-- ============================================================
-- Invalid text input (wrong number of fields)
SELECT 'bad_text' AS test, '(1.0,2.0,3.0)'::orbital_elements;
-- Negative perihelion distance
SELECT 'negative_q' AS test, '(2460600.0,-1.0,0.5,10.0,73.0,80.0,2460319.0,3.33,0.12)'::orbital_elements;
-- Negative eccentricity
SELECT 'negative_e' AS test, '(2460600.0,1.0,-0.1,10.0,73.0,80.0,2460319.0,3.33,0.12)'::orbital_elements;
-- MPC line too short
SELECT 'mpc_short' AS test, oe_from_mpc('too short');
-- ============================================================
-- Test 9: MPC packed date decoding
-- Verify K24AM -> 2024-10-22 -> JD 2460605.5
-- ============================================================
SELECT 'mpc_epoch' AS test,
round(oe_epoch(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 1) AS epoch_jd;
-- ============================================================
-- Test 10: Consistency -- small_body_observe vs comet_observe
-- Both pipelines should produce the same topocentric result
-- when fed the same elements and Earth position.
-- ============================================================
WITH ceres AS (
SELECT oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
) AS oe
), earth AS (
SELECT planet_heliocentric(3, '2025-06-15 12:00:00+00') AS pos
)
SELECT 'pipeline_match' AS test,
round(abs(
topo_elevation(small_body_observe(c.oe, :boulder, '2025-06-15 12:00:00+00'))
- topo_elevation(comet_observe(
oe_perihelion(c.oe), oe_eccentricity(c.oe),
oe_inclination(c.oe), oe_arg_perihelion(c.oe), oe_raan(c.oe),
oe_tp(c.oe),
helio_x(e.pos), helio_y(e.pos), helio_z(e.pos),
:boulder, '2025-06-15 12:00:00+00'
))
)::numeric, 6) AS el_diff_deg
FROM ceres c, earth e;

139
test/sql/refraction.sql Normal file
View File

@ -0,0 +1,139 @@
-- refraction regression tests
--
-- Tests atmospheric refraction (Bennett 1982), pressure/temperature
-- correction, apparent elevation, and refracted pass prediction.
\set boulder '''40.015N 105.270W 1655m'''::observer
-- ISS TLE for pass prediction tests (inline in CTEs below)
-- ============================================================
-- Test 1: Refraction at horizon (0 deg) ~ 0.57 deg
-- Bennett: R = 1/tan(0 + 7.31/4.4) arcmin ~ 34.5 arcmin ~ 0.575 deg
-- ============================================================
SELECT 'refr_horizon' AS test,
round(atmospheric_refraction(0.0)::numeric, 2) AS refr_deg;
-- ============================================================
-- Test 2: Refraction at 30 deg ~ 0.03 deg
-- ============================================================
SELECT 'refr_30deg' AS test,
round(atmospheric_refraction(30.0)::numeric, 3) AS refr_deg;
-- ============================================================
-- Test 3: Refraction at zenith (90 deg) ~ 0 deg
-- ============================================================
SELECT 'refr_zenith' AS test,
round(atmospheric_refraction(90.0)::numeric, 4) AS refr_deg;
-- ============================================================
-- Test 4: Refraction at 10 deg ~ 0.09 deg
-- ============================================================
SELECT 'refr_10deg' AS test,
round(atmospheric_refraction(10.0)::numeric, 3) AS refr_deg;
-- ============================================================
-- Test 5: Domain guard - refraction at -5 deg should return 0
-- (below -1 deg validity range)
-- ============================================================
SELECT 'refr_below_range' AS test,
atmospheric_refraction(-5.0) AS refr_deg;
-- ============================================================
-- Test 6: Domain guard - refraction at -10 deg returns 0 (no NaN)
-- ============================================================
SELECT 'refr_deep_neg' AS test,
atmospheric_refraction(-10.0) AS refr_deg,
atmospheric_refraction(-10.0) = atmospheric_refraction(-10.0) AS is_finite;
-- ============================================================
-- Test 7: Refraction at exactly -1 deg (edge of domain)
-- Should return a small positive value
-- ============================================================
SELECT 'refr_minus1' AS test,
atmospheric_refraction(-1.0) > 0 AS positive;
-- ============================================================
-- Test 8: Extended refraction with P/T correction
-- Standard: P=1010, T=10 should match basic function
-- ============================================================
SELECT 'refr_ext_standard' AS test,
round(atmospheric_refraction_ext(0.0, 1010.0, 10.0)::numeric, 2) AS refr_deg,
round(atmospheric_refraction(0.0)::numeric, 2) AS refr_basic,
round(atmospheric_refraction_ext(0.0, 1010.0, 10.0)::numeric, 4) =
round(atmospheric_refraction(0.0)::numeric, 4) AS match;
-- ============================================================
-- Test 9: Extended refraction - cold high-altitude
-- At P=700 mbar, T=-20 C, refraction should be reduced
-- ============================================================
SELECT 'refr_ext_cold' AS test,
round(atmospheric_refraction_ext(0.0, 700.0, -20.0)::numeric, 2) AS refr_deg,
atmospheric_refraction_ext(0.0, 700.0, -20.0) <
atmospheric_refraction(0.0) AS less_than_standard;
-- ============================================================
-- Test 10: Extended refraction - hot sea level
-- At P=1013, T=35 C, refraction should be slightly different
-- ============================================================
SELECT 'refr_ext_hot' AS test,
round(atmospheric_refraction_ext(0.0, 1013.0, 35.0)::numeric, 2) AS refr_deg;
-- ============================================================
-- Test 11: Apparent elevation for a topocentric observation
-- Sun near horizon: geometric el small -> apparent el higher
-- ============================================================
SELECT 'apparent_el' AS test,
round(topo_elevation(sun_observe(:boulder, '2024-03-20 00:30:00+00'))::numeric, 1) AS geometric,
round(topo_elevation_apparent(sun_observe(:boulder, '2024-03-20 00:30:00+00'))::numeric, 1) AS apparent,
topo_elevation_apparent(sun_observe(:boulder, '2024-03-20 00:30:00+00')) >
topo_elevation(sun_observe(:boulder, '2024-03-20 00:30:00+00')) AS refraction_positive;
-- ============================================================
-- Test 12: Apparent elevation for star high up (refraction small)
-- Polaris from Boulder has el ~49 deg; refraction ~0.01 deg
-- ============================================================
SELECT 'apparent_el_high' AS test,
round(topo_elevation_apparent(
star_observe(2.530303, 89.2641, :boulder, '2024-06-15 04:00:00+00'))::numeric, 1) AS apparent_deg;
-- ============================================================
-- Test 13: Refracted pass prediction returns results
-- Using the ISS TLE, should find passes in a week window
-- ============================================================
WITH iss AS (
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
)
SELECT 'refracted_passes' AS test,
count(*) > 0 AS has_passes
FROM iss, predict_passes_refracted(
t, :boulder,
'2024-01-02 00:00:00+00', '2024-01-09 00:00:00+00');
-- ============================================================
-- Test 14: Refracted passes find at least as many as standard
-- Because refracted horizon is -0.569 deg, satellites visible ~35s earlier
-- ============================================================
SELECT 'refracted_more_passes' AS test,
(SELECT count(*) FROM predict_passes_refracted(
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
:boulder,
'2024-01-02 00:00:00+00', '2024-01-09 00:00:00+00'))
>=
(SELECT count(*) FROM predict_passes(
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
:boulder,
'2024-01-02 00:00:00+00', '2024-01-09 00:00:00+00'))
AS refracted_ge_standard;
-- ============================================================
-- Test 15: Monotonicity - refraction decreases with elevation
-- ============================================================
SELECT 'refr_monotonic' AS test,
atmospheric_refraction(0.0) > atmospheric_refraction(10.0) AS r0_gt_r10,
atmospheric_refraction(10.0) > atmospheric_refraction(30.0) AS r10_gt_r30,
atmospheric_refraction(30.0) > atmospheric_refraction(60.0) AS r30_gt_r60,
atmospheric_refraction(60.0) > atmospheric_refraction(90.0) AS r60_gt_r90;

316
test/sql/spgist_tle.sql Normal file
View File

@ -0,0 +1,316 @@
-- Test SP-GiST orbital trie index and &? visibility cone operator
SET client_min_messages = warning;
CREATE EXTENSION IF NOT EXISTS pg_orrery;
RESET client_min_messages;
-- ============================================================
-- Test table with mixed orbital regimes
-- ============================================================
CREATE TABLE test_spgist (
id serial,
name text,
tle tle
);
-- ISS (LEO, ~400km, 51.64 deg)
INSERT INTO test_spgist (name, tle) VALUES ('ISS',
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001');
-- Hubble (LEO, ~540km, 28.47 deg)
INSERT INTO test_spgist (name, tle) VALUES ('Hubble',
'1 20580U 90037B 24001.50000000 .00000790 00000+0 39573-4 0 9992
2 20580 28.4705 61.4398 0002797 317.3115 42.7577 15.09395228 00008');
-- GPS IIR-M (MEO, ~20200km, 55.44 deg)
INSERT INTO test_spgist (name, tle) VALUES ('GPS-IIR',
'1 28874U 05038A 24001.50000000 .00000012 00000+0 00000+0 0 9993
2 28874 55.4408 300.3467 0117034 51.6543 309.5420 2.00557079 00006');
-- Equatorial-LEO (same altitude as ISS, 5 deg inclination)
INSERT INTO test_spgist (name, tle) VALUES ('Equatorial-LEO',
'1 99901U 24999A 24001.50000000 .00016717 00000-0 10270-3 0 9990
2 99901 5.0000 208.9163 0006703 30.1694 61.7520 15.50100486 00001');
-- SSO-800 (Sun-synchronous, ~800km, 98.7 deg)
INSERT INTO test_spgist (name, tle) VALUES ('SSO-800',
'1 99902U 24999B 24001.50000000 .00000100 00000+0 50000-4 0 9991
2 99902 98.7000 120.0000 0001000 90.0000 270.0000 14.19553000 00001');
-- GEO-SAT (Geostationary, ~35786km, 0.04 deg)
INSERT INTO test_spgist (name, tle) VALUES ('GEO-SAT',
'1 99903U 24999C 24001.50000000 .00000000 00000+0 00000+0 0 9992
2 99903 0.0400 270.0000 0003000 0.0000 180.0000 1.00273791 00001');
-- ============================================================
-- Test 1: Operator standalone — ISS from Eagle Idaho (2h window)
-- Eagle Idaho: 43.6977N 116.3535W, 760m elevation
-- ISS passes altitude and inclination checks, but RAAN filter
-- rejects it — the orbital plane isn't overhead during this
-- specific 2-hour window (correct physics, see Test 5 for 24h).
-- ============================================================
SELECT name,
tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-01 02:00:00+00'::timestamptz,
10.0
)::observer_window AS visible
FROM test_spgist
WHERE name = 'ISS';
-- ============================================================
-- Test 2: Equatorial-LEO NOT visible from Eagle Idaho
-- 5 deg inc + ~12 deg footprint = 17 deg < 43.7 deg latitude
-- ============================================================
SELECT name,
tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-01 02:00:00+00'::timestamptz,
10.0
)::observer_window AS visible
FROM test_spgist
WHERE name = 'Equatorial-LEO';
-- ============================================================
-- Test 3: Create SP-GiST index, verify index scan with positive
-- results. Equatorial observer at 0E — SSO-800 RAAN (120 deg)
-- aligns with LST near 0E at this epoch, so it passes.
-- ============================================================
CREATE INDEX test_spgist_idx ON test_spgist USING spgist (tle tle_spgist_ops);
SET enable_seqscan = off;
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-01 02:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
RESET enable_seqscan;
-- ============================================================
-- Test 4: Seqscan vs index scan consistency — same query must
-- return identical results regardless of scan method.
-- ============================================================
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-01 02:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
RESET enable_indexscan;
RESET enable_bitmapscan;
-- ============================================================
-- Test 5: 24-hour window — RAAN filter bypassed (full Earth
-- rotation). Only ISS and SSO-800 pass inclination from Eagle
-- Idaho (43.7 deg). Hubble (28.5+14.8=43.3 deg) barely fails.
-- GPS-IIR and GEO-SAT filtered by altitude.
-- ============================================================
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
-- ============================================================
-- Test 6: High min_el (45 deg) changes footprint — wider
-- footprint lets more inclinations through. Same 24h window.
-- ============================================================
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
45.0
)::observer_window
ORDER BY name;
-- ============================================================
-- Test 7: GiST coexistence — both index types on same table
-- ============================================================
CREATE INDEX test_gist_idx ON test_spgist USING gist (tle);
-- GiST overlap query still works
SELECT a.name AS sat_a, b.name AS sat_b, a.tle && b.tle AS overlaps
FROM test_spgist a, test_spgist b
WHERE a.name = 'ISS' AND b.name = 'Hubble';
-- SP-GiST query still works alongside GiST
SET enable_seqscan = off;
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
RESET enable_seqscan;
-- ============================================================
-- Test 8: NULL TLE handling — NULLs should be excluded
-- ============================================================
INSERT INTO test_spgist (name, tle) VALUES ('NULL-SAT', NULL);
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
-- ============================================================
-- Test 9: Degenerate TLE (mean_motion = 0) — rejected by filter
-- ============================================================
INSERT INTO test_spgist (name, tle) VALUES ('DECAYED',
'1 99904U 24999D 24001.50000000 .00000000 00000+0 00000+0 0 9993
2 99904 0.0000 0.0000 0000000 0.0000 0.0000 0.00000000 00001');
SELECT name,
tle &? ROW(
observer('0.0N 0.0E 0m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window AS visible
FROM test_spgist
WHERE name = 'DECAYED';
-- ============================================================
-- Test 10: Polar observer (90N) — only ISS and SSO-800 reach
-- the pole. ISS (51.6 + footprint) < 90, so only SSO-800
-- (retrograde, 98.7 deg inc > 90 deg) passes. 24h window.
-- ============================================================
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('90.0N 0.0E 0m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
-- ============================================================
-- Test 11: Zero-duration window — sees only what is directly
-- overhead at the instant. RAAN window = footprint only.
-- ============================================================
SELECT name,
tle &? ROW(
observer('0.0N 0.0E 0m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-01 00:00:00+00'::timestamptz,
10.0
)::observer_window AS visible
FROM test_spgist
WHERE name = 'ISS';
-- ============================================================
-- Test 12: Index-vs-seqscan consistency on 24h Eagle Idaho
-- (the primary correctness test, now after all inserts)
-- ============================================================
SET enable_seqscan = off;
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
RESET enable_seqscan;
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT name
FROM test_spgist
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
RESET enable_indexscan;
RESET enable_bitmapscan;
-- ============================================================
-- Test 13: HEO at high latitude — GTO-class orbit (low inc,
-- high SMA, high eccentricity) from Tromsø (69.6°N).
-- The large SMA gives a huge footprint that compensates for the
-- low inclination. Must pass the seqscan operator check.
-- Regression test for the L1 pruning bug (sma_low vs sma_high).
-- ============================================================
-- GTO debris: inc 5 deg, perigee ~250 km, apogee ~35786 km
INSERT INTO test_spgist (name, tle) VALUES ('GTO-DEBRIS',
'1 99905U 24999E 24001.50000000 .00000100 00000+0 10000-3 0 9994
2 99905 5.0000 210.0000 7300000 30.0000 61.0000 2.25600000 00001');
-- Seqscan: GTO-DEBRIS from Tromsø — must be visible
-- inc 5 deg + footprint(SMA ~25000) ~65 deg = 70 > 69.6
SELECT name,
tle &? ROW(
observer('69.6N 19.0E 0m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window AS visible
FROM test_spgist
WHERE name = 'GTO-DEBRIS';
-- Index scan: same query, must return the same result
SET enable_seqscan = off;
SELECT name,
tle &? ROW(
observer('69.6N 19.0E 0m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window AS visible
FROM test_spgist
WHERE name = 'GTO-DEBRIS';
RESET enable_seqscan;
-- ============================================================
-- Cleanup
-- ============================================================
DROP TABLE test_spgist;