Merge rename/pg-orrery: v0.5.0 OD solver enhancements

This commit is contained in:
Ryan Malloy 2026-02-17 17:09:14 -07:00
commit 6e17513885
167 changed files with 353822 additions and 496 deletions

11
.gitignore vendored
View File

@ -17,3 +17,14 @@ log/
*~ *~
.vscode/ .vscode/
.idea/ .idea/
# Test artifacts
test/matrix-logs/
test/test_de_reader
test/test_od_math
test/test_od_iod
# Docs site
docs/node_modules/
docs/dist/
docs/.astro/

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "lib/sat_code"]
path = lib/sat_code
url = https://github.com/Bill-Gray/sat_code.git

393
CLAUDE.md
View File

@ -1,147 +1,348 @@
# pg_orbit — PostgreSQL Extension for Orbital Mechanics # pg_orrery — A Database Orrery for PostgreSQL
## What This Is ## What This Is
A PostgreSQL extension that makes TLE/orbital data first-class types — the way PostGIS does for geographic data. Native C extension using PGXS, wrapping Bill Gray's `sat_code` SGP4/SDP4 implementation. 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.
**Current version:** 0.3.0 on branch `phase/solar-system-expansion`
**Repository:** https://git.supported.systems/warehack.ing/pg_orrery
**Documentation:** https://pg-orrery.warehack.ing
## Build System ## Build System
```bash ```bash
make # Compile with PGXS make PG_CONFIG=/usr/bin/pg_config # Compile with PGXS
make install # Install to PostgreSQL extensions dir sudo make install PG_CONFIG=/usr/bin/pg_config # Install extension
make installcheck # Run regression tests make installcheck PG_CONFIG=/usr/bin/pg_config # Run 13 regression test suites
``` ```
Requires: PostgreSQL 14+ development headers (`pg_config` in PATH), GCC, Make. Requires: PostgreSQL 17 development headers, GCC, Make.
### Docker
```bash
make docker-build # Build standalone image (pg17 + pg_orrery)
make docker-test # Smoke test the image
make docker-push # Push to git.supported.systems registry
```
Image: `git.supported.systems/warehack.ing/pg_orrery:pg17`
## Project Layout ## Project Layout
``` ```
pg_orbit.control # Extension metadata pg_orrery.control # Extension metadata (version 0.3.0)
Makefile # PGXS build Makefile # PGXS build + Docker targets
sql/ sql/
pg_orbit--0.1.0.sql # All type/function/operator definitions 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.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)
src/ src/
pg_orbit.c # PG_MODULE_MAGIC entry point pg_orrery.c # PG_MODULE_MAGIC + _PG_init() (GUC registration)
tle_type.c # TLE custom type (input/output/binary/accessors) types.h # All struct definitions + constants + DE body ID mapping
eci_type.c # ECI position type + geodetic/topocentric types astro_math.h # Shared astronomical helpers + observe_from_geocentric()
observer_type.c # Observer location type with flexible parsing # --- Satellite (v0.1.0) ---
sgp4_funcs.c # sgp4_propagate(), sgp4_propagate_series() tle_type.c # TLE custom type (I/O, binary, 15 accessors)
coord_funcs.c # eci_to_geodetic(), eci_to_topocentric(), subsatellite_point() eci_type.c # ECI position type + geodetic/topocentric types
pass_funcs.c # next_pass(), predict_passes(), pass_visible() observer_type.c # Observer type with flexible string parsing
gist_tle.c # GiST operator class for altitude-band indexing sgp4_funcs.c # sgp4_propagate(), _safe(), _series(), tle_distance()
types.h # Shared struct definitions coord_funcs.c # eci_to_geodetic(), eci_to_topocentric(), ground_track()
lib/ pass_funcs.c # next_pass(), predict_passes(), pass_visible()
sat_code/ # Bill Gray's SGP4 (MIT license, git submodule) gist_tle.c # GiST operator class (&&, <->)
# --- Solar System (v0.2.0) ---
vsop87.c / vsop87.h # VSOP87 planetary ephemeris (Bretagnon 1988)
elp82b.c / elp82b.h # ELP2000-82B lunar ephemeris (Chapront 1988)
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()
kepler_funcs.c # kepler_propagate(), comet_observe()
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)
marssat.c / marssat.h # MarsSat Mars moon theory (Jacobson 2014)
moon_funcs.c # galilean/saturn/uranus/mars_moon_observe()
radio_funcs.c # io_phase_angle(), jupiter_cml(), burst_probability()
lambert.c / lambert.h # Lambert transfer solver (Izzo 2015)
transfer_funcs.c # lambert_transfer(), lambert_c3()
# --- JPL DE Ephemeris (v0.3.0) ---
de_reader.h / de_reader.c # Clean-room JPL DE binary reader (Chebyshev/Clenshaw)
eph_provider.h / eph_provider.c # Provider dispatch, GUC, lazy init, frame rotation
de_funcs.c # All _de() SQL function implementations
sgp4/ # Vendored SGP4/SDP4 (Bill Gray's sat_code, MIT license)
sgp4.c # Near-earth propagator (period < 225 min)
sdp4.c # Deep-space propagator (period >= 225 min)
deep.c # Lunar/solar perturbations, resonance integration
common.c # Shared initialization (Brouwer mean elements, Kepler solver)
basics.c # select_ephemeris(), FMod2p()
get_el.c # TLE parsing (parse_elements(), checksum)
tle_out.c # TLE output (write_elements_in_tle_format())
norad.h / norad_in.h # Public + internal headers
PROVENANCE.md # Vendoring decision, modifications, verification
LICENSE # MIT license (Bill Gray / Project Pluto)
test/ test/
sql/ # Regression test SQL sql/ # 13 regression test suites
expected/ # Expected output expected/ # Expected output
data/ data/vallado_518.json # 518 Vallado test vectors (AIAA 2006-6753-Rev1)
vallado_518.csv # 518 verification test vectors
docs/ docs/
DESIGN.md # Architecture decisions, theory-to-code mappings DESIGN.md # Architecture decisions, theory-to-code mappings
Dockerfile # Starlight docs site (Astro + Caddy)
package.json # Docs site dependencies
astro.config.mjs # Starlight configuration
src/content/docs/ # MDX documentation pages
``` ```
## Type System ## Type System
### Core Types (all varlena or fixed-size, stored in tuples) All types are fixed-size, `STORAGE = plain`, `ALIGNMENT = double`. No TOAST overhead.
| Type | Storage | Description | | Type | Bytes | Description |
|------|---------|-------------| |------|-------|-------------|
| `tle` | ~160 bytes fixed | Parsed mean elements (not raw text) | | `tle` | 112 | Parsed mean orbital elements for SGP4/SDP4 |
| `eci_position` | 48 bytes | x,y,z + vx,vy,vz (km, km/s) in TEME | | `eci_position` | 48 | x,y,z + vx,vy,vz (km, km/s) in TEME frame |
| `geodetic` | 24 bytes | lat, lon (radians), alt (km) above WGS-84 | | `geodetic` | 24 | lat, lon (radians), alt (km) above WGS-84 |
| `topocentric` | 32 bytes | azimuth, elevation, range, range_rate | | `topocentric` | 32 | azimuth, elevation, range, range_rate |
| `observer` | 24 bytes | lat, lon (radians), alt_m (meters) | | `observer` | 24 | lat, lon (radians), alt_m (meters) |
| `pass_event` | 56 bytes | AOS/MAX/LOS times + max_el + AOS/LOS az | | `pass_event` | 48 | AOS/MAX/LOS times + max_el + AOS/LOS azimuth |
| `heliocentric` | 24 | x, y, z in AU (ecliptic J2000 frame) |
### TLE Internal Struct ## Function Domains (68 total)
Stores all parsed mean elements from the two-line format:
- epoch (Julian date, float64) | Domain | Theory | Key Functions | Count |
- inclination, eccentricity, RAAN, arg_perigee, mean_anomaly (radians, float64) |--------|--------|---------------|-------|
- mean_motion (rev/day, float64), mean_motion_dot, mean_motion_ddot | Satellite | SGP4/SDP4 (Brouwer 1959) | `observe()`, `predict_passes()`, `ground_track()` | 22 |
- bstar (drag coefficient, float64) | Planets | VSOP87 (Bretagnon 1988) | `planet_observe()`, `planet_heliocentric()` | 3 |
- norad_id (int32), elset_num (int32), rev_num (int32) | Sun/Moon | VSOP87 + ELP2000-82B | `sun_observe()`, `moon_observe()` | 2 |
- classification (char), intl_designator (8 chars) | Planetary moons | L1.2, TASS17, GUST86, MarsSat | `galilean_observe()`, `saturn_moon_observe()` | 4 |
- ephemeris_type (int8) | Stars | J2000 + IAU 1976 precession | `star_observe()`, `star_observe_safe()` | 2 |
| Comets/asteroids | Two-body Keplerian | `kepler_propagate()`, `comet_observe()` | 2 |
| 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 |
| GiST index | Altitude-band approximation | `&&` (overlap), `<->` (distance) | 8 |
| Diagnostics | -- | `pg_orrery_ephemeris_info()` | 1 |
All functions are `PARALLEL SAFE`. VSOP87/ELP82B functions are `IMMUTABLE` (compiled-in coefficients). DE functions are `STABLE` (external file dependency).
## 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 |
### Planetary Moons (per-family indexing)
- **Galilean** (0-3): Io, Europa, Ganymede, Callisto
- **Saturn** (0-7): Mimas, Enceladus, Tethys, Dione, Rhea, Titan, Iapetus, Hyperion
- **Uranus** (0-4): Miranda, Ariel, Umbriel, Titania, Oberon
- **Mars** (0-1): Phobos, Deimos
## Constant Chain of Custody ## Constant Chain of Custody
**This is the most critical design constraint.** **The most critical design constraint.** TLEs absorb geodetic model biases — using wrong constants silently corrupts positions by kilometers.
TLEs are mean elements fitted using WGS-72 constants. The elements absorb geodetic model biases — using WGS-84 constants for propagation silently corrupts position accuracy by kilometers.
### Rules ### Rules
1. **SGP4 propagation**: WGS-72 constants ONLY (mu, ae, J2, J3, J4, ke) 1. **SGP4 propagation**: WGS-72 constants ONLY (mu, ae, J2, J3, J4, ke)
2. **Coordinate output** (geodetic, topocentric): Convert to WGS-84 (a=6378.137km, f=1/298.257223563) 2. **Coordinate output** (geodetic, topocentric): WGS-84 (a=6378.137km, f=1/298.257223563)
3. **TEME frame**: Use only 4 of 106 IAU-80 nutation terms (matching SGP4's internal model) 3. **TEME frame**: Only 4 of 106 IAU-80 nutation terms (matching SGP4's internal model)
4. **Never mix**: WGS-72 propagation + WGS-84 output. No other combination. 4. **Solar system pipeline**: IAU 1976 precession, J2000 obliquity, GMST from Vallado Eq. 3-47
5. **Never mix**: WGS-72 propagation + WGS-84 output. No other combination.
6. **DE frame rotation**: DE positions (ICRS equatorial) pass through `equatorial_to_ecliptic()` at the provider boundary before entering the observation pipeline
7. **Same-provider rule**: Both target and Earth must come from the same provider in any geocentric computation (never mix DE target with VSOP87 Earth)
8. **DE AU consistency**: Verify DE header AU matches compiled-in `AU_KM` (149597870.7) at init time
### WGS-72 Constants (from Hoots & Roehrich STR#3) ### WGS-72 Constants (from Hoots & Roehrich STR#3, propagation only)
```c ```c
#define WGS72_MU 398600.8 /* km^3/s^2 */ #define WGS72_MU 398600.8 /* km^3/s^2 */
#define WGS72_AE 6378.135 /* km */ #define WGS72_AE 6378.135 /* km */
#define WGS72_J2 0.001082616 #define WGS72_J2 0.001082616
#define WGS72_J3 -0.00000253881 #define WGS72_KE 0.0743669161331734132 /* (min)^(-1) */
#define WGS72_J4 -0.00000165597
#define WGS72_KE 0.0743669161 /* (min)^(-1), = sqrt(mu) * 60 / ae^(3/2) */
#define WGS72_XPDOTP 1440.0 / (2.0 * M_PI) /* min/rev */
``` ```
### WGS-84 Constants (for output only) ### WGS-84 Constants (coordinate output only)
```c ```c
#define WGS84_A 6378.137 /* km */ #define WGS84_A 6378.137 /* km */
#define WGS84_F (1.0 / 298.257223563) #define WGS84_F (1.0 / 298.257223563)
#define WGS84_E2 (WGS84_F * (2.0 - WGS84_F))
``` ```
## sat_code Submodule ### Astronomical Constants
Bill Gray's SGP4 implementation: https://github.com/Bill-Gray/sat_code
Key files we use:
- `sgp4.c` / `sgp4.h` — SGP4/SDP4 propagator
- `norad.h` — TLE struct definitions and constants
The submodule lives at `lib/sat_code/`. To initialize:
```bash
git submodule update --init
```
### Integration Pattern
```c ```c
#include "lib/sat_code/norad.h" #define AU_KM 149597870.7 /* IAU 2012 */
#define GAUSS_K 0.01720209895 /* AU^(3/2)/day */
// Parse TLE lines into sat_code's tle_t struct #define OBLIQUITY_J2000 0.40909280422232897 /* 23.4392911 deg in radians */
// Call SGP4_init() once per TLE #define J2000_JD 2451545.0 /* 2000 Jan 1.5 TT */
// Call SGP4() with minutes-since-epoch for each propagation
``` ```
## JPL DE Ephemeris (Optional)
v0.3.0 adds optional JPL DE440/441 ephemeris support (~0.1 milliarcsecond accuracy) alongside the existing VSOP87 pipeline (~1 arcsecond). DE functions are separate `_de()` variants — existing VSOP87 functions are completely unchanged.
### Architecture
- **Clean-room DE reader** (`de_reader.c`): ~250 lines of C. Parses the JPL binary format, evaluates Chebyshev polynomials via Clenshaw recurrence. No GPL dependency (avoids Bill Gray's `jpl_eph`).
- **Per-backend lazy init**: Each PostgreSQL backend opens its own file descriptor on first `_de()` call. Never opens in `_PG_init()` (postmaster context). Safe for `PARALLEL SAFE`.
- **VSOP87 fallback**: Every `_de()` function falls back to its VSOP87/ELP82B equivalent when DE is unavailable.
- **STABLE volatility**: DE functions are `STABLE` (not `IMMUTABLE`) because output depends on an external file. Existing VSOP87 functions remain `IMMUTABLE`.
### GUC Configuration
```sql
-- Set the path to a JPL DE binary file (requires superuser)
ALTER SYSTEM SET pg_orrery.ephemeris_path = '/var/lib/postgres/de441.bin';
SELECT pg_reload_conf();
-- Check which provider is active
SELECT * FROM pg_orrery_ephemeris_info();
```
| GUC | Type | Default | Context |
|-----|------|---------|---------|
| `pg_orrery.ephemeris_path` | string | `''` (empty = VSOP87 only) | `SIGHUP` (superuser only) |
### DE Function Variants
Every `_de()` function mirrors an existing VSOP87 function:
| DE Function | VSOP87 Equivalent | Volatility |
|-------------|-------------------|------------|
| `planet_heliocentric_de()` | `planet_heliocentric()` | STABLE |
| `planet_observe_de()` | `planet_observe()` | STABLE |
| `sun_observe_de()` | `sun_observe()` | STABLE |
| `moon_observe_de()` | `moon_observe()` | STABLE |
| `lambert_transfer_de()` | `lambert_transfer()` | STABLE |
| `lambert_c3_de()` | `lambert_c3()` | STABLE |
| `galilean_observe_de()` | `galilean_observe()` | STABLE |
| `saturn_moon_observe_de()` | `saturn_moon_observe()` | STABLE |
| `uranus_moon_observe_de()` | `uranus_moon_observe()` | STABLE |
| `mars_moon_observe_de()` | `mars_moon_observe()` | STABLE |
| `pg_orrery_ephemeris_info()` | — | STABLE |
## Vendored SGP4/SDP4
Bill Gray's sat_code (MIT license): https://github.com/Bill-Gray/sat_code
Vendored into `src/sgp4/` from upstream commit `ff7b98957dfa2979700a482bde9de9542807293e`. The `.cpp` files were renamed to `.c` — the code is valid C99 with zero C++ features (no classes, templates, namespaces, exceptions, or STL). This eliminates the `g++` and `-lstdc++` dependencies.
Modifications from upstream are minimal and documented in `src/sgp4/PROVENANCE.md`:
- Renamed `.cpp``.c` (no code changes — already valid C99)
- Stripped Win32 `DLL_FUNC`/`__stdcall` decorators
- Removed `extern "C"` wrapper (now compiled as C)
- Removed unused SGP/SGP8/SDP8 model prototypes
- Added forward declarations (`-Wmissing-prototypes`)
- Changed bare `inline` to `static inline` for C99 compliance
- Added equation citations referencing STR#3 and Vallado Rev-1
All numerical logic is byte-identical to upstream. Verified against 518 Vallado test vectors (AIAA 2006-6753-Rev1).
## Testing ## Testing
### Vallado 518 Test Vectors 13 regression test suites via `make installcheck`:
The definitive SGP4 verification dataset. Each row: NORAD ID, minutes since epoch, expected x,y,z,vx,vy,vz. All 518 must pass to machine epsilon before any other work proceeds.
### Regression Tests | Suite | What it tests |
Standard PostgreSQL `make installcheck` framework: |-------|--------------|
- `test/sql/*.sql` — test queries | tle_parse | TLE I/O round-trip, malformed input rejection, all 15 accessors |
- `test/expected/*.out` — expected output | sgp4_propagate | SGP4/SDP4, propagation series, tle_distance |
- Tests run against a temporary database | coord_transforms | TEME-to-geodetic, TEME-to-topocentric, ground_track |
| pass_prediction | predict_passes, next_pass, pass_visible, min elevation filter |
| gist_index | `&&` overlap, `<->` distance, GiST index scan, KNN ordering |
| convenience | observe(), observe_safe(), tle_from_lines(), observer_from_geodetic() |
| star_observe | Star observation, IAU 1976 precession, heliocentric type I/O |
| kepler_comet | Keplerian propagation (elliptic/parabolic/hyperbolic), comet_observe |
| planet_observe | VSOP87 planets, sun_observe, moon_observe (ELP2000-82B) |
| 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 |
| vallado_518 | 518 Vallado test vectors (AIAA 2006-6753-Rev1), per-satellite breakdown |
### Test Categories ### PG Version Matrix
1. **tle_parse** — TLE input/output round-trip, malformed input rejection
2. **sgp4_propagate** — Vallado vectors, edge cases (deep space, high eccentricity) Test all 13 regression suites + DE reader unit test across PostgreSQL 14-18 using Docker:
3. **coord_transforms** — TEME->geodetic, TEME->topocentric accuracy
4. **pass_prediction** — Known ISS passes, edge cases (polar, retrograde) ```bash
5. **gist_index** — Index scan vs sequential scan equivalence make test-matrix # Full matrix (PG 14-18)
make test-pg18 # Single version
PG_TEST_VERSIONS="16 17" make test-matrix # Subset
make test-matrix-clean # Remove logs + test images
```
Logs saved to `test/matrix-logs/pg${ver}.log`. The script reuses the Dockerfile `builder` stage as the test engine — no additional test infrastructure.
**Adding a new PG version:** Update `PG_TEST_VERSIONS` default in `Makefile` and `PG_VERSIONS` default in `test/pg-version-matrix.sh`.
## Error Handling Patterns
- `_safe()` variants (`sgp4_propagate_safe`, `observe_safe`, `star_observe_safe`) return NULL on error instead of raising exceptions. Use these for batch queries over potentially invalid data.
- SGP4 error codes: -1 (nearly parabolic), -2 (negative semi-major axis/decayed), -3/-4 (orbit within Earth, returns with NOTICE), -5 (negative mean motion), -6 (convergence failure)
- Pass prediction: propagation failures return -pi elevation (below horizon), shedding the failed timestep without aborting the scan.
- Input validation: same-body Lambert check, arrival-before-departure, invalid body_id, RA out of [0,24), negative perihelion distance.
## Documentation Site
**Live:** https://pg-orrery.warehack.ing
Starlight docs at `docs/` — 36 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).
### Local Development
```bash
cd docs && npm run dev # Dev server on :4321
cd docs && npm run build # Static build to dist/
```
### Production Deployment
The docs site deploys to the `warehack.ing` VPS (`149.28.126.25`) which runs caddy-docker-proxy with wildcard DNS for `*.warehack.ing`.
**Deploy (or redeploy after changes):**
```bash
ssh -A warehack-ing@pg-orrery.warehack.ing
cd ~/pg_orrery
git pull origin phase/solar-system-expansion # or the current branch
cd docs
make prod # builds image + starts container
```
**First-time setup on VPS:**
```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
cat > docs/.env << 'EOF'
COMPOSE_PROJECT_NAME=pg-orrery-docs
NODE_ENV=production
VITE_HMR_HOST=pg-orrery.warehack.ing
EOF
cd docs && make prod
```
**Makefile targets:**
- `make prod` — build + start production (Caddy serves static files)
- `make dev` — build + start dev mode (hot-reload, volume mounts)
- `make down` — stop containers
- `make restart` — stop + start production
- `make clean` — stop + remove volumes
- `make logs` — tail container logs
**Infrastructure:** Container `pg-orrery-docs` joins external `caddy` network. caddy-docker-proxy reads labels to auto-configure reverse proxy + TLS (Let's Encrypt via Vultr DNS challenge). TLS cert provisioning takes ~2 minutes on first deploy.
**Do NOT run the docs container locally** if also deployed on the VPS — competing ACME DNS challenges will corrupt each other's TXT records.
## Coding Style ## Coding Style
- Standard PostgreSQL extension C style - Standard PostgreSQL extension C style
- `ereport(ERROR, ...)` for user-facing errors, never `elog(ERROR, ...)` - `ereport(ERROR, ...)` for user-facing errors, never `elog(ERROR, ...)`
- All memory allocation through `palloc`/`pfree` (PostgreSQL memory contexts) - All memory via `palloc`/`pfree` (PostgreSQL memory contexts)
- Comments explain "why", not "what" - Comments explain "why", not "what"
- No global mutable state — all computation from function arguments - No global mutable state — all computation from function arguments (exceptions: per-backend DE handle via `on_proc_exit`; 3 statics in vendored `deep.c` + 1 cache in `sdp4.c`, safe in PostgreSQL's fork model, never modified by pg_orrery)
- Functions that call `SGP4()` must handle the error return code - Every function handling SGP4 must check the error return code
- All functions marked `PARALLEL SAFE`
- DE functions: always fall back to VSOP87/ELP82B on any error
## Git Conventions ## Git Conventions
- One commit per logical change - One commit per logical change
- Branch per phase: `phase/1-tle-sgp4`, `phase/2-coordinates`, etc. - Branch per phase: `phase/solar-system-expansion`
- Tag releases: `v0.1.0`, `v0.2.0`, etc. - Tag releases: `v0.1.0`, `v0.2.0`
- Commit messages: imperative mood, no AI attribution - Commit messages: imperative mood, no AI attribution

View File

@ -20,11 +20,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
apt-get update && apt-get install -y --no-install-recommends \ apt-get update && apt-get install -y --no-install-recommends \
postgresql-${PG_MAJOR} \ postgresql-${PG_MAJOR} \
postgresql-server-dev-${PG_MAJOR} \ postgresql-server-dev-${PG_MAJOR} \
gcc g++ make && \ gcc make \
liblapack-dev libblas-dev && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# Copy source tree (submodule content included as regular files) # Copy source tree (submodule content included as regular files)
WORKDIR /build/pg_orbit WORKDIR /build/pg_orrery
COPY . . COPY . .
ENV PG_CONFIG=/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config ENV PG_CONFIG=/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config
@ -35,15 +36,20 @@ RUN make PG_CONFIG=${PG_CONFIG}
# Install to system location (needed for installcheck) # Install to system location (needed for installcheck)
RUN make PG_CONFIG=${PG_CONFIG} install RUN make PG_CONFIG=${PG_CONFIG} install
# Run all 6 regression test suites against a throwaway cluster # Run all 14 regression test suites against a throwaway cluster
RUN su postgres -c "/usr/lib/postgresql/${PG_MAJOR}/bin/initdb -D /tmp/pgtest" && \ RUN su postgres -c "/usr/lib/postgresql/${PG_MAJOR}/bin/initdb -D /tmp/pgtest" && \
su postgres -c "/usr/lib/postgresql/${PG_MAJOR}/bin/pg_ctl -D /tmp/pgtest -l /tmp/pgtest.log start" && \ su postgres -c "/usr/lib/postgresql/${PG_MAJOR}/bin/pg_ctl -D /tmp/pgtest -l /tmp/pgtest.log start" && \
su postgres -c "/usr/lib/postgresql/${PG_MAJOR}/bin/createuser -s root" && \ su postgres -c "/usr/lib/postgresql/${PG_MAJOR}/bin/createuser -s root" && \
make PG_CONFIG=${PG_CONFIG} installcheck && \ make PG_CONFIG=${PG_CONFIG} installcheck && \
su postgres -c "/usr/lib/postgresql/${PG_MAJOR}/bin/pg_ctl -D /tmp/pgtest stop" su postgres -c "/usr/lib/postgresql/${PG_MAJOR}/bin/pg_ctl -D /tmp/pgtest stop"
# Capture artifacts under /pg_orbit prefix for the next stage # Standalone unit tests (no PostgreSQL dependency)
RUN make PG_CONFIG=${PG_CONFIG} DESTDIR=/pg_orbit install RUN make test-de-reader
RUN make test-od-math
RUN make test-od-iod
# Capture artifacts under /pg_orrery prefix for the next stage
RUN make PG_CONFIG=${PG_CONFIG} DESTDIR=/pg_orrery install
# ── Stage 2: Minimal artifact (COPY --from target) ────────── # ── Stage 2: Minimal artifact (COPY --from target) ──────────
@ -51,13 +57,30 @@ RUN make PG_CONFIG=${PG_CONFIG} DESTDIR=/pg_orbit install
# Downstream images (TimescaleDB-HA, vanilla PG) pull from here. # Downstream images (TimescaleDB-HA, vanilla PG) pull from here.
FROM scratch AS artifact FROM scratch AS artifact
COPY --from=builder /pg_orbit/ / COPY --from=builder /pg_orrery/ /
COPY docker/020_install_pg_orbit.sh /docker-entrypoint-initdb.d/ COPY docker/020_install_pg_orrery.sh /docker-entrypoint-initdb.d/
# ── Stage 3: Standalone dev/test image ─────────────────────── # ── Stage 3: Standalone dev/test image ───────────────────────
# Ready-to-run PostgreSQL with pg_orbit pre-installed. # Ready-to-run PostgreSQL with pg_orrery pre-installed.
# For development, CI, and standalone experiments. # For development, CI, and standalone experiments.
#
# Optional DE ephemeris at runtime (recommended):
# docker run -v /path/to/de440.bin:/var/lib/postgresql/pg_orrery/de440.bin pg_orrery
# Then: ALTER SYSTEM SET pg_orrery.ephemeris_path = '/var/lib/postgresql/pg_orrery/de440.bin';
#
# Or bake into the image (115 MB for DE440, 3.1 GB for DE441):
# Place the DE file in the build context, then:
# docker build --build-arg DE_FILE=de440.bin -t pg_orrery:de440 .
FROM postgres:${PG_MAJOR}-bookworm AS standalone FROM postgres:${PG_MAJOR}-bookworm AS standalone
# LAPACK/BLAS runtime for OD solver (dgelss_)
RUN apt-get update && apt-get install -y --no-install-recommends \
liblapack3 libblas3 && \
rm -rf /var/lib/apt/lists/*
COPY --from=artifact / / COPY --from=artifact / /
# Create the pg_orrery data directory for DE ephemeris files
RUN mkdir -p /var/lib/postgresql/pg_orrery && \
chown postgres:postgres /var/lib/postgresql/pg_orrery

View File

@ -1,43 +1,94 @@
MODULE_big = pg_orbit MODULE_big = pg_orrery
EXTENSION = pg_orbit EXTENSION = pg_orrery
DATA = sql/pg_orbit--0.1.0.sql DATA = sql/pg_orrery--0.1.0.sql sql/pg_orrery--0.2.0.sql sql/pg_orrery--0.1.0--0.2.0.sql \
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
# Our extension C sources # Our extension C sources
OBJS = src/pg_orbit.o src/tle_type.o src/eci_type.o src/observer_type.o \ OBJS = src/pg_orrery.o src/tle_type.o src/eci_type.o src/observer_type.o \
src/sgp4_funcs.o src/coord_funcs.o src/pass_funcs.o src/gist_tle.o src/sgp4_funcs.o src/coord_funcs.o src/pass_funcs.o src/gist_tle.o \
src/star_funcs.o src/kepler_funcs.o \
src/vsop87.o src/elp82b.o src/elliptic_to_rectangular.o \
src/precession.o src/sidereal_time.o src/planet_funcs.o \
src/tass17.o src/gust86.o src/marssat.o src/l12.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
# sat_code C++ sources (compiled with g++, linked with extern "C" symbols) # Vendored SGP4/SDP4 sources (pure C, from Bill Gray's sat_code, MIT license)
SAT_CODE_DIR = lib/sat_code SGP4_DIR = src/sgp4
SAT_CODE_SRCS = $(SAT_CODE_DIR)/sgp4.cpp $(SAT_CODE_DIR)/sdp4.cpp \ SGP4_SRCS = $(SGP4_DIR)/sgp4.c $(SGP4_DIR)/sdp4.c \
$(SAT_CODE_DIR)/deep.cpp $(SAT_CODE_DIR)/common.cpp \ $(SGP4_DIR)/deep.c $(SGP4_DIR)/common.c \
$(SAT_CODE_DIR)/basics.cpp $(SAT_CODE_DIR)/get_el.cpp \ $(SGP4_DIR)/basics.c $(SGP4_DIR)/get_el.c \
$(SAT_CODE_DIR)/tle_out.cpp $(SGP4_DIR)/tle_out.c
SAT_CODE_OBJS = $(SAT_CODE_SRCS:.cpp=.o) SGP4_OBJS = $(SGP4_SRCS:.c=.o)
OBJS += $(SAT_CODE_OBJS) OBJS += $(SGP4_OBJS)
# Regression tests # Regression tests
REGRESS = tle_parse sgp4_propagate coord_transforms pass_prediction gist_index convenience 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
REGRESS_OPTS = --inputdir=test REGRESS_OPTS = --inputdir=test
# Need C++ runtime for sat_code # Pure C — no C++ runtime needed. LAPACK for OD solver (dgelss_).
SHLIB_LINK += -lstdc++ -lm SHLIB_LINK += -lm -llapack -lblas
# Compiler flags # Compiler flags
PG_CPPFLAGS = -I$(SAT_CODE_DIR) PG_CPPFLAGS = -I$(SGP4_DIR)
# Use PGXS # Use PGXS
PG_CONFIG ?= pg_config PG_CONFIG ?= pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs) PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS) include $(PGXS)
# Rule for compiling sat_code C++ files # ── Standalone DE reader unit test (no PostgreSQL dependency) ──
$(SAT_CODE_DIR)/%.o: $(SAT_CODE_DIR)/%.cpp # Generates a synthetic DE binary, exercises Chebyshev evaluation,
$(CXX) $(CXXFLAGS) -fPIC -I$(SAT_CODE_DIR) -c -o $@ $< # header parsing, Earth derivation, error paths.
test-de-reader: test/test_de_reader.c src/de_reader.c src/de_reader.h
$(CC) -Wall -Werror -Isrc -o test/test_de_reader $< src/de_reader.c -lm
./test/test_de_reader
.PHONY: test-de-reader
# ── Standalone OD math unit test (no PostgreSQL dependency) ──
# Element converters, inverse coordinate transforms, Brouwer/Kozai inverse.
test-od-math: test/test_od_math.c src/od_math.c src/od_math.h
$(CC) -Wall -Werror -Isrc -o test/test_od_math $< src/od_math.c -lm
./test/test_od_math
.PHONY: test-od-math
# ── Standalone IOD unit test (no PostgreSQL dependency) ──
# Gibbs method: 3-position orbit recovery, coplanarity checks.
test-od-iod: test/test_od_iod.c src/od_iod.c src/od_iod.h src/od_math.c src/od_math.h
$(CC) -Wall -Werror -Isrc -o test/test_od_iod $< src/od_iod.c src/od_math.c -lm
./test/test_od_iod
.PHONY: test-od-iod
# ── PG version test matrix ─────────────────────────────────
PG_TEST_VERSIONS ?= 14 15 16 17 18
test-matrix:
PG_VERSIONS="$(PG_TEST_VERSIONS)" bash test/pg-version-matrix.sh
test-pg%:
PG_VERSIONS="$*" bash test/pg-version-matrix.sh
test-matrix-clean:
rm -rf test/matrix-logs
@for v in $(PG_TEST_VERSIONS); do \
docker rmi "pg_orrery-test:pg$$v" 2>/dev/null || true; \
done
.PHONY: test-matrix test-matrix-clean
# ── Docker packaging ──────────────────────────────────────── # ── Docker packaging ────────────────────────────────────────
REGISTRY ?= git.supported.systems/warehack.ing REGISTRY ?= git.supported.systems/warehack.ing
IMAGE ?= pg_orbit IMAGE ?= pg_orrery
PG_MAJOR ?= 17 PG_MAJOR ?= 17
TAG ?= pg$(PG_MAJOR) TAG ?= pg$(PG_MAJOR)
@ -53,14 +104,14 @@ docker-push:
docker-test: docker-test:
@echo "Smoke-testing standalone image..." @echo "Smoke-testing standalone image..."
docker run --rm -d --name pg_orbit_test \ docker run --rm -d --name pg_orrery_test \
-e POSTGRES_PASSWORD=test $(REGISTRY)/$(IMAGE):$(TAG) -e POSTGRES_PASSWORD=test $(REGISTRY)/$(IMAGE):$(TAG)
@echo "Waiting for PostgreSQL to initialize..." @echo "Waiting for PostgreSQL to initialize..."
@sleep 10 @sleep 10
docker exec pg_orbit_test psql -U postgres -tAc \ docker exec pg_orrery_test psql -U postgres -tAc \
"SELECT tle_norad_id(E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle);" \ "SELECT tle_norad_id(E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle);" \
| grep -q 25544 | grep -q 25544
@docker stop pg_orbit_test @docker stop pg_orrery_test
@echo "Smoke test passed." @echo "Smoke test passed."
.PHONY: docker-build docker-push docker-test .PHONY: docker-build docker-push docker-test

428
README.md
View File

@ -1,305 +1,229 @@
# pg_orbit # pg_orrery
Orbital mechanics types and functions for PostgreSQL. *It's not rocket science.* (It's celestial mechanics. But now it's just SQL.)
pg_orbit adds native SQL types for TLEs, orbital positions, ground stations, and An orrery is a clockwork model of the solar system — brass gears turning planets in their courses. pg_orrery is the same idea, built from Keplerian parameters and SQL instead of wheelwork. Where a mechanical orrery approximates orbits with gear ratios, a database orrery computes them from the six orbital elements that define each trajectory.
satellite passes. It wraps Bill Gray's [sat_code](https://github.com/Bill-Gray/sat_code)
(MIT) for SGP4/SDP4 propagation, provides coordinate transforms between inertial
and ground-referenced frames, predicts passes over observer locations, and supports
GiST-indexed conjunction screening on altitude bands.
Think PostGIS, but for objects in orbit. 68 functions. 7 custom types. All `PARALLEL SAFE`. Optional JPL DE440/441 support
for sub-arcsecond accuracy; core functions have zero external dependencies at runtime.
**[Documentation](https://pg-orrery.warehack.ing)** · [Source](https://git.supported.systems/warehack.ing/pg_orrery)
## Installation ## Installation
Requirements: ### Docker (recommended)
- PostgreSQL 14+ development headers (`pg_config` in PATH)
- GCC and Make
- C++ compiler (for sat_code)
```bash ```bash
git clone --recurse-submodules https://github.com/... docker run -d --name pg_orrery \
cd pg_orbit -e POSTGRES_PASSWORD=orbit \
make -p 5499:5432 \
sudo make install git.supported.systems/warehack.ing/pg_orrery:pg17
``` ```
Then in your database: ```bash
psql -h localhost -p 5499 -U postgres -c "CREATE EXTENSION pg_orrery;"
```
### Build from Source
Requires PostgreSQL 17 development headers and a C/C++ toolchain.
```bash
git clone https://git.supported.systems/warehack.ing/pg_orrery.git
cd pg_orrery
git submodule update --init
make PG_CONFIG=/usr/bin/pg_config
sudo make install PG_CONFIG=/usr/bin/pg_config
```
```sql ```sql
CREATE EXTENSION pg_orbit; CREATE EXTENSION pg_orrery;
```
If you cloned without `--recurse-submodules`, initialize the sat_code dependency:
```bash
git submodule update --init
``` ```
## Quick Start ## Quick Start
**Where is Jupiter right now?**
```sql ```sql
-- Create a table with a TLE column SELECT topo_azimuth(t) AS az,
CREATE TABLE satellites ( topo_elevation(t) AS el,
norad_id int PRIMARY KEY, topo_range(t) / 149597870.7 AS distance_au
name text NOT NULL, FROM planet_observe(5, '40.0N 105.3W 1655m'::observer, now()) t;
tle tle NOT NULL
);
-- Insert a TLE (standard two-line format, concatenated with newline)
INSERT INTO satellites VALUES (25544, 'ISS',
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
2 25544 51.6400 208.5000 0006000 30.0000 330.0000 15.50000000400000');
-- Propagate to a point in time
SELECT sgp4_propagate(tle, now()) FROM satellites WHERE norad_id = 25544;
-- Subsatellite point (nadir)
SELECT subsatellite_point(tle, now()) FROM satellites WHERE norad_id = 25544;
-- All passes over Boulder, CO in the next 24 hours (min 10 deg elevation)
SELECT sat.name, p.*
FROM satellites sat,
LATERAL predict_passes(sat.tle, '40.0N 105.3W 1655m'::observer,
now(), now() + '24h', 10.0) p
ORDER BY pass_aos_time(p);
-- Conjunction screening with GiST index
CREATE INDEX ON satellites USING gist (tle);
SELECT a.name, b.name, tle_distance(a.tle, b.tle, now())
FROM satellites a, satellites b
WHERE a.tle && b.tle AND a.norad_id < b.norad_id
AND tle_distance(a.tle, b.tle, now()) < 10.0;
``` ```
**What's the entire solar system doing?**
```sql
SELECT body_id,
CASE body_id
WHEN 1 THEN 'Mercury' WHEN 2 THEN 'Venus'
WHEN 3 THEN 'Earth' WHEN 4 THEN 'Mars'
WHEN 5 THEN 'Jupiter' WHEN 6 THEN 'Saturn'
WHEN 7 THEN 'Uranus' WHEN 8 THEN 'Neptune'
END AS name,
round(helio_distance(planet_heliocentric(body_id, now()))::numeric, 4) AS distance_au
FROM generate_series(1, 8) AS body_id;
```
**Predict ISS passes over your location:**
```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 pass_aos(p) AS rise_time,
pass_max_el(p) AS max_elevation,
pass_los(p) AS set_time
FROM iss, predict_passes(tle, '40.0N 105.3W 1655m'::observer,
now(), now() + interval '24 hours', 10.0) p;
```
**When will Jupiter produce radio bursts tonight?**
```sql
SELECT t,
round(jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
)::numeric, 3) AS burst_prob
FROM generate_series(now(), now() + interval '12 hours', interval '10 minutes') AS t
WHERE jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
) > 0.3;
```
**Plan an Earth-Mars transfer:**
```sql
SELECT round(c3_departure::numeric, 2) AS c3_depart_km2s2,
round(tof_days::numeric, 1) AS flight_days,
round(transfer_sma::numeric, 4) AS sma_au
FROM lambert_transfer(3, 4, '2028-10-01'::timestamptz, '2029-06-15'::timestamptz);
```
## What It Covers
| Domain | Theory | Key Functions | Accuracy |
|---|---|---|---|
| Satellites | SGP4/SDP4 (Brouwer 1959) | `observe()`, `predict_passes()` | ~1 km (LEO, fresh TLE) |
| Planets | VSOP87 (Bretagnon 1988) | `planet_observe()`, `planet_heliocentric()` | ~1 arcsecond |
| Sun | VSOP87 (Earth vector, inverted) | `sun_observe()` | ~1 arcsecond |
| 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 |
| Jupiter radio | Carr et al. (1983) sources | `jupiter_burst_probability()` | Empirical probability |
| Transfers | Lambert (Izzo 2015) | `lambert_transfer()`, `lambert_c3()` | Ballistic two-body |
## Types ## Types
| Type | Size | Description | | Type | Bytes | Description |
|------|------|-------------| |------|-------|-------------|
| `tle` | 112 bytes | Parsed mean orbital elements (epoch, Keplerian elements, drag terms, identifiers). Stored as a fixed-size struct, not raw text. | | `tle` | 112 | Parsed mean orbital elements for SGP4/SDP4 propagation |
| `eci_position` | 48 bytes | Position (km) and velocity (km/s) in the True Equator Mean Equinox (TEME) frame. | | `eci_position` | 48 | Position and velocity in TEME frame (km, km/s) |
| `geodetic` | 24 bytes | Latitude/longitude (degrees) and altitude (km) on the WGS-84 ellipsoid. | | `geodetic` | 24 | Latitude, longitude, altitude on WGS-84 ellipsoid |
| `topocentric` | 32 bytes | Azimuth, elevation (degrees), slant range (km), and range rate (km/s) relative to an observer. | | `topocentric` | 32 | Azimuth, elevation, range, range rate relative to observer |
| `observer` | 24 bytes | Ground station location. Accepts human-readable input: `'40.0N 105.3W 1655m'` or decimal degrees. | | `observer` | 24 | Ground location. Input: `'40.0N 105.3W 1655m'` or decimal degrees |
| `pass_event` | 48 bytes | Satellite pass with AOS/MAX/LOS times, max elevation, and AOS/LOS azimuths. | | `pass_event` | 48 | Satellite pass with AOS/TCA/LOS times and azimuths |
| `heliocentric` | 24 | Position in AU, ecliptic J2000 frame |
### Input Formats All types are fixed-size with `STORAGE = plain`. No TOAST overhead.
**tle** -- Standard two-line format (lines joined by newline): ## Body IDs
```sql Planets follow the VSOP87 convention. Planetary moons use per-family indexing.
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
2 25544 51.6400 208.5000 0006000 30.0000 330.0000 15.50000000400000'::tle;
```
**observer** -- Flexible ground station input: | ID | Planet | | Galilean (0-3) | Saturn (0-7) | Uranus (0-4) | Mars (0-1) |
|----|--------|-|----------------|--------------|--------------|------------|
```sql | 1 | Mercury | | 0: Io | 0: Mimas | 0: Miranda | 0: Phobos |
-- Compass notation with altitude | 2 | Venus | | 1: Europa | 1: Enceladus | 1: Ariel | 1: Deimos |
SELECT '40.0N 105.3W 1655m'::observer; | 3 | Earth | | 2: Ganymede | 2: Tethys | 2: Umbriel | |
| 4 | Mars | | 3: Callisto | 3: Dione | 3: Titania | |
-- Decimal degrees (positive East, altitude in meters) | 5 | Jupiter | | | 4: Rhea | 4: Oberon | |
SELECT '40.0 -105.3 1655'::observer; | 6 | Saturn | | | 5: Titan | | |
``` | 7 | Uranus | | | 6: Iapetus | | |
| 8 | Neptune | | | 7: Hyperion | | |
## Functions
### TLE Accessors
| Function | Returns | Description |
|----------|---------|-------------|
| `tle_norad_id(tle)` | `int4` | NORAD catalog number |
| `tle_epoch(tle)` | `float8` | Epoch as Julian date (UTC) |
| `tle_inclination(tle)` | `float8` | Inclination in degrees |
| `tle_eccentricity(tle)` | `float8` | Eccentricity (dimensionless) |
| `tle_raan(tle)` | `float8` | Right ascension of ascending node (degrees) |
| `tle_arg_perigee(tle)` | `float8` | Argument of perigee (degrees) |
| `tle_mean_anomaly(tle)` | `float8` | Mean anomaly (degrees) |
| `tle_mean_motion(tle)` | `float8` | Mean motion (rev/day) |
| `tle_bstar(tle)` | `float8` | B* drag coefficient (1/earth-radii) |
| `tle_period(tle)` | `float8` | Orbital period (minutes) |
| `tle_perigee(tle)` | `float8` | Perigee altitude (km above WGS-72 ellipsoid) |
| `tle_apogee(tle)` | `float8` | Apogee altitude (km above WGS-72 ellipsoid) |
| `tle_age(tle, timestamptz)` | `float8` | TLE age in days (positive = past epoch) |
| `tle_intl_desig(tle)` | `text` | International designator (COSPAR ID) |
### Propagation
| Function | Returns | Description |
|----------|---------|-------------|
| `sgp4_propagate(tle, timestamptz)` | `eci_position` | Propagate to a point in time. Uses SGP4 for near-earth, SDP4 for deep-space. |
| `sgp4_propagate_series(tle, start, stop, step)` | `SETOF (t, x, y, z, vx, vy, vz)` | Time series of TEME positions at regular intervals. |
| `tle_distance(tle, tle, timestamptz)` | `float8` | Euclidean distance (km) between two objects at a reference time. |
### Coordinate Transforms
| Function | Returns | Description |
|----------|---------|-------------|
| `eci_to_geodetic(eci_position, timestamptz)` | `geodetic` | TEME to WGS-84 geodetic (lat/lon/alt). Requires time for Earth rotation. |
| `eci_to_topocentric(eci_position, observer, timestamptz)` | `topocentric` | TEME to observer-relative az/el/range. |
| `subsatellite_point(tle, timestamptz)` | `geodetic` | Nadir point on WGS-84 ellipsoid. Propagates internally. |
| `ground_track(tle, start, stop, step)` | `SETOF (t, lat, lon, alt)` | Ground track as time series of subsatellite points. |
### ECI Accessors
| Function | Returns | Description |
|----------|---------|-------------|
| `eci_x(eci_position)` | `float8` | X position (km, TEME) |
| `eci_y(eci_position)` | `float8` | Y position (km, TEME) |
| `eci_z(eci_position)` | `float8` | Z position (km, TEME) |
| `eci_vx(eci_position)` | `float8` | X velocity (km/s) |
| `eci_vy(eci_position)` | `float8` | Y velocity (km/s) |
| `eci_vz(eci_position)` | `float8` | Z velocity (km/s) |
| `eci_speed(eci_position)` | `float8` | Velocity magnitude (km/s) |
| `eci_altitude(eci_position)` | `float8` | Geocentric altitude (km) |
### Topocentric Accessors
| Function | Returns | Description |
|----------|---------|-------------|
| `topo_azimuth(topocentric)` | `float8` | Azimuth in degrees (0=N, 90=E, 180=S, 270=W) |
| `topo_elevation(topocentric)` | `float8` | Elevation in degrees (0=horizon, 90=zenith) |
| `topo_range(topocentric)` | `float8` | Slant range (km) |
| `topo_range_rate(topocentric)` | `float8` | Range rate (km/s, positive = receding) |
### Pass Prediction
| Function | Returns | Description |
|----------|---------|-------------|
| `next_pass(tle, observer, timestamptz)` | `pass_event` | Next pass over observer. Searches up to 7 days. |
| `predict_passes(tle, observer, start, stop [, min_el])` | `SETOF pass_event` | All passes in a time window. Optional minimum elevation (degrees). |
| `pass_visible(tle, observer, start, stop)` | `boolean` | True if any pass occurs in the time window. |
### Pass Event Accessors
| Function | Returns | Description |
|----------|---------|-------------|
| `pass_aos_time(pass_event)` | `timestamptz` | Acquisition of signal time |
| `pass_max_el_time(pass_event)` | `timestamptz` | Maximum elevation time |
| `pass_los_time(pass_event)` | `timestamptz` | Loss of signal time |
| `pass_max_elevation(pass_event)` | `float8` | Maximum elevation (degrees) |
| `pass_aos_azimuth(pass_event)` | `float8` | AOS azimuth (degrees, 0=N) |
| `pass_los_azimuth(pass_event)` | `float8` | LOS azimuth (degrees, 0=N) |
| `pass_duration(pass_event)` | `interval` | Pass duration (LOS - AOS) |
### Operators
| Operator | Operands | Description |
|----------|----------|-------------|
| `&&` | `tle, tle` | Altitude band overlap. Necessary (not sufficient) condition for conjunction. |
| `<->` | `tle, tle` | Minimum altitude-band separation in km. Returns 0 if bands overlap. |
Both operators are supported by the GiST `tle_ops` operator class for indexed scans.
## GiST Indexing ## GiST Indexing
The `tle_ops` operator class indexes TLEs by their altitude band (perigee to apogee). The `tle_ops` operator class indexes TLEs by altitude band for conjunction screening:
This provides fast filtering for conjunction screening: only pairs whose altitude
bands overlap can possibly be close to each other.
```sql ```sql
-- Create the index CREATE INDEX ON satellites USING gist (tle);
CREATE INDEX idx_tle_alt ON satellites USING gist (tle);
-- The && operator triggers index scans -- Find objects in overlapping altitude shells
EXPLAIN SELECT a.name, b.name SELECT a.name, b.name
FROM satellites a, satellites b FROM satellites a, satellites b
WHERE a.tle && b.tle AND a.norad_id < b.norad_id; WHERE a.tle && b.tle AND a.norad_id < b.norad_id;
-- KNN ordering by altitude-band distance -- K-nearest-neighbor by altitude separation
SELECT name, tle <-> (SELECT tle FROM satellites WHERE norad_id = 25544) AS sep SELECT name, round((tle <-> iss.tle)::numeric, 0) AS alt_sep_km
FROM satellites FROM satellites, (SELECT tle FROM satellites WHERE norad_id = 25544) iss
ORDER BY tle <-> (SELECT tle FROM satellites WHERE norad_id = 25544) ORDER BY tle <-> iss.tle
LIMIT 20; LIMIT 20;
``` ```
The index reduces conjunction candidate pairs from O(n^2) to the set of objects with ## Performance
intersecting altitude bands, which is then refined by computing actual `tle_distance()`
at a specific time.
## Geodetic Constants Measured on PostgreSQL 17, single backend:
TLEs are mean elements fitted using WGS-72 constants. Using WGS-84 constants for | Operation | Count | Time | Rate |
propagation introduces kilometer-scale position errors because the elements absorb |---|---|---|---|
geodetic model biases during the fitting process. | TLE propagation (SGP4) | 12,000 | 17ms | 706K/sec |
| Planet observation (VSOP87) | 875 | 57ms | 15.4K/sec |
pg_orbit enforces this: | Moon observation (Galilean) | 1,000 | 63ms | 15.9K/sec |
- **Propagation (SGP4/SDP4):** WGS-72 constants only (mu, ae, J2, J3, J4, ke) | Star observation | 500 | 0.7ms | 714K/sec |
- **Coordinate output (geodetic, topocentric):** WGS-84 (a=6378.137 km, f=1/298.257223563) | Lambert transfer solve | 100 | 0.1ms | 800K/sec |
| Pork chop plot (150x150) | 22,500 | 8.3s | 2.7K/sec |
These are not interchangeable. Mixing them is a silent accuracy loss.
## Building from Source
```bash
# Build (requires pg_config in PATH)
make
# Install to PostgreSQL extension directory
sudo make install
# Run regression tests against a live database
make installcheck
```
Override `pg_config` location if needed:
```bash
make PG_CONFIG=/usr/lib/postgresql/16/bin/pg_config
sudo make PG_CONFIG=/usr/lib/postgresql/16/bin/pg_config install
```
### Project Layout
```
pg_orbit.control Extension metadata (version 0.1.0)
Makefile PGXS build
sql/
pg_orbit--0.1.0.sql Type, function, operator, and GiST definitions
src/
pg_orbit.c PG_MODULE_MAGIC entry point
tle_type.c TLE input/output/binary/accessors
eci_type.c ECI position type
observer_type.c Observer type with flexible parsing
sgp4_funcs.c SGP4 propagation and distance
coord_funcs.c Coordinate transforms (TEME/geodetic/topocentric)
pass_funcs.c Pass prediction (next_pass, predict_passes)
gist_tle.c GiST operator class for altitude-band indexing
types.h Shared struct definitions and constants
lib/
sat_code/ Bill Gray's SGP4/SDP4 (MIT, git submodule)
test/
sql/ Regression test SQL
expected/ Expected output
data/
vallado_518.csv 518 verification test vectors (Vallado et al.)
```
## Testing ## Testing
pg_orbit uses the standard PostgreSQL regression test framework. 12 regression test suites covering all domains:
```bash ```bash
make installcheck make installcheck PG_CONFIG=/usr/bin/pg_config
``` ```
Test categories: Tests: TLE parsing, SGP4/SDP4 propagation, coordinate transforms, pass prediction,
GiST indexing, convenience functions, star observation, Keplerian propagation,
planet observation, moon observation, Lambert transfers, and DE ephemeris.
| Suite | Coverage | ## Documentation
|-------|----------|
| `tle_parse` | TLE input/output round-trip, malformed input rejection |
| `sgp4_propagate` | Vallado 518 test vectors, deep-space and high-eccentricity edge cases |
| `coord_transforms` | TEME to geodetic, TEME to topocentric accuracy |
| `pass_prediction` | Known ISS passes, polar and retrograde orbits |
| `gist_index` | Index scan vs. sequential scan result equivalence |
The Vallado 518 test vectors are the standard SGP4 verification dataset. Each row Full documentation at the [pg_orrery docs site](https://pg-orrery.warehack.ing),
contains a NORAD ID, minutes since epoch, and expected position/velocity. All 518 built with [Starlight](https://starlight.astro.build). Includes guides, workflow
must pass to machine epsilon. translations (from Skyfield, JPL Horizons, GMAT, Radio Jupiter Pro), complete
function reference, architecture notes, and benchmarks.
## What pg_orrery Is Not
**Not a GUI.** Use Stellarium, GPredict, or STK for visualization.
**Not sub-arcsecond.** VSOP87 gives ~1 arcsecond — good for observation planning,
not for dish pointing at GHz frequencies. For that, use SPICE or Skyfield with DE441.
**Not a TLE source.** Bring your own from Space-Track, CelesTrak, or any provider.
**Not a replacement for SPICE.** No BSP kernels, no aberration corrections at IAU 2000A
level. pg_orrery trades those last few milliarcseconds for SQL-speed computation joined
with your existing data.
**Not a full mission design tool.** The Lambert solver handles ballistic two-body
transfers. For low-thrust, gravity assists, or multi-body optimization, use GMAT.
## Upgrading from v0.1.0
```sql
ALTER EXTENSION pg_orrery UPDATE TO '0.2.0';
```
Adds all solar system functions while preserving existing TLE data and satellite functions.
## License ## License
[PostgreSQL License](LICENSE). Copyright (c) 2025, Ryan Malloy. [PostgreSQL License](LICENSE). Copyright (c) 2025, Ryan Malloy.
The bundled sat_code library is separately licensed under the MIT license. The bundled [sat_code](https://github.com/Bill-Gray/sat_code) library is separately
licensed under the MIT license.

7
TODO Normal file
View File

@ -0,0 +1,7 @@
- Gauss method for angles-only initial orbit determination
(eliminates seed requirement for sensors without ranging)
- Weighted observations (per-obs covariance weighting for
heterogeneous sensor fusion)
- Range rate fitting in topocentric mode (currently reserved
via vel_ecef in residual computation)

View File

@ -1,10 +1,10 @@
#!/bin/bash #!/bin/bash
set -e set -e
# Create the pg_orbit extension on first container startup. # Create the pg_orrery extension on first container startup.
# The 020_ prefix orders this after TimescaleDB's own init scripts # The 020_ prefix orders this after TimescaleDB's own init scripts
# (000_, 001_, 010_) when used in timescaledb-ha images. # (000_, 001_, 010_) when used in timescaledb-ha images.
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "${POSTGRES_DB:-postgres}" <<-'EOSQL' psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "${POSTGRES_DB:-postgres}" <<-'EOSQL'
CREATE EXTENSION IF NOT EXISTS pg_orbit; CREATE EXTENSION IF NOT EXISTS pg_orrery;
EOSQL EOSQL

View File

@ -1,8 +1,8 @@
# pg_orbit Design Document # pg_orrery Design Document
Internal architecture notes. Documents WHY decisions were made, Internal architecture notes. Documents WHY decisions were made,
not how to use the extension. Intended audience: future maintainers not how to use the extension. Intended audience: future maintainers
who need to modify pg_orbit without breaking physical correctness. who need to modify pg_orrery without breaking physical correctness.
## 1. Constant Chain of Custody ## 1. Constant Chain of Custody
@ -49,20 +49,20 @@ prediction error of the TLE by an order of magnitude.
### Constant Inventory ### Constant Inventory
| Constant | Source Paper | Value | pg_orbit Location | sat_code Location | | Constant | Source Paper | Value | pg_orrery Location | Vendored SGP4 Location |
|----------|-------------|-------|-------------------|-------------------| |----------|-------------|-------|-------------------|------------------------|
| ae (equatorial radius) | Hoots & Roehrich STR#3 | 6378.135 km | `types.h:20` (WGS72_AE) | `norad_in.h:64` (earth_radius_in_km) | | ae (equatorial radius) | Hoots & Roehrich STR#3 | 6378.135 km | `types.h` (WGS72_AE) | `src/sgp4/norad_in.h` (earth_radius_in_km) |
| J2 | Hoots & Roehrich STR#3 | 0.001082616 | `types.h:21` (WGS72_J2) | `norad_in.h:69` (xj2) | | J2 | Hoots & Roehrich STR#3 | 0.001082616 | `types.h` (WGS72_J2) | `src/sgp4/norad_in.h` (xj2) |
| J3 | Hoots & Roehrich STR#3 | -2.53881e-6 | `types.h:22` (WGS72_J3) | `norad_in.h:63` (xj3) | | J3 | Hoots & Roehrich STR#3 | -2.53881e-6 | `types.h` (WGS72_J3) | `src/sgp4/norad_in.h` (xj3) |
| J4 | Hoots & Roehrich STR#3 | -1.65597e-6 | `types.h:23` (WGS72_J4) | `norad_in.h:79` (xj4) | | J4 | Hoots & Roehrich STR#3 | -1.65597e-6 | `types.h` (WGS72_J4) | `src/sgp4/norad_in.h` (xj4) |
| ke | Hoots & Roehrich STR#3 | 0.0743669161331734132 min^-1 | `types.h:24` (WGS72_KE) | `norad_in.h:83` (xke) | | ke | Hoots & Roehrich STR#3 | 0.0743669161331734132 min^-1 | `types.h` (WGS72_KE) | `src/sgp4/norad_in.h` (xke) |
| mu | Hoots & Roehrich STR#3 | 398600.8 km^3/s^2 | `types.h:19` (WGS72_MU) | (implicit in xke) | | mu | Hoots & Roehrich STR#3 | 398600.8 km^3/s^2 | `types.h` (WGS72_MU) | (implicit in xke) |
| WGS-84 a | NIMA TR8350.2 | 6378.137 km | `types.h:31` (WGS84_A) | -- | | WGS-84 a | NIMA TR8350.2 | 6378.137 km | `types.h:31` (WGS84_A) | -- |
| WGS-84 f | NIMA TR8350.2 | 1/298.257223563 | `types.h:32` (WGS84_F) | -- | | WGS-84 f | NIMA TR8350.2 | 1/298.257223563 | `types.h:32` (WGS84_F) | -- |
Note that `types.h` carries a parallel copy of the WGS-72 constants Note that `types.h` carries a parallel copy of the WGS-72 constants
even though sat_code defines them in `norad_in.h`. This is intentional: even though sat_code defines them in `norad_in.h`. This is intentional:
`types.h` is the single header for all pg_orbit C sources, and `types.h` is the single header for all pg_orrery C sources, and
`norad_in.h` is an internal sat_code header not meant for external `norad_in.h` is an internal sat_code header not meant for external
consumers. The GiST index (`gist_tle.c`) and TLE accessor functions consumers. The GiST index (`gist_tle.c`) and TLE accessor functions
(`tle_type.c`) need KE and AE without pulling in sat_code internals. (`tle_type.c`) need KE and AE without pulling in sat_code internals.
@ -84,7 +84,7 @@ but wrong in principle, and the error compounds in index operations.
## 2. SGP4 Implementation Choice ## 2. SGP4 Implementation Choice
pg_orbit wraps Bill Gray's `sat_code` library (MIT license, Project Pluto). pg_orrery wraps Bill Gray's `sat_code` library (MIT license, Project Pluto).
### Why sat_code over alternatives ### Why sat_code over alternatives
@ -112,38 +112,37 @@ across function invocations.
3. **Includes deep-space SDP4.** Many SGP4 implementations only handle 3. **Includes deep-space SDP4.** Many SGP4 implementations only handle
near-earth orbits (period < 225 minutes). sat_code includes the full near-earth orbits (period < 225 minutes). sat_code includes the full
SDP4 with lunar/solar perturbations via `deep.cpp`, handling GEO, SDP4 with lunar/solar perturbations via `deep.c`, handling GEO,
Molniya, and GPS orbits. Molniya, and GPS orbits.
4. **MIT license.** Compatible with the PostgreSQL License for embedding 4. **MIT license.** Compatible with the PostgreSQL License for embedding
in a shared library. in a shared library.
5. **Actively maintained.** Used in Bill Gray's Find_Orb production 5. **Actively maintained.** Used in Bill Gray's Find_Orb production
astrometry software. Bug fixes reach us through the git submodule. astrometry software.
### Build Integration ### Build Integration
The Makefile compiles sat_code's `.cpp` files with `g++` and links the The SGP4/SDP4 source is vendored into `src/sgp4/` — the `.cpp` files
resulting `.o` files into the PostgreSQL shared library alongside our C renamed to `.c` (the code is valid C99 with zero C++ features). The
sources. The `-lstdc++` link flag pulls in the C++ runtime. This is the Makefile compiles everything with `gcc` and links with `-lm` only. No
same pattern used by PostGIS for GEOS integration (C extension linking C++ compiler or runtime is required.
C++ library objects).
``` ```
src/*.c --> gcc --> .o --| src/*.c --[gcc]--> .o --|
lib/sat_code/*.cpp -> g++ -> .o --|--> pg_orbit.so src/sgp4/*.c --[gcc]--> .o --|--> pg_orrery.so
-lstdc++ -lm -lm
``` ```
The `-I$(SAT_CODE_DIR)` flag lets our C files `#include "norad.h"` The `-I$(SGP4_DIR)` flag lets our C files `#include "norad.h"` directly.
directly. Provenance is recorded in `src/sgp4/PROVENANCE.md`.
## 3. Type System Design ## 3. Type System Design
### Design Principles ### Design Principles
Every pg_orbit type is fixed-size, not varlena. This means: Every pg_orrery type is fixed-size, not varlena. This means:
- No TOAST overhead (no detoasting on access) - No TOAST overhead (no detoasting on access)
- Direct pointer access via `PG_GETARG_POINTER(n)` -- no copy - Direct pointer access via `PG_GETARG_POINTER(n)` -- no copy
@ -203,7 +202,7 @@ Six doubles: x, y, z (km), vx, vy, vz (km/s).
SGP4 outputs velocity in km/min. We convert to km/s at the boundary SGP4 outputs velocity in km/min. We convert to km/s at the boundary
(`sgp4_funcs.c`, lines 181-183: `vel[i] / 60.0`). This conversion (`sgp4_funcs.c`, lines 181-183: `vel[i] / 60.0`). This conversion
happens exactly once, at the point where the pg_eci struct is populated. happens exactly once, at the point where the pg_eci struct is populated.
Internally, all velocity in pg_orbit is km/s. Internally, all velocity in pg_orrery is km/s.
### Geodetic Type (24 bytes) ### Geodetic Type (24 bytes)
@ -484,11 +483,16 @@ no `new`, no static buffers.
### No Global Mutable State ### No Global Mutable State
There are no file-scope variables, no static locals that accumulate For v0.1.0/v0.2.0 functions, there are no file-scope variables, no
state, no caches. Every function computes from its arguments alone. static locals that accumulate state, no caches. Every function computes
This is required for `PARALLEL SAFE` (all pg_orbit functions are from its arguments alone.
declared PARALLEL SAFE) and avoids cross-session contamination in
a multi-backend PostgreSQL server. The v0.3.0 DE ephemeris layer introduces per-backend static state in
`eph_provider.c` (file descriptor, coefficient cache, init flags). This
is safe because each backend gets its own copy after fork(). The handle
is cleaned up via `on_proc_exit()`. All pg_orrery functions remain
`PARALLEL SAFE` -- parallel workers each open their own DE handle
independently.
sat_code itself has no global mutable state. The propagator state is sat_code itself has no global mutable state. The propagator state is
entirely in the `params[]` array and the `tle_t` struct, both of which entirely in the `params[]` array and the `tle_t` struct, both of which
@ -521,7 +525,7 @@ the per-row propagation loop.
sat_code returns integer error codes from SGP4() and SDP4(): sat_code returns integer error codes from SGP4() and SDP4():
| Code | Constant | Severity | Meaning | pg_orbit Response | | Code | Constant | Severity | Meaning | pg_orrery Response |
|------|----------|----------|---------|-------------------| |------|----------|----------|---------|-------------------|
| 0 | -- | OK | Normal | Return result | | 0 | -- | OK | Normal | Return result |
| -1 | SXPX_ERR_NEARLY_PARABOLIC | Fatal | eccentricity >= 1 | `ereport(ERROR)` | | -1 | SXPX_ERR_NEARLY_PARABOLIC | Fatal | eccentricity >= 1 | `ereport(ERROR)` |
@ -568,18 +572,18 @@ initialize the propagator.
## 9. Theory-to-Code Mapping ## 9. Theory-to-Code Mapping
This table maps key equations from the SGP4 theory papers to their This table maps key equations from the SGP4 theory papers to their
implementation in pg_orbit and sat_code. implementation in pg_orrery and the vendored SGP4 code.
| Theory | Paper | What | Code Location | | Theory | Paper | What | Code Location |
|--------|-------|------|---------------| |--------|-------|------|---------------|
| Mean element recovery | Brouwer (1959) | Recover original mean motion (xnodp) and semi-major axis (aodp) from input TLE elements, removing secular J2 perturbations | `sat_code/common.cpp:sxpall_common_init()` lines 17-35 | | Mean element recovery | Brouwer (1959) | Recover original mean motion (xnodp) and semi-major axis (aodp) from input TLE elements, removing secular J2 perturbations | `src/sgp4/common.c:sxpall_common_init()` lines 17-35 |
| Secular perturbations | Lane & Cranford (1969), Hoots & Roehrich STR#3 | Secular rates of mean anomaly, argument of perigee, and RAAN due to J2, J4 | `sat_code/common.cpp:sxpx_common_init()` lines 86-101 | | Secular perturbations | Lane & Cranford (1969), Hoots & Roehrich STR#3 | Secular rates of mean anomaly, argument of perigee, and RAAN due to J2, J4 | `src/sgp4/common.c:sxpx_common_init()` lines 86-101 |
| Atmospheric drag | Hoots & Roehrich STR#3 | B* formulation of drag, C1/C2/C4 coefficients, perigee-dependent s parameter | `sat_code/common.cpp:sxpx_common_init()` lines 47-84; `sat_code/sgp4.cpp:SGP4_init()` | | Atmospheric drag | Hoots & Roehrich STR#3 | B* formulation of drag, C1/C2/C4 coefficients, perigee-dependent s parameter | `src/sgp4/common.c:sxpx_common_init()` lines 47-84; `src/sgp4/sgp4.c:SGP4_init()` |
| Short-period perturbations | Lane & Cranford (1969), Brouwer (1959) | Oscillatory corrections to radius, argument of latitude, node, and inclination | `sat_code/common.cpp:sxpx_posn_vel()` lines 121-229 | | Short-period perturbations | Lane & Cranford (1969), Brouwer (1959) | Oscillatory corrections to radius, argument of latitude, node, and inclination | `src/sgp4/common.c:sxpx_posn_vel()` lines 121-229 |
| Kepler equation | Classical | Newton-Raphson with second-order correction, bounded first step | `sat_code/common.cpp:sxpx_posn_vel()` lines 175-208 | | Kepler equation | Classical | Newton-Raphson with second-order correction, bounded first step | `src/sgp4/common.c:sxpx_posn_vel()` lines 175-208 |
| Deep-space resonance | Hujsak (1979) | Lunar and solar gravitational perturbations, geopotential resonance for 12-hour and 24-hour orbits | `sat_code/deep.cpp:Deep_dpinit()`, `Deep_dpsec()`, `Deep_dpper()` | | Deep-space resonance | Hujsak (1979) | Lunar and solar gravitational perturbations, geopotential resonance for 12-hour and 24-hour orbits | `src/sgp4/deep.c:Deep_dpinit()`, `Deep_dpsec()`, `Deep_dpper()` |
| Near-earth propagation | Hoots & Roehrich STR#3 | SGP4 main loop: secular + short-period + drag terms | `sat_code/sgp4.cpp:SGP4()` | | Near-earth propagation | Hoots & Roehrich STR#3 | SGP4 main loop: secular + short-period + drag terms | `src/sgp4/sgp4.c:SGP4()` |
| Deep-space propagation | Hoots & Roehrich STR#3 | SDP4: SGP4 core + deep-space secular/periodic corrections | `sat_code/sdp4.cpp:SDP4()` | | Deep-space propagation | Hoots & Roehrich STR#3 | SDP4: SGP4 core + deep-space secular/periodic corrections | `src/sgp4/sdp4.c:SDP4()` |
| Semi-major axis from n | Kepler's third law | a = (KE / n)^(2/3) in earth radii | `src/tle_type.c:tle_perigee()` line 415; `src/gist_tle.c:tle_to_alt_range()` line 76 | | Semi-major axis from n | Kepler's third law | a = (KE / n)^(2/3) in earth radii | `src/tle_type.c:tle_perigee()` line 415; `src/gist_tle.c:tle_to_alt_range()` line 76 |
| GMST | Vallado (2013) Eq. 3-47 | Greenwich Mean Sidereal Time from Julian date | `src/coord_funcs.c:gmst_from_jd()` lines 59-73; `src/pass_funcs.c:gmst_from_jd()` lines 129-151 | | GMST | Vallado (2013) Eq. 3-47 | Greenwich Mean Sidereal Time from Julian date | `src/coord_funcs.c:gmst_from_jd()` lines 59-73; `src/pass_funcs.c:gmst_from_jd()` lines 129-151 |
| TEME to ECEF | Vallado (2013) | Z-axis rotation by -GMST, velocity cross-product correction | `src/coord_funcs.c:teme_to_ecef()` lines 83-103; `src/pass_funcs.c:teme_to_ecef()` lines 157-179 | | TEME to ECEF | Vallado (2013) | Z-axis rotation by -GMST, velocity cross-product correction | `src/coord_funcs.c:teme_to_ecef()` lines 83-103; `src/pass_funcs.c:teme_to_ecef()` lines 157-179 |
@ -587,4 +591,157 @@ implementation in pg_orbit and sat_code.
| Topocentric transform | Standard SEZ | ECEF range vector rotated to South-East-Zenith, azimuth from north | `src/coord_funcs.c:ecef_to_topocentric()` lines 163-188 | | Topocentric transform | Standard SEZ | ECEF range vector rotated to South-East-Zenith, azimuth from north | `src/coord_funcs.c:ecef_to_topocentric()` lines 163-188 |
| Observer to ECEF | Geodesy standard | WGS-84 ellipsoid surface point to Cartesian | `src/coord_funcs.c:observer_to_ecef()` lines 143-156 | | Observer to ECEF | Geodesy standard | WGS-84 ellipsoid surface point to Cartesian | `src/coord_funcs.c:observer_to_ecef()` lines 143-156 |
| Range rate | Dot product | Projection of relative velocity onto line-of-sight unit vector | `src/coord_funcs.c:eci_to_topocentric()` line 618 | | Range rate | Dot product | Projection of relative velocity onto line-of-sight unit vector | `src/coord_funcs.c:eci_to_topocentric()` line 618 |
| Near/deep selection | Hoots & Roehrich STR#3 | Period threshold: 225 minutes (n < 2*pi/225 rad/min) | `sat_code/norad.h:select_ephemeris()` | | Near/deep selection | Hoots & Roehrich STR#3 | Period threshold: 225 minutes (n < 2*pi/225 rad/min) | `src/sgp4/norad.h:select_ephemeris()` |
## 10. JPL DE Ephemeris Architecture (v0.3.0)
v0.3.0 adds optional JPL DE440/441 ephemeris support (~0.1 milliarcsecond
accuracy) without modifying any existing VSOP87/ELP82B code path. This
section documents the architectural decisions specific to DE integration.
### The Fundamental Tension
pg_orrery's core properties (compiled-in coefficients, no file I/O, no
mutable state) are precisely what DE441 challenges. A ~3GB binary file
introduces file dependency, per-backend state (file descriptor,
coefficient cache), and OS-level file descriptor management across
PostgreSQL's multi-process model.
The architecture resolves this by treating DE as an **additive layer**:
new `_de()` function variants alongside existing functions. Existing
functions are untouched. VSOP87 is the bulkhead -- always compiled in,
always works, always IMMUTABLE.
### Why Separate Functions (Not a GUC Switch)
Considered and rejected: a single `planet_observe()` that dispatches to
DE or VSOP87 based on a GUC setting.
The problem is volatility. VSOP87 functions are IMMUTABLE -- their data
is compiled into the binary. PostgreSQL can:
- Cache results in expression indexes (almanac tables, pork chop grids)
- Constant-fold during planning (bake results into prepared statements)
If `planet_observe()` dispatched to DE based on a GUC, it would need to
be STABLE, losing these optimizations for ALL users, even those without
a DE file. Separate `_de()` variants let VSOP87 users keep IMMUTABLE
and DE users get STABLE -- honest volatility for both.
### Clean-Room DE Reader Design
The JPL DE binary format (used by DE405, DE430, DE440, DE441) consists
of fixed-size records of `double` values:
- **Record 1 (header):** Start/end JD, record interval, number of
coefficients, AU value, Earth-Moon mass ratio (EMRAT), coefficient
layout table (3 values per body: offset, ncoeff, nsub)
- **Record 2:** Constant values
- **Records 3+:** Chebyshev polynomial coefficients covering a time
interval for all 13 body groups
The reader is implemented in ~250 lines of C (`de_reader.c`), using:
- Raw POSIX I/O (`open`/`lseek`/`read`, not `fread`) to avoid libc
buffering issues across fork
- Clenshaw recurrence for Chebyshev evaluation (~15 lines)
- Single-record coefficient cache (most queries hit consecutive times)
#### Why Not jpl_eph
Bill Gray's `jpl_eph` (GPL-2+) would be the obvious choice, but:
1. GPL-2+ license constrains pg_orrery's licensing flexibility
2. Uses global statics (`static int init_err_code`)
3. Written in C++ (`jpleph.cpp`); pg_orrery is pure C
4. pg_orrery only needs position queries, not velocity or nutation
The format is well-documented and the algorithm is straightforward.
A clean-room implementation in ~250 lines avoids all four issues.
### Per-Backend Lazy Initialization
PostgreSQL parallel workers are forked from the **postmaster, not the
leader**. They don't inherit the leader's file descriptors or initialized
handles.
Each backend (including parallel workers) gets its own independently-
opened handle via lazy init on first `_de()` call:
```
Backend A calls planet_observe_de() -> opens own fd, own cache
Worker W1 calls planet_observe_de() -> opens own fd, own cache
Worker W2 calls planet_observe_de() -> opens own fd, own cache
```
Static per-backend variables (`de_handle_ptr`, `de_init_attempted`) are
safe because after `fork()`, each process gets its own copy-on-write
address space.
**Critical rule:** Never open the DE file in `_PG_init()`. That runs in
the postmaster, and the file descriptor would be inherited by all children
with undefined stream behavior. Always lazy init.
Cleanup is via `on_proc_exit(eph_cleanup, 0)`, registered in `_PG_init()`.
### ICRS-to-Ecliptic Frame Rotation
DE ephemerides return positions in the ICRS equatorial frame. The
pg_orrery observation pipeline expects ecliptic J2000. The conversion
happens at the provider boundary in `eph_provider.c`:
```
DE position (ICRS equatorial) -> equatorial_to_ecliptic() -> ecliptic J2000
^
already in astro_math.h since v0.2.0
```
This rotation is applied to BOTH the target body and Earth positions
before they enter `observe_from_geocentric()`. The frame conversion
happens exactly once, at the provider boundary.
### Earth Position from DE
DE ephemerides don't store Earth directly. They store the Earth-Moon
Barycenter (EMB, body 3) and the Moon (body 10). Earth's position is
derived:
```
Earth = EMB - Moon / (1 + EMRAT)
```
where EMRAT (~81.300587) is the Earth-Moon mass ratio from the DE
header. This calculation happens in `eph_provider.c:de_get_earth_helio_ecliptic()`.
### Constant Chain of Custody Extension
Three new rules for the DE pipeline:
| Rule | What | Why |
|------|------|-----|
| 6 | DE positions pass through `equatorial_to_ecliptic()` at the provider boundary | DE returns ICRS equatorial; the observation pipeline expects ecliptic J2000 |
| 7 | Both target and Earth must come from the same provider | Mixing DE Mars with VSOP87 Earth introduces frame inconsistency |
| 8 | DE header AU must match compiled-in `AU_KM` (149597870.7) | AU defines the length scale; a mismatch corrupts all distance calculations |
### Fallback Strategy
Every `_de()` function follows this pattern:
1. Try to get position from DE
2. If DE succeeds, use DE result
3. If DE fails (no file, JD out of range, I/O error):
- If DE was explicitly configured (GUC set), emit `ereport(NOTICE)`
- Fall back to VSOP87/ELP82B equivalent
- Return the VSOP87 result
When `pg_orrery.ephemeris_path` is empty (default), DE functions fall
back silently -- no NOTICE, no overhead, identical results to the
non-DE variants.
### Lauren Bugs (DE-Specific)
| Bug | What "Can't Happen" | How It Happens | Mitigation |
|-----|---------------------|----------------|------------|
| L1 | File won't change mid-query | DBA replaces file; new parallel worker opens new version | Per-backend handle is stable for backend lifetime |
| L2 | File will always be there | Docker restart with ephemeral volume | VSOP87 fallback always available |
| L3 | File is always valid | Partial download, bit rot, wrong endianness | Canary validation: Earth at J2000.0 ~1 AU from Sun; byte-order detection via AU constant |
| L4 | All backends use same version | Primary has DE441, replica has DE440 | AU consistency check; log ephemeris version at NOTICE |
| L5 | read() always succeeds | NFS timeout, disk error, fd limit | Every `read()` return checked; propagate as `ereport(ERROR)` |

61
docs/Dockerfile Normal file
View File

@ -0,0 +1,61 @@
ARG NODE_VERSION=22
# ── Stage 1: Build ──────────────────────────────────────────────
FROM node:${NODE_VERSION}-slim AS build
WORKDIR /app
# Install dependencies first (cache layer)
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Copy source and build
COPY . .
ENV ASTRO_TELEMETRY_DISABLED=1
RUN npm run build
# ── Stage 2: Production ────────────────────────────────────────
FROM caddy:2-alpine AS production
COPY --from=build /app/dist /srv
COPY <<'CADDYFILE' /etc/caddy/Caddyfile
:3000 {
root * /srv
file_server
try_files {path} {path}/ /404.html
encode gzip
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
}
header /docs/_astro/* {
Cache-Control "public, max-age=31536000, immutable"
}
}
CADDYFILE
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- http://127.0.0.1:3000/ || exit 1
# ── Stage 3: Development ───────────────────────────────────────
FROM node:${NODE_VERSION}-slim AS development
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
ENV ASTRO_TELEMETRY_DISABLED=1
EXPOSE 3000
CMD ["npm", "run", "dev"]

32
docs/Makefile Normal file
View File

@ -0,0 +1,32 @@
.PHONY: dev prod build down logs clean restart
COMPOSE = docker compose
# Start in development mode with hot-reload
dev:
$(COMPOSE) -f docker-compose.yml -f docker-compose.dev.yml up -d --build
@$(COMPOSE) -f docker-compose.yml -f docker-compose.dev.yml logs -f
# Start in production mode (static build served by Caddy)
prod:
$(COMPOSE) up -d --build
@$(COMPOSE) logs --tail=30
# Build image without starting
build:
$(COMPOSE) build
# Stop services
down:
$(COMPOSE) down
# Tail logs
logs:
$(COMPOSE) logs -f
# Stop and remove volumes
clean:
$(COMPOSE) down -v --remove-orphans
# Restart in production mode
restart: down prod

View File

@ -3,7 +3,7 @@
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
| From | craft-api | | From | craft-api |
| To | pg-orbit | | To | pg-orrery |
| Date | 2026-02-15T17:00:00-07:00 | | Date | 2026-02-15T17:00:00-07:00 |
| Re | Consumer use cases and API feedback on first draft | | Re | Consumer use cases and API feedback on first draft |
@ -13,7 +13,7 @@
Craft is a satellite tracking + radio astronomy platform at `~/claude/ham/satellite/astrolock/`. We run a FastAPI backend backed by TimescaleDB-HA. Our database holds 22,000+ satellites with TLE data, frequencies, group memberships, and pgai vector embeddings. The frontend renders a live sky view via `/api/sky/up`. Craft is a satellite tracking + radio astronomy platform at `~/claude/ham/satellite/astrolock/`. We run a FastAPI backend backed by TimescaleDB-HA. Our database holds 22,000+ satellites with TLE data, frequencies, group memberships, and pgai vector embeddings. The frontend renders a live sky view via `/api/sky/up`.
The sky engine (`packages/api/src/astrolock_api/services/sky_engine.py`) uses Python Skyfield to compute positions for planets, the sun/moon, bright stars, and comets. Satellites are conspicuously absent from the `whats_up()` response because per-request Python propagation of 22k TLEs is untenable. pg_orbit is the solution. The sky engine (`packages/api/src/astrolock_api/services/sky_engine.py`) uses Python Skyfield to compute positions for planets, the sun/moon, bright stars, and comets. Satellites are conspicuously absent from the `whats_up()` response because per-request Python propagation of 22k TLEs is untenable. pg_orrery is the solution.
## What We Love About the First Draft ## What We Love About the First Draft
@ -139,7 +139,7 @@ Our API passes observer coordinates as floats from the `observer_location` table
### P0 -- Unblocks `/api/sky/up` ### P0 -- Unblocks `/api/sky/up`
| Use Case | Query Pattern | pg_orbit Functions | | Use Case | Query Pattern | pg_orrery Functions |
|----------|--------------|-------------------| |----------|--------------|-------------------|
| What satellites are overhead? | `WHERE topo_elevation(observe(...)) >= :min_alt` | `observe()` (new), `topo_elevation()` | | What satellites are overhead? | `WHERE topo_elevation(observe(...)) >= :min_alt` | `observe()` (new), `topo_elevation()` |
| Single satellite position | `observe(tle_from_lines(:l1, :l2), :obs, NOW())` | `observe()` (new), `tle_from_lines()` (new) | | Single satellite position | `observe(tle_from_lines(:l1, :l2), :obs, NOW())` | `observe()` (new), `tle_from_lines()` (new) |
@ -147,7 +147,7 @@ Our API passes observer coordinates as floats from the `observer_location` table
### P1 -- Enables pass prediction and materialized views ### P1 -- Enables pass prediction and materialized views
| Use Case | Query Pattern | pg_orbit Functions | | Use Case | Query Pattern | pg_orrery Functions |
|----------|--------------|-------------------| |----------|--------------|-------------------|
| Upcoming passes for a group | `LATERAL predict_passes(tle, :obs, NOW(), NOW()+'24h', 10.0)` | `predict_passes()`, `tle_from_lines()` (new) | | Upcoming passes for a group | `LATERAL predict_passes(tle, :obs, NOW(), NOW()+'24h', 10.0)` | `predict_passes()`, `tle_from_lines()` (new) |
| Next pass for a satellite | `next_pass(tle_from_lines(:l1, :l2), :obs, NOW())` | `next_pass()`, `tle_from_lines()` (new) | | Next pass for a satellite | `next_pass(tle_from_lines(:l1, :l2), :obs, NOW())` | `next_pass()`, `tle_from_lines()` (new) |
@ -157,7 +157,7 @@ Our API passes observer coordinates as floats from the `observer_location` table
### P2 -- Batch Doppler, ground tracks, conjunction screening ### P2 -- Batch Doppler, ground tracks, conjunction screening
| Use Case | Query Pattern | pg_orbit Functions | | Use Case | Query Pattern | pg_orrery Functions |
|----------|--------------|-------------------| |----------|--------------|-------------------|
| Doppler correction | `f.frequency_mhz * (1 - topo_range_rate(observe(...))/299792.458)` | `observe()` (new), `topo_range_rate()` | | Doppler correction | `f.frequency_mhz * (1 - topo_range_rate(observe(...))/299792.458)` | `observe()` (new), `topo_range_rate()` |
| Ground track overlay | `LATERAL ground_track(tle, :start, :stop, '30s')` | `ground_track()` | | Ground track overlay | `LATERAL ground_track(tle, :start, :stop, '30s')` | `ground_track()` |
@ -166,7 +166,7 @@ Our API passes observer coordinates as floats from the `observer_location` table
### P2 -- PostGIS integration (future) ### P2 -- PostGIS integration (future)
| Use Case | Query Pattern | pg_orbit Functions | | Use Case | Query Pattern | pg_orrery Functions |
|----------|--------------|-------------------| |----------|--------------|-------------------|
| Satellites over a region | `WHERE ST_Contains(:geom, ST_Point(geodetic_lon(g), geodetic_lat(g)))` | `ground_track()`, geodetic accessors | | Satellites over a region | `WHERE ST_Contains(:geom, ST_Point(geodetic_lon(g), geodetic_lat(g)))` | `ground_track()`, geodetic accessors |
| Footprint circles | `ST_Buffer(ST_Point(lon, lat), footprint_radius)` | `subsatellite_point()`, `geodetic_lat/lon()` | | Footprint circles | `ST_Buffer(ST_Point(lon, lat), footprint_radius)` | `subsatellite_point()`, `geodetic_lat/lon()` |
@ -241,7 +241,7 @@ ORDER BY s.name, f.frequency_mhz;
We can provide: We can provide:
1. **ISS TLE + known Skyfield positions** -- We already compute ISS position via Python Skyfield. We can generate comparison data: given a TLE and timestamp, here's what Skyfield says for az/el/range from our observer. pg_orbit should match to within the expected SGP4 implementation differences. 1. **ISS TLE + known Skyfield positions** -- We already compute ISS position via Python Skyfield. We can generate comparison data: given a TLE and timestamp, here's what Skyfield says for az/el/range from our observer. pg_orrery should match to within the expected SGP4 implementation differences.
2. **Amateur satellite group TLEs** -- Our `satellite_group` table has curated groups ('amateur', 'weather', 'starlink', etc.). We can provide a batch of TLEs for pass prediction testing. 2. **Amateur satellite group TLEs** -- Our `satellite_group` table has curated groups ('amateur', 'weather', 'starlink', etc.). We can provide a batch of TLEs for pass prediction testing.
@ -264,4 +264,4 @@ We can provide:
- [ ] Confirm whether `tle_in()` validates checksums and what happens on bad input - [ ] Confirm whether `tle_in()` validates checksums and what happens on bad input
- [ ] Clarify NULL-vs-error behavior for failed propagation - [ ] Clarify NULL-vs-error behavior for failed propagation
- [ ] Consider a `_safe` variant of propagation functions that returns NULL on error - [ ] Consider a `_safe` variant of propagation functions that returns NULL on error
- [ ] Reply with `002-pg-orbit-*.md` when ready - [ ] Reply with `002-pg-orrery-*.md` when ready

View File

@ -2,7 +2,7 @@
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
| From | pg-orbit | | From | pg-orrery |
| To | craft-api | | To | craft-api |
| Date | 2026-02-15T18:45:00-07:00 | | Date | 2026-02-15T18:45:00-07:00 |
| Re | All three convenience functions shipped, plus _safe variants | | Re | All three convenience functions shipped, plus _safe variants |
@ -150,10 +150,10 @@ Drop the test data as `003-craft-test-data.md` in this thread and we'll integrat
## Build & Install ## Build & Install
```bash ```bash
cd ~/claude/pg_orbit cd ~/claude/pg_orrery
make clean && make # Zero warnings make clean && make # Zero warnings
sudo make install sudo make install
psql -c "DROP EXTENSION IF EXISTS pg_orbit CASCADE; CREATE EXTENSION pg_orbit;" psql -c "DROP EXTENSION IF EXISTS pg_orrery CASCADE; CREATE EXTENSION pg_orrery;"
``` ```
--- ---

View File

@ -3,7 +3,7 @@
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
| From | craft-api | | From | craft-api |
| To | pg-orbit | | To | pg-orrery |
| Date | 2026-02-15T19:30:00-07:00 | | Date | 2026-02-15T19:30:00-07:00 |
| Re | Test data package -- ISS vectors, edge cases, cross-verification script | | Re | Test data package -- ISS vectors, edge cases, cross-verification script |
@ -224,10 +224,10 @@ Save as `test/skyfield_verify.py` or run standalone. Uses `uv run` with inline s
# ] # ]
# /// # ///
""" """
Cross-verification tool for pg_orbit. Cross-verification tool for pg_orrery.
Computes satellite position via Skyfield/sgp4 and outputs JSON Computes satellite position via Skyfield/sgp4 and outputs JSON
for comparison against pg_orbit's observe() function. for comparison against pg_orrery's observe() function.
Usage: Usage:
uv run skyfield_verify.py --tle1 "1 25544U ..." --tle2 "2 25544 ..." \ uv run skyfield_verify.py --tle1 "1 25544U ..." --tle2 "2 25544 ..." \
@ -363,7 +363,7 @@ def run_batch(args):
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Cross-verify satellite positions against pg_orbit" description="Cross-verify satellite positions against pg_orrery"
) )
sub = parser.add_subparsers(dest="mode") sub = parser.add_subparsers(dest="mode")
@ -411,11 +411,11 @@ Batch mode expects a CSV with columns: `tle1,tle2,lat,lon,alt,timestamp`
uv run skyfield_verify.py batch --csv test_vectors.csv --output skyfield_results.json uv run skyfield_verify.py batch --csv test_vectors.csv --output skyfield_results.json
``` ```
Then compare `skyfield_results.json` against pg_orbit output for the same inputs. Then compare `skyfield_results.json` against pg_orrery output for the same inputs.
Expected agreement between Skyfield and pg_orbit: ~0.01 deg angular, ~1 km range, ~0.001 km/s range rate. Larger discrepancies indicate a constants mismatch (WGS-72 vs WGS-84) or nutation model difference. Expected agreement between Skyfield and pg_orrery: ~0.01 deg angular, ~1 km range, ~0.001 km/s range rate. Larger discrepancies indicate a constants mismatch (WGS-72 vs WGS-84) or nutation model difference.
## 5. Suggested pg_orbit Regression SQL ## 5. Suggested pg_orrery Regression SQL
### 5a. Round-trip: tle_from_lines extracts correct NORAD ID ### 5a. Round-trip: tle_from_lines extracts correct NORAD ID

View File

@ -2,7 +2,7 @@
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
| From | pg-orbit | | From | pg-orrery |
| To | craft-api | | To | craft-api |
| Date | 2026-02-15T19:10:00-07:00 | | Date | 2026-02-15T19:10:00-07:00 |
| Re | Test results -- all pass, plus 2-D GiST index upgrade | | Re | Test results -- all pass, plus 2-D GiST index upgrade |
@ -11,7 +11,7 @@
## Test Results Summary ## Test Results Summary
All suggested regression tests from message 003 pass against pg_orbit v0.1.0 on PostgreSQL 17 (port 5499). All suggested regression tests from message 003 pass against pg_orrery v0.1.0 on PostgreSQL 17 (port 5499).
| Test | Result | Notes | | Test | Result | Notes |
|------|--------|-------| |------|--------|-------|
@ -76,11 +76,11 @@ If Craft wants to explicitly filter by observer latitude (stronger pruning), we
Your `skyfield_verify.py` script is excellent. I have not run it yet (it requires `uv run` with sgp4/skyfield/numpy). The expected agreement tolerances you specified (0.05 deg angular, 5 km range, 0.01 km/s range rate) are reasonable for SGP4 implementations using the same WGS-72 constants. Your `skyfield_verify.py` script is excellent. I have not run it yet (it requires `uv run` with sgp4/skyfield/numpy). The expected agreement tolerances you specified (0.05 deg angular, 5 km range, 0.01 km/s range rate) are reasonable for SGP4 implementations using the same WGS-72 constants.
One note: Skyfield's `altaz()` uses the full IAU-80 nutation model (106 terms) for TEME→ITRF conversion, while pg_orbit uses only the 4 terms that match SGP4's internal model (per Hoots & Roehrich STR#3). This can introduce ~0.01-0.03 deg differences in azimuth at certain times. The range/range-rate agreement should be tighter since those are less sensitive to frame rotation. One note: Skyfield's `altaz()` uses the full IAU-80 nutation model (106 terms) for TEME→ITRF conversion, while pg_orrery uses only the 4 terms that match SGP4's internal model (per Hoots & Roehrich STR#3). This can introduce ~0.01-0.03 deg differences in azimuth at certain times. The range/range-rate agreement should be tighter since those are less sensitive to frame rotation.
## Next Steps ## Next Steps
Your edge case TLEs (Vela 1, Molniya, decayed, JPSS-1, GOES-16) are already verified individually. They would be good additions to pg_orbit's regression suite as permanent test cases. Your edge case TLEs (Vela 1, Molniya, decayed, JPSS-1, GOES-16) are already verified individually. They would be good additions to pg_orrery's regression suite as permanent test cases.
The amateur satellite batch is particularly valuable for testing `observe_safe()` in the pattern Craft's API will use — `LATERAL observe_safe(...)` over a table of TLEs. The amateur satellite batch is particularly valuable for testing `observe_safe()` in the pattern Craft's API will use — `LATERAL observe_safe(...)` over a table of TLEs.

View File

@ -3,9 +3,9 @@
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
| From | craft-api | | From | craft-api |
| To | pg-orbit | | To | pg-orrery |
| Date | 2026-02-15T20:00:00-07:00 | | Date | 2026-02-15T20:00:00-07:00 |
| Re | Integration complete — install pg_orbit on Craft database | | Re | Integration complete — install pg_orrery on Craft database |
--- ---
@ -15,13 +15,13 @@ Craft-side integration is done. The sky engine (`sky_engine.py`) already calls `
## What's Left ## What's Left
pg_orbit needs to be installed on the Craft database (TimescaleDB-HA, PostgreSQL 17, port 5499): pg_orrery needs to be installed on the Craft database (TimescaleDB-HA, PostgreSQL 17, port 5499):
```bash ```bash
cd ~/claude/pg_orbit cd ~/claude/pg_orrery
make clean && make make clean && make
sudo make install sudo make install
psql -p 5499 -d astrolock -c "CREATE EXTENSION IF NOT EXISTS pg_orbit;" psql -p 5499 -d astrolock -c "CREATE EXTENSION IF NOT EXISTS pg_orrery;"
``` ```
Once installed, `/api/sky/up` will include satellites automatically — no code changes, no restart needed. The `observe_safe()` query runs on next request. Once installed, `/api/sky/up` will include satellites automatically — no code changes, no restart needed. The `observe_safe()` query runs on next request.

164
docs/astro.config.mjs Normal file
View File

@ -0,0 +1,164 @@
import { defineConfig } from "astro/config";
import starlight from "@astrojs/starlight";
import tailwindcss from "@tailwindcss/vite";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import mermaid from "astro-mermaid";
import icon from "astro-icon";
import opengraphImages from "astro-opengraph-images";
import { pgOrreryOgImage } from "./src/og-renderer.js";
import * as fs from "fs";
export default defineConfig({
site: "https://pg-orrery.warehack.ing",
integrations: [
icon(),
mermaid(),
starlight({
title: "pg_orrery",
description:
"It's not rocket science. A database orrery — celestial mechanics for PostgreSQL.",
favicon: "/favicon.svg",
logo: {
src: "./src/assets/pg-orrery-logo.svg",
replacesTitle: true,
},
social: [
{
icon: "github",
label: "Gitea",
href: "https://git.supported.systems/warehack.ing/pg_orrery",
},
],
customCss: [
"./src/styles/custom.css",
"./src/styles/katex-fixes.css",
"katex/dist/katex.min.css",
],
head: [
{
tag: "meta",
attrs: {
name: "theme-color",
content: "#0a0e17",
},
},
],
components: {
Head: "./src/components/Head.astro",
},
sidebar: [
{
label: "Getting Started",
items: [
{ label: "What is pg_orrery?", slug: "getting-started/what-is-pg-orrery" },
{ label: "Installation", slug: "getting-started/installation" },
{ label: "Quick Start", slug: "getting-started/quick-start" },
],
},
{
label: "Guides",
items: [
{ label: "Tracking Satellites", slug: "guides/tracking-satellites" },
{ label: "Observing the Solar System", slug: "guides/observing-solar-system" },
{ 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" },
{ label: "Jupiter Radio Burst Prediction", slug: "guides/jupiter-radio-bursts" },
{ label: "Interplanetary Trajectories", slug: "guides/interplanetary-trajectories" },
{ label: "Conjunction Screening", slug: "guides/conjunction-screening" },
{ label: "JPL DE Ephemeris", slug: "guides/de-ephemeris" },
],
},
{
label: "Workflow Translation",
items: [
{ label: "From Skyfield to SQL", slug: "workflow/from-skyfield" },
{ label: "From JPL Horizons to SQL", slug: "workflow/from-jpl-horizons" },
{ label: "From GMAT to SQL", slug: "workflow/from-gmat" },
{ label: "From Radio Jupiter Pro to SQL", slug: "workflow/from-radio-jupiter-pro" },
{ label: "The SQL Advantage", slug: "workflow/sql-advantage" },
],
},
{
label: "Reference",
items: [
{ label: "Types", slug: "reference/types" },
{ label: "Functions: Satellite", slug: "reference/functions-satellite" },
{ label: "Functions: Solar System", slug: "reference/functions-solar-system" },
{ label: "Functions: Moons", slug: "reference/functions-moons" },
{ 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: DE Ephemeris", slug: "reference/functions-de" },
{ label: "Operators & GiST Index", slug: "reference/operators-gist" },
{ label: "Body ID Reference", slug: "reference/body-ids" },
{ label: "Constants & Accuracy", slug: "reference/constants-accuracy" },
],
},
{
label: "Architecture",
items: [
{ label: "Design Principles", slug: "architecture/design-principles" },
{ label: "Constant Chain of Custody", slug: "architecture/constant-chain-of-custody" },
{ label: "Observation Pipeline", slug: "architecture/observation-pipeline" },
{ label: "Theory-to-Code Mapping", slug: "architecture/theory-to-code" },
{ label: "Memory & Thread Safety", slug: "architecture/memory-thread-safety" },
{ label: "SGP4 Integration", slug: "architecture/sgp4-integration" },
],
},
{
label: "Performance",
items: [
{ label: "Benchmarks", slug: "performance/benchmarks" },
],
},
],
}),
opengraphImages({
options: {
fonts: [
{
name: "Inter",
weight: 400,
style: "normal",
data: fs.readFileSync(
"node_modules/@fontsource/inter/files/inter-latin-400-normal.woff"
),
},
{
name: "Inter",
weight: 700,
style: "normal",
data: fs.readFileSync(
"node_modules/@fontsource/inter/files/inter-latin-700-normal.woff"
),
},
],
},
render: pgOrreryOgImage,
}),
],
markdown: {
remarkPlugins: [remarkMath],
rehypePlugins: [rehypeKatex],
},
vite: {
plugins: [tailwindcss()],
server: {
host: "0.0.0.0",
...(process.env.VITE_HMR_HOST && {
hmr: {
host: process.env.VITE_HMR_HOST,
protocol: "wss",
clientPort: 443,
},
}),
},
},
telemetry: false,
devToolbar: { enabled: false },
});

View File

@ -0,0 +1,21 @@
services:
docs:
build:
target: development
volumes:
- ./src:/app/src
- ./public:/app/public
- ./astro.config.mjs:/app/astro.config.mjs
- ./package.json:/app/package.json
environment:
- VITE_HMR_HOST=${VITE_HMR_HOST:-pg-orrery.warehack.ing}
labels:
# WebSocket / HMR support for dev hot-reload
caddy.reverse_proxy.flush_interval: "-1"
caddy.reverse_proxy.transport: http
caddy.reverse_proxy.transport.read_timeout: "0"
caddy.reverse_proxy.transport.write_timeout: "0"
caddy.reverse_proxy.transport.keepalive: 5m
caddy.reverse_proxy.transport.keepalive_idle_conns: "10"
caddy.reverse_proxy.stream_timeout: 24h
caddy.reverse_proxy.stream_close_delay: 5s

22
docs/docker-compose.yml Normal file
View File

@ -0,0 +1,22 @@
services:
docs:
build:
context: .
dockerfile: Dockerfile
target: production
container_name: pg-orrery-docs
restart: unless-stopped
expose:
- "3000"
networks:
- caddy
environment:
- ASTRO_TELEMETRY_DISABLED=1
labels:
caddy: pg-orrery.warehack.ing
caddy.reverse_proxy: "{{upstreams 3000}}"
caddy.encode: gzip
networks:
caddy:
external: true

11797
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
docs/package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "pg-orrery-docs",
"type": "module",
"version": "2026.02.16",
"private": true,
"scripts": {
"dev": "astro dev --host 0.0.0.0 --port 3000",
"build": "astro build",
"preview": "astro preview --port 3000"
},
"dependencies": {
"@astrojs/starlight": "^0.37.6",
"@fontsource/inter": "^5.0.0",
"@fontsource/jetbrains-mono": "^5.0.0",
"@iconify-json/lucide": "^1.2.91",
"astro": "^5.17.2",
"astro-icon": "^1.1.5",
"astro-mermaid": "^1.3.1",
"astro-opengraph-images": "^1.14.3",
"astro-seo-meta": "^5.2.0",
"katex": "^0.16.28",
"react": "^19.2.4",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"sharp": "^0.33.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
}
}

6
docs/public/favicon.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" fill="none">
<ellipse cx="20" cy="20" rx="15" ry="7" stroke="#f59e0b" stroke-width="1.5" transform="rotate(-20 20 20)" opacity="0.6"/>
<ellipse cx="20" cy="20" rx="13" ry="5" stroke="#fbbf24" stroke-width="1" transform="rotate(35 20 20)" opacity="0.35"/>
<circle cx="20" cy="20" r="4" fill="#f59e0b"/>
<circle cx="32" cy="15" r="1.8" fill="#fbbf24"/>
</svg>

After

Width:  |  Height:  |  Size: 426 B

View File

@ -0,0 +1,99 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 400" fill="none">
<defs>
<!-- Orbital glow -->
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<filter id="starGlow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- Planet gradient -->
<radialGradient id="planetGrad" cx="45%" cy="40%">
<stop offset="0%" stop-color="#f59e0b"/>
<stop offset="60%" stop-color="#d97706"/>
<stop offset="100%" stop-color="#92400e"/>
</radialGradient>
<!-- Elephant gradient -->
<linearGradient id="elephantGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#94a3b8"/>
<stop offset="50%" stop-color="#64748b"/>
<stop offset="100%" stop-color="#475569"/>
</linearGradient>
<!-- Orbit trail gradient -->
<linearGradient id="orbitTrail" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#f59e0b" stop-opacity="0"/>
<stop offset="40%" stop-color="#f59e0b" stop-opacity="0.15"/>
<stop offset="100%" stop-color="#fbbf24" stop-opacity="0.6"/>
</linearGradient>
</defs>
<!-- Background stars -->
<circle cx="45" cy="30" r="1" fill="#fbbf24" opacity="0.4"/>
<circle cx="120" cy="80" r="0.8" fill="#e2e8f0" opacity="0.3"/>
<circle cx="530" cy="50" r="1.2" fill="#fbbf24" opacity="0.5"/>
<circle cx="480" cy="340" r="0.8" fill="#e2e8f0" opacity="0.3"/>
<circle cx="70" cy="350" r="1" fill="#e2e8f0" opacity="0.25"/>
<circle cx="560" cy="150" r="0.7" fill="#fbbf24" opacity="0.35"/>
<circle cx="200" cy="370" r="0.9" fill="#e2e8f0" opacity="0.2"/>
<circle cx="400" cy="25" r="1.1" fill="#fbbf24" opacity="0.4"/>
<circle cx="150" cy="180" r="0.6" fill="#e2e8f0" opacity="0.2"/>
<circle cx="510" cy="250" r="0.8" fill="#fbbf24" opacity="0.3"/>
<circle cx="30" cy="200" r="1" fill="#e2e8f0" opacity="0.15"/>
<circle cx="350" cy="380" r="0.7" fill="#fbbf24" opacity="0.25"/>
<!-- Outer orbit (behind planet) - dashed, subtle -->
<ellipse cx="300" cy="200" rx="220" ry="90" stroke="#f59e0b" stroke-width="0.8" stroke-dasharray="4 6" transform="rotate(-12 300 200)" opacity="0.15"/>
<!-- Primary orbit ellipse (behind planet portion) -->
<ellipse cx="300" cy="200" rx="180" ry="75" stroke="url(#orbitTrail)" stroke-width="2" transform="rotate(-12 300 200)" opacity="0.5"/>
<!-- Central planet/star -->
<circle cx="300" cy="200" r="45" fill="url(#planetGrad)" filter="url(#glow)"/>
<!-- Planet surface detail -->
<ellipse cx="290" cy="190" rx="20" ry="8" fill="#fbbf24" opacity="0.15" transform="rotate(-10 290 190)"/>
<ellipse cx="310" cy="210" rx="15" ry="5" fill="#92400e" opacity="0.2" transform="rotate(5 310 210)"/>
<!-- Primary orbit ellipse (in front of planet) - brighter section -->
<path d="M 135 230 A 180 75 -12 0 0 465 170" stroke="#fbbf24" stroke-width="2" transform="rotate(-12 300 200)" fill="none" opacity="0.6" stroke-linecap="round"/>
<!-- PostgreSQL Elephant (Slonik) - orbiting at upper-right of orbit -->
<!-- Positioned at roughly the 2 o'clock position on the orbit -->
<g transform="translate(438, 135) scale(0.55) rotate(15)">
<!-- Body -->
<ellipse cx="0" cy="10" rx="38" ry="30" fill="url(#elephantGrad)"/>
<!-- Head -->
<circle cx="-30" cy="-8" r="22" fill="url(#elephantGrad)"/>
<!-- Ear -->
<ellipse cx="-42" cy="-15" rx="14" ry="18" fill="#475569" stroke="#64748b" stroke-width="1"/>
<ellipse cx="-42" cy="-15" rx="9" ry="12" fill="#334155" opacity="0.6"/>
<!-- Trunk - curling forward and down -->
<path d="M -48 0 C -58 5, -62 20, -55 30 C -50 36, -42 34, -40 28" stroke="#64748b" stroke-width="7" fill="none" stroke-linecap="round"/>
<!-- Eye -->
<circle cx="-24" cy="-12" r="3" fill="#0a0e17"/>
<circle cx="-23" cy="-13" r="1" fill="#fbbf24" opacity="0.8"/>
<!-- Tusk -->
<path d="M -38 4 C -42 12, -38 18, -34 16" stroke="#e2e8f0" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<!-- Legs (2 visible) -->
<rect x="-12" y="30" width="8" height="20" rx="4" fill="#475569"/>
<rect x="12" y="30" width="8" height="20" rx="4" fill="#475569"/>
<!-- Tail -->
<path d="M 36 5 C 45 0, 48 8, 44 14" stroke="#64748b" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<!-- Orbital velocity trail from elephant -->
<path d="M 40 10 C 55 8, 70 12, 85 18" stroke="#fbbf24" stroke-width="1.5" fill="none" opacity="0.4" stroke-linecap="round"/>
<path d="M 40 15 C 52 14, 62 18, 72 24" stroke="#fbbf24" stroke-width="1" fill="none" opacity="0.25" stroke-linecap="round"/>
</g>
<!-- Small satellite dot (on opposite side of orbit) -->
<circle cx="165" cy="258" r="3" fill="#fbbf24" filter="url(#starGlow)" opacity="0.7"/>
<!-- Inner orbit ring (like Saturn's ring on the planet) -->
<ellipse cx="300" cy="200" rx="60" ry="12" stroke="#fbbf24" stroke-width="1" transform="rotate(-5 300 200)" opacity="0.25"/>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 40" fill="none">
<!-- Orbital ellipse -->
<ellipse cx="20" cy="20" rx="15" ry="7" stroke="#f59e0b" stroke-width="1.5" transform="rotate(-20 20 20)" opacity="0.6"/>
<!-- Second orbit (inclined) -->
<ellipse cx="20" cy="20" rx="13" ry="5" stroke="#fbbf24" stroke-width="1" transform="rotate(35 20 20)" opacity="0.35"/>
<!-- Central body -->
<circle cx="20" cy="20" r="4" fill="#f59e0b"/>
<!-- Satellite dot on orbit -->
<circle cx="32" cy="15" r="1.8" fill="#fbbf24"/>
<!-- "pg_orrery" text -->
<text x="44" y="27" font-family="Inter, system-ui, sans-serif" font-size="22" font-weight="700" fill="#e2e8f0" letter-spacing="0.02em">pg_orrery</text>
</svg>

After

Width:  |  Height:  |  Size: 729 B

View File

@ -0,0 +1,25 @@
---
/**
* Extends Starlight's built-in Head with OG image and Twitter card tags.
*
* Starlight already generates: og:title, og:type, og:url, og:locale,
* og:description, og:site_name, twitter:card, meta description.
*
* We add: og:image, twitter:image, twitter:title, twitter:description.
*/
import Default from "@astrojs/starlight/components/Head.astro";
import { getImagePath } from "astro-opengraph-images";
const route = Astro.locals.starlightRoute;
const title = route?.entry?.data?.title ?? "pg_orrery";
const description =
route?.entry?.data?.description ||
"It's not rocket science. A database orrery — celestial mechanics for PostgreSQL.";
const ogImageUrl = getImagePath({ url: Astro.url, site: Astro.site });
---
<Default {...Astro.props}><slot /></Default>
<meta property="og:image" content={ogImageUrl} />
<meta name="twitter:image" content={ogImageUrl} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />

View File

@ -0,0 +1,6 @@
import { defineCollection } from "astro:content";
import { docsSchema } from "@astrojs/starlight/schema";
export const collections = {
docs: defineCollection({ schema: docsSchema() }),
};

View File

@ -0,0 +1,176 @@
---
title: Constant Chain of Custody
sidebar:
order: 2
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
This is the single most critical design constraint in pg_orrery. Get it wrong and positions silently drift by kilometers. There is no runtime check that can detect this class of error after the fact.
## The problem
Two-Line Elements are not raw orbital measurements. They are *mean* elements produced by a differential correction process that fits observed positions against an SGP4 propagator running with a specific set of geopotential constants --- WGS-72. The mean elements absorb geodetic model biases: the eccentricity, inclination, and mean motion encode corrections that only make physical sense when propagated with the same constants used to generate them.
Substituting WGS-84 constants into the propagator does not "upgrade" accuracy. It breaks the internal consistency of the element set. The resulting position error can exceed the natural prediction error of the TLE by an order of magnitude.
<Aside type="danger" title="This is not theoretical">
The WGS-72 equatorial radius is 6378.135 km. The WGS-84 equatorial radius is 6378.137 km. The 2-meter difference looks negligible, but it enters every altitude computation, every semi-major axis derivation, and every GiST index key. Mixing the two constants corrupts results in ways that pass casual inspection but fail against reference implementations.
</Aside>
## The rules
Four rules govern constant usage across the entire codebase. No exceptions.
### Rule 1: WGS-72 for SGP4/SDP4 propagation
All propagation uses WGS-72 constants: $\mu$, $a_e$, $J_2$, $J_3$, $J_4$, $k_e$. These flow through the vendored `src/sgp4/norad_in.h` defines and are never overridden. The functions `SGP4_init()`, `SGP4()`, `SDP4_init()`, and `SDP4()` operate entirely in the WGS-72 domain.
### Rule 2: WGS-84 for coordinate output
Geodetic latitude, longitude, and altitude use the WGS-84 ellipsoid ($a = 6378.137$ km, $f = 1/298.257223563$). This is the modern standard for ground-station positioning, GPS receivers, and mapping services. The conversion from ECEF to geodetic in `coord_funcs.c:ecef_to_geodetic()` uses WGS-84.
### Rule 3: Reduced TEME nutation
The SGP4 output frame (True Equator, Mean Equinox) uses only 4 of the 106 IAU-80 nutation terms. Applying the full nutation model would "correct" for effects that SGP4 already accounts for internally, introducing error rather than removing it.
### Rule 4: No other combination is valid
WGS-72 for propagation, WGS-84 for output. Perigee and apogee altitudes use WGS-72 because they derive from mean elements. Geodetic altitude uses WGS-84 because it converts a physical position. There is no scenario where mixing these is correct.
## Constant inventory
The complete set of constants, with provenance and location in both pg_orrery and the vendored SGP4 code.
### WGS-72 constants (propagation domain)
Source: Hoots & Roehrich, "Models for Propagation of NORAD Element Sets," Spacetrack Report No. 3, 1980.
| Constant | Symbol | Value | `types.h` | `norad_in.h` |
|----------|--------|-------|-----------|--------------|
| Gravitational parameter | $\mu$ | $398600.8\ \text{km}^3/\text{s}^2$ | `WGS72_MU` | (implicit in $k_e$) |
| Equatorial radius | $a_e$ | $6378.135\ \text{km}$ | `WGS72_AE` | `earth_radius_in_km` |
| Zonal harmonic $J_2$ | $J_2$ | $0.001082616$ | `WGS72_J2` | `xj2` |
| Zonal harmonic $J_3$ | $J_3$ | $-2.53881 \times 10^{-6}$ | `WGS72_J3` | `xj3` |
| Zonal harmonic $J_4$ | $J_4$ | $-1.65597 \times 10^{-6}$ | `WGS72_J4` | `xj4` |
| Derived rate constant | $k_e$ | $0.0743669161\ \text{min}^{-1}$ | `WGS72_KE` | `xke` |
The rate constant $k_e$ is derived from $\mu$ and $a_e$:
$$
k_e = \frac{\sqrt{\mu} \times 60}{a_e^{3/2}}
$$
The factor of 60 converts from seconds to minutes, matching the SGP4 convention of radians per minute for mean motion.
### WGS-84 constants (output domain)
Source: NIMA TR8350.2, "Department of Defense World Geodetic System 1984."
| Constant | Symbol | Value | `types.h` |
|----------|--------|-------|-----------|
| Equatorial radius | $a$ | $6378.137\ \text{km}$ | `WGS84_A` |
| Flattening | $f$ | $1/298.257223563$ | `WGS84_F` |
| Eccentricity squared | $e^2$ | $f(2 - f)$ | `WGS84_E2` |
## Why two copies of AE?
`types.h` carries a parallel copy of the WGS-72 constants even though the vendored SGP4 code defines them in `norad_in.h`. This is intentional.
`types.h` is the single header for all pg_orrery C sources. `norad_in.h` is an internal SGP4 header in `src/sgp4/` not meant for external consumers. The GiST index (`gist_tle.c`) and TLE accessor functions (`tle_type.c`) need $k_e$ and $a_e$ without pulling in sat_code internals. The values **must** be identical.
The perigee and apogee altitude computations derive from mean elements:
$$
a_{er} = \left(\frac{k_e}{n}\right)^{2/3} \quad \text{[earth radii]}
$$
$$
\text{perigee}_\text{km} = a_{er} \cdot (1 - e) \cdot a_e - a_e
$$
$$
\text{apogee}_\text{km} = a_{er} \cdot (1 + e) \cdot a_e - a_e
$$
These **must** use WGS-72 $a_e$ (6378.135 km), not WGS-84 (6378.137 km), because $n$ is a mean motion fitted against the WGS-72 geopotential. Using the wrong radius shifts every altitude by 2 meters. The error compounds in GiST index operations where altitude-band overlap determines whether two orbits are candidates for conjunction screening.
## Where the boundary lives
The WGS-72/WGS-84 boundary is crossed in exactly two places in the codebase:
<Tabs>
<TabItem label="TEME to Geodetic">
`coord_funcs.c:ecef_to_geodetic()` converts ECEF Cartesian coordinates (derived from WGS-72 propagation through GMST rotation) to geodetic latitude, longitude, and altitude on the WGS-84 ellipsoid. This is the correct boundary --- the ECEF position is a physical location in space, and WGS-84 is the standard for expressing that location as geodetic coordinates.
</TabItem>
<TabItem label="Observer to ECEF">
`coord_funcs.c:observer_to_ecef()` converts a ground station's geodetic coordinates (on WGS-84, as entered by the user) to ECEF Cartesian for the topocentric transform. The observer's position is a real-world location defined in WGS-84; converting it to ECEF puts it in the same Cartesian frame as the satellite.
</TabItem>
</Tabs>
Everything upstream of these functions operates in the WGS-72 domain. Everything downstream operates on physical positions that have already been converted. The boundary is narrow, explicit, and documented in the source comments.
## The GMST question
The GMST computation uses the IAU 1982 formula (Vallado Eq. 3-47):
$$
\text{GMST} = 67310.54841 + (876600 \times 3600 + 8640184.812866) \cdot T_{UT1} + 0.093104 \cdot T_{UT1}^2 - 6.2 \times 10^{-6} \cdot T_{UT1}^3
$$
where $T_{UT1} = (JD - 2451545.0) / 36525.0$, and the result is in seconds of time, converted to radians by multiplying by $\pi / 43200$ and normalized to $[0, 2\pi)$.
pg_orrery deliberately does **not** use a higher-precision GMST model (e.g., IAU 2000A). The SGP4 output is only accurate to the precision of its own GMST model. Applying a more precise rotation would not improve the final position and could introduce a systematic offset between the propagated TEME position and the Earth-fixed frame.
This is the constant chain of custody in action: match the precision of the input, not the precision available in the literature.
## Rules 6-8: DE Ephemeris Pipeline (v0.3.0)
The v0.3.0 DE integration adds three rules to the chain of custody.
### Rule 6: ICRS-to-ecliptic frame rotation at the provider boundary
DE ephemerides return positions in the ICRS equatorial frame. The observation pipeline expects ecliptic J2000. The conversion uses `equatorial_to_ecliptic()` from `astro_math.h`, applied in `eph_provider.c` before the position enters the shared observation pipeline. This rotation is applied to both the target body and Earth.
### Rule 7: Same-provider consistency
Both the target body and Earth must come from the same provider in any geocentric computation. If DE succeeds for the target but fails for Earth (or vice versa), the entire computation falls back to VSOP87. This prevents mixing DE Mars with VSOP87 Earth, which would introduce a frame-dependent systematic error at the arcsecond level.
```c
/* From de_funcs.c: both positions or neither */
if (eph_de_planet(body_id, jd, target_xyz) &&
eph_de_earth(jd, earth_xyz))
{
/* Use DE positions for both */
}
else
{
/* Fall back to VSOP87 for both */
}
```
### Rule 8: AU consistency verification
The DE header contains an AU value (in km). At init time, `eph_provider.c` verifies this matches pg_orrery's compiled-in `AU_KM` constant (149597870.7 km, IAU 2012). A mismatch would corrupt every distance calculation. If they disagree, the DE file is rejected and fallback to VSOP87 activates with a log message.
<Aside type="note" title="For maintainers">
If you are modifying `eph_provider.c` or `de_funcs.c`, remember that Rule 7 is the critical invariant. Never return a DE position for one body and a VSOP87 position for another within the same geocentric computation. The conditional must gate both positions atomically.
</Aside>
## Verification
The chain of custody is verified through the Vallado 518 test vectors --- 518 reference propagations across a range of orbit types (LEO, MEO, GEO, deep-space, high-eccentricity). Every test vector must match to machine epsilon before any other development proceeds.
If a code change causes even one vector to drift, the constant chain has been broken somewhere. The test suite is the enforcement mechanism for the design constraint.
```sql
-- Verify against Vallado test vector (ISS-like orbit)
-- Expected: position match within 1e-8 km
SELECT eci_x(sgp4_propagate(tle, epoch + interval '720 minutes'))
FROM vallado_test_vectors
WHERE norad_id = 25544;
```
<Aside type="note" title="For maintainers">
If you are modifying any function in `tle_type.c`, `gist_tle.c`, or `coord_funcs.c`, check which constant set you are using. If you find yourself reaching for `WGS84_A` in a function that computes from mean elements, stop. You are about to break the chain.
</Aside>

View File

@ -0,0 +1,187 @@
---
title: Design Principles
sidebar:
order: 1
---
import { Aside, Tabs, TabItem, Steps } from "@astrojs/starlight/components";
pg_orrery is engineering software that computes physical quantities. A wrong answer delivered confidently is worse than no answer at all. The design principles that govern the extension trace directly to Margaret Hamilton's work on the Apollo guidance computer --- software that could not afford to be approximately correct.
These principles are not aspirational. They are enforced structurally in the code.
## Development before the fact
Hamilton's most fundamental principle: design the system correctly from the start, rather than patching it after deployment. In pg_orrery, this manifests as the **constant chain of custody** --- the strict separation between WGS-72 constants (used for SGP4 propagation) and WGS-84 constants (used for coordinate output).
This separation was not bolted on after a bug was found. It was the first architectural decision, made before any code was written. The `types.h` header carries both constant sets with explicit comments about which functions may use which set.
```c
/* WGS-72 constants (for SGP4 propagation ONLY) */
#define WGS72_MU 398600.8 /* km^3/s^2 */
#define WGS72_AE 6378.135 /* km */
/* WGS-84 constants (for coordinate output ONLY) */
#define WGS84_A 6378.137 /* km */
#define WGS84_F (1.0 / 298.257223563)
```
The 2-meter difference between WGS-72 and WGS-84 equatorial radii looks insignificant. It compounds through index operations, altitude computations, and conjunction screening. Getting this wrong would not produce a crash --- it would produce subtly wrong results that pass every test except comparison with an independent reference implementation.
See [Constant Chain of Custody](/architecture/constant-chain-of-custody/) for the full treatment.
## Error detection by design
The Apollo guidance computer did not wait for failures to announce themselves. It classified errors by severity and responded proportionally. pg_orrery follows the same pattern across three mechanisms.
### The `_safe()` function variants
Every propagation function that can fail has a `_safe()` variant that returns `NULL` instead of raising a PostgreSQL `ERROR`. This lets callers handle failure in SQL without `BEGIN/EXCEPTION` blocks:
<Tabs>
<TabItem label="Standard (aborts on error)">
```sql
-- Raises ERROR if TLE has decayed past validity
SELECT sgp4_propagate(tle, now())
FROM catalog;
```
</TabItem>
<TabItem label="Safe (returns NULL)">
```sql
-- Returns NULL for invalid propagations, continues scan
SELECT sgp4_propagate_safe(tle, now())
FROM catalog
WHERE sgp4_propagate_safe(tle, now()) IS NOT NULL;
```
</TabItem>
</Tabs>
### SGP4 error classification
The vendored SGP4/SDP4 library returns six distinct error codes. pg_orrery classifies them into two categories based on physical meaning:
| Code | Meaning | Severity | Response |
|------|---------|----------|----------|
| -1 | Nearly parabolic orbit ($e \geq 1$) | Fatal | `ereport(ERROR)` |
| -2 | Negative semi-major axis (decayed) | Fatal | `ereport(ERROR)` |
| -3 | Orbit within Earth | Warning | `ereport(NOTICE)`, return result |
| -4 | Perigee within Earth | Warning | `ereport(NOTICE)`, return result |
| -5 | Negative mean motion | Fatal | `ereport(ERROR)` |
| -6 | Kepler equation diverged | Fatal | `ereport(ERROR)` |
The distinction between warnings and errors is physical, not numerical. A satellite with perigee below Earth's surface is plausible during reentry --- the state vector is still mathematically valid. A negative semi-major axis means the model has broken down entirely.
### Input validation at storage time
TLE parsing errors are caught in `tle_in()`, not during propagation. Invalid TLEs never enter the database. A marginal TLE might parse correctly but fail during propagator initialization --- that failure surfaces at query time with a clear error message.
## Priority-driven execution
The Apollo computer had a priority scheduler that shed low-priority tasks under overload rather than crashing. pg_orrery applies a similar principle in pass prediction: **failures degrade gracefully instead of aborting the scan**.
When `elevation_at_jd()` encounters a propagation error during the coarse scan, it returns $-\pi$ radians --- well below any physical horizon elevation. The scan treats this as "satellite below horizon" and continues searching.
```c
static double
elevation_at_jd(/* ... */)
{
int err = propagate_tle(&sat, tsince, pos, vel);
if (err < -2) /* hard errors: treat as below horizon */
return -M_PI;
/* ... compute actual elevation ... */
}
```
This matters because a TLE might be valid for the first three days of a seven-day search window and then decay past model validity. The pass finder should return the three days of valid passes, not abort the entire query.
## Ultra-reliable software
Hamilton defined ultra-reliable software as software that behaves correctly under all possible input combinations, including combinations the designer did not anticipate. pg_orrery achieves this through four structural guarantees.
### Zero global mutable state
For v0.1.0/v0.2.0 functions, there are no file-scope variables, no static locals, no caches. Every function computes from its arguments alone. The v0.3.0 DE ephemeris layer introduces per-backend static state (a file descriptor and coefficient cache in `eph_provider.c`), but each backend gets its own copy after `fork()` --- no shared state between processes. All 68 pg_orrery functions carry the `PARALLEL SAFE` declaration, meaning the query planner can distribute work across multiple CPU cores without coordination.
### 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.
### Deterministic memory
All heap allocation goes through `palloc()`/`pfree()`. No `malloc()`, no `new`, no static buffers. PostgreSQL's memory context system owns every byte, and frees it automatically when the query completes.
### Reproducible computation
Given the same TLE and timestamp, pg_orrery produces the same result on every platform, every time. No floating-point non-determinism from threading, no stale caches, no accumulated state from previous calls.
## Software engineering as discipline
Hamilton insisted that software engineering was a real engineering discipline, not an ad hoc craft. For pg_orrery, this means every equation in the codebase traces to a published, peer-reviewed source.
The [Theory-to-Code Mapping](/architecture/theory-to-code/) page provides the complete table. A sample:
| Equation | Source | Code |
|----------|--------|------|
| SGP4/SDP4 propagation | Hoots & Roehrich, STR#3 (1980) | `src/sgp4/sgp4.c`, `sdp4.c` |
| VSOP87 planetary positions | Bretagnon & Francou (1988) | `src/vsop87.c` |
| GMST computation | Vallado (2013) Eq. 3-47 | `src/coord_funcs.c:gmst_from_jd()` |
| Lambert solver | Izzo (2015) | `src/lambert.c` |
| Precession J2000 to date | Lieske et al. (1977) | `src/precession.c` |
Every constant has a provenance. Every algorithm has a citation. If a future maintainer needs to understand why `0.40909280422232897` appears in `types.h`, the comment says "23.4392911 degrees in radians" and the design document traces it to the IAU value for the obliquity of the ecliptic at J2000.
## Systems thinking
Hamilton's approach to the Apollo software was holistic --- she understood that modifying one subsystem could cascade through the entire stack. pg_orrery embodies this through the **observation pipeline**, a seven-stage flow from heliocentric coordinates to topocentric azimuth and elevation.
<Steps>
1. VSOP87 heliocentric ecliptic J2000 position for the target body (AU)
2. VSOP87 heliocentric ecliptic J2000 position for Earth
3. Geocentric ecliptic = target minus Earth
4. Ecliptic-to-equatorial rotation by J2000 obliquity ($23.4392911\degree$)
5. IAU 1976 precession from J2000 to the date of observation
6. GMST for sidereal time (Vallado Eq. 3-47, IAU 1982)
7. Equatorial-to-horizontal transform for the observer's latitude and longitude
</Steps>
You cannot modify stage 4 without understanding what stage 3 produces and what stage 5 expects. You cannot swap the GMST model without understanding that the SGP4 output is only accurate to the precision of its own internal GMST --- applying a higher-precision rotation would not improve accuracy and could introduce systematic offsets.
See [Observation Pipeline](/architecture/observation-pipeline/) for the full flow with equations.
## The "Lauren Bug"
<Aside type="tip" title="The name">
Hamilton named this class of error after her daughter Lauren, who as a young child pressed unexpected key sequences on the Apollo simulator and crashed it. The lesson: if a child can trigger it, an astronaut under stress certainly will. Design for the input you did not expect.
</Aside>
pg_orrery defends against three categories of unexpected input that would silently produce wrong results in a naive implementation.
### Same-body Lambert transfer
What happens when someone computes a transfer from Earth to Earth?
```sql
SELECT * FROM lambert_transfer(3, 3, '2028-01-01', '2028-06-01');
```
The departure and arrival positions are the same body at different times. The Lambert solver would converge on a trivial solution that does not represent a physical transfer orbit. pg_orrery validates `dep_body_id != arr_body_id` and returns an error before invoking the solver.
### Arrival before departure
```sql
SELECT * FROM lambert_transfer(3, 4, '2029-06-15', '2028-10-01');
```
A negative time of flight. The Lambert solver might converge on a mathematically valid but physically meaningless retrograde solution. pg_orrery checks `arr_time > dep_time` and returns an error.
### Observer on the observed body
When computing the topocentric observation of Earth (body ID 3), the geocentric vector is zero --- the observer is on the body being observed. Division by zero in the range computation. pg_orrery catches this case and returns a clear error rather than NaN or infinity propagating through the rest of the pipeline.
These are not edge cases in the traditional sense. They are the inputs that a SQL user will inevitably produce when exploring the system with ad hoc queries, and they must produce clear errors rather than silently wrong results.
### DE ephemeris file dependency (v0.3.0)
The v0.3.0 DE integration introduces five new failure domains --- external file dependency, per-backend mutable state, OS-level file descriptor management, frame boundary crossings, and volatility contract management --- into a system that previously had none.
Each is handled by the principle of graceful degradation: DE functions fall back to VSOP87 on any DE-specific failure. The existing VSOP87 pipeline is the bulkhead --- always compiled in, always works, always IMMUTABLE. See the [DE Ephemeris guide](/guides/de-ephemeris/) for details on the fallback strategy.

View File

@ -0,0 +1,190 @@
---
title: Memory & Thread Safety
sidebar:
order: 5
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
PostgreSQL extensions run inside a shared-memory, multi-process server. A function that leaks memory degrades the entire backend. A function that uses global state cannot be parallelized. pg_orrery is designed to be a well-behaved citizen: all memory goes through PostgreSQL's allocator, and no mutable state survives between function calls.
## Allocation strategy
All heap allocation goes through `palloc()` / `pfree()`. No `malloc()`, no `new`, no static buffers. This is not a convention --- it is a hard requirement. PostgreSQL's memory context system tracks every allocation and frees entire contexts at transaction boundaries, query completion, or error recovery. Using `malloc()` would create memory that PostgreSQL cannot reclaim on error, leading to gradual backend bloat.
### Single-shot propagation
Functions like `sgp4_propagate()` and `tle_distance()` follow the simplest pattern:
```c
double *params = palloc(sizeof(double) * N_SAT_PARAMS);
/* Initialize and propagate */
SGP4_init(params, &sat);
err = SGP4(tsince, &sat, params, pos, vel);
pfree(params);
```
The `params` array (~92 doubles, ~736 bytes) lives in the current memory context. It is allocated before propagation and freed before the function returns. If an `ereport(ERROR)` fires between `palloc` and `pfree`, PostgreSQL's error recovery frees the current context automatically.
### Set-returning functions
SRF functions like `sgp4_propagate_series()`, `ground_track()`, and `predict_passes()` must maintain state across multiple calls. They use PostgreSQL's `multi_call_memory_ctx`:
```c
typedef struct {
tle_t sat;
double params[N_SAT_PARAMS]; /* embedded, not separate allocation */
int is_deep;
double epoch_jd;
int64 start_ts;
int64 step_usec;
} propagate_series_ctx;
```
<Aside type="note" title="Embedded arrays">
The `params` array is embedded directly in the context struct, not allocated separately. This puts everything in a single `palloc` call --- fewer allocations, better cache locality during the per-row propagation loop, and simpler cleanup.
</Aside>
The lifecycle:
<Tabs>
<TabItem label="First call">
```c
funcctx = SRF_FIRSTCALL_INIT();
oldctx = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
ctx = palloc(sizeof(propagate_series_ctx));
/* Copy TLE and observer into ctx */
/* Initialize propagator */
MemoryContextSwitchTo(oldctx);
funcctx->user_fctx = ctx;
```
</TabItem>
<TabItem label="Subsequent calls">
```c
funcctx = SRF_PERCALL_SETUP();
ctx = funcctx->user_fctx;
/* Propagate to next timestep using ctx->params */
/* Return result or SRF_RETURN_DONE */
```
</TabItem>
<TabItem label="Cleanup">
PostgreSQL frees `multi_call_memory_ctx` automatically when the SRF completes (either by returning `SRF_RETURN_DONE` or via error recovery). No explicit cleanup code needed.
</TabItem>
</Tabs>
### Type I/O functions
Input functions (`tle_in`, `eci_in`, etc.) allocate the result struct with `palloc()` in the current context. PostgreSQL manages the lifecycle --- the struct may be copied into a tuple for storage or used transiently for a computation.
```c
pg_tle *result = (pg_tle *) palloc(sizeof(pg_tle));
/* Parse input text into result */
PG_RETURN_POINTER(result);
```
## Minimal global mutable state
The v0.1.0/v0.2.0 functions have zero global mutable state --- no file-scope variables, no static locals, no caches. Every function computes from its arguments alone.
The v0.3.0 DE ephemeris layer introduces a controlled exception: per-backend static variables in `eph_provider.c` (`de_handle_ptr`, `de_init_attempted`, `de_init_success`). These are safe because each PostgreSQL backend (including parallel workers) gets its own copy after `fork()` --- no shared state between processes. The handle is opened lazily on first `_de()` call and cleaned up via `on_proc_exit()`. See the [DE Ephemeris guide](/guides/de-ephemeris/) for details.
This guarantee has three consequences:
### PARALLEL SAFE
All 68 pg_orrery functions are declared `PARALLEL SAFE` in the SQL definition. This tells PostgreSQL's query planner that the function can be executed in parallel worker processes without coordination. For bulk operations like propagating 12,000 TLEs, the planner can distribute work across multiple CPU cores:
```sql
-- PostgreSQL may parallelize this across available cores
SELECT tle_norad_id(tle),
eci_x(sgp4_propagate(tle, now())) AS x_km
FROM satellite_catalog;
```
If any function used global state --- even a read-only cache --- PostgreSQL would need to serialize access or copy state between workers. `PARALLEL SAFE` cannot be declared for functions with global mutable state; doing so risks data races and incorrect results.
### No cross-session contamination
PostgreSQL backends are long-lived processes that serve multiple sessions. A global variable written by session A persists when session B runs in the same backend. pg_orrery avoids this entirely --- no function call leaves any trace in the process state.
### Deterministic computation
Given the same TLE and timestamp, pg_orrery produces the same result regardless of what queries ran before, how many backends are active, or whether the function is running in a parallel worker. There is no path-dependent behavior.
## SGP4/SDP4 memory model
The vendored SGP4/SDP4 code has no global mutable state. The propagator state lives entirely in two caller-provided structures:
| Structure | Size | Contains | Owner |
|-----------|------|----------|-------|
| `tle_t` | ~200 bytes | Parsed mean elements, identification | Caller (pg_orrery copies from `pg_tle`) |
| `params[N_SAT_PARAMS]` | ~736 bytes | Initialized propagator coefficients | Caller (pg_orrery `palloc`s this) |
The `SGP4_init()` / `SDP4_init()` functions write into the `params` array. The `SGP4()` / `SDP4()` functions read from `params` and `tle_t`, and write position/velocity into caller-provided arrays. No internal state is retained between calls.
This maps cleanly to PostgreSQL's per-call execution model. There is no object lifecycle to manage, no destructor to call, no persistent state to synchronize.
## Fixed-size types
All seven pg_orrery types are fixed-size with `STORAGE = plain`:
| Type | Size | `ALIGNMENT` | TOAST? |
|------|------|-------------|--------|
| `tle` | 112 bytes | `double` | No |
| `eci_position` | 48 bytes | `double` | No |
| `geodetic` | 24 bytes | `double` | No |
| `topocentric` | 32 bytes | `double` | No |
| `observer` | 24 bytes | `double` | No |
| `pass_event` | 48 bytes | `double` | No |
| `heliocentric` | 24 bytes | `double` | No |
### Why fixed-size matters
**No TOAST overhead.** Variable-length types (varlena) carry a 4-byte header and may be compressed or moved to a secondary TOAST table. Reading a TOASTed value requires a separate heap fetch. Fixed-size types are stored inline in the tuple --- one pointer dereference, no detoasting.
**Direct pointer access.** `PG_GETARG_POINTER(n)` returns a pointer directly into the tuple data. No copy, no allocation. The function reads the struct in place.
**Predictable memory layout.** All types use `ALIGNMENT = double` because every struct contains `double` fields. This satisfies the strictest alignment requirement without platform-specific conditionals.
**Binary I/O.** The `tle_recv()` / `tle_send()` functions implement binary protocol support. The fixed layout means binary transfer is a straight memory copy --- no serialization logic, no endianness concerns beyond what PostgreSQL's binary protocol handles.
### TLE: 112 bytes vs raw text
The TLE text format is 138+ bytes (two 69-character lines plus separator). The parsed struct is 112 bytes --- smaller than the text it came from, and it eliminates the ~10x parsing overhead that would be incurred on every propagation call if raw text were stored.
The text representation can be reconstructed from the parsed elements via the vendored `write_elements_in_tle_format()`. The round-trip is lossless for all fields that affect propagation.
## Memory usage in practice
For a typical catalog query propagating 12,000 TLEs:
| Resource | Per-call | Peak (12K TLEs) |
|----------|----------|-----------------|
| `params` array | 736 bytes | 736 bytes (reused) |
| `tle_t` conversion | 200 bytes (stack) | 200 bytes |
| Result `pg_eci` | 48 bytes | 48 bytes (returned, then freed) |
| **Total transient** | **~1 KB** | **~1 KB** |
The 736-byte `params` array is the largest per-call allocation. It is freed before the function returns. At no point does pg_orrery hold allocations proportional to the number of rows being processed --- each row is computed and returned independently.
<Aside type="caution" title="SRF exception">
Set-returning functions hold their context struct for the lifetime of the SRF call. For `predict_passes()` over a 7-day window, this is ~1 KB for the duration of the scan. The context is freed when the SRF completes.
</Aside>
## Error recovery
When `ereport(ERROR)` fires inside a pg_orrery function, PostgreSQL's error recovery mechanism:
1. Unwinds the call stack via `longjmp`
2. Frees the current memory context (including any `palloc`'d memory)
3. Rolls back the current transaction
4. Returns an error message to the client
Because pg_orrery uses only `palloc` and has no global state for v0.1.0/v0.2.0 functions, there is nothing to clean up beyond what PostgreSQL's context system handles automatically. No sockets, no mutex locks, no C++ destructors.
The v0.3.0 DE reader holds a file descriptor in per-backend static state. This is cleaned up via `on_proc_exit(eph_cleanup, 0)`, registered during `_PG_init()`. If `ereport(ERROR)` fires during a DE function, the file descriptor persists (it will be reused by the next DE call in the same backend) --- it is not leaked, just kept open for the backend's lifetime. The extension is always in a consistent state after error recovery.

View File

@ -0,0 +1,201 @@
---
title: Observation Pipeline
sidebar:
order: 3
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
When a user calls `planet_observe(5, '40.0N 105.3W 1655m'::observer, now())` to find Jupiter's position in the sky, seven coordinate transformations execute in sequence. Each stage consumes the output of the previous stage and produces input for the next. Understanding this pipeline is a prerequisite for modifying any part of it.
## The full pipeline
```mermaid
flowchart TD
A["VSOP87: Target heliocentric<br/>ecliptic J2000 (AU)"] --> C["Geocentric ecliptic<br/>target - Earth"]
B["VSOP87: Earth heliocentric<br/>ecliptic J2000 (AU)"] --> C
C --> D["Ecliptic → Equatorial J2000<br/>obliquity rotation"]
D --> E["RA/Dec J2000<br/>Cartesian → spherical"]
E --> F["Precession J2000 → date<br/>IAU 1976"]
F --> G["Sidereal time → hour angle<br/>GMST (Vallado Eq. 3-47)"]
G --> H["Equatorial → Horizontal<br/>az/el for observer"]
H --> I["pg_topocentric result<br/>(az, el, range, range_rate)"]
```
## Stage-by-stage breakdown
<Steps>
1. **Heliocentric ecliptic position of the target**
VSOP87 (Bretagnon & Francou, 1988) computes the target planet's position in the heliocentric ecliptic J2000 frame. The output is three Cartesian coordinates in AU.
VSOP87 is a semi-analytical theory: it expands each coordinate as a sum of trigonometric series with polynomial time arguments. The truncated series used in pg_orrery provides ~1 arcsecond accuracy for the inner planets and ~1-2 arcseconds for the outer planets over the period 2000 BCE to 6000 CE.
For the Sun, this stage returns $(0, 0, 0)$ --- the Sun is at the origin of heliocentric coordinates. The Sun's apparent position is computed by inverting Earth's heliocentric position.
For the Moon, this stage is replaced by ELP2000-82B (Chapront-Touze & Chapront, 1988), which computes geocentric ecliptic coordinates directly, skipping stage 2.
**Code**: `src/vsop87.c:GetVsop87Coor()`, `src/elp82b.c:GetElp82bCoor()`
2. **Heliocentric ecliptic position of Earth**
The same VSOP87 call, but for body ID 3 (Earth). This gives Earth's position in the same frame as the target. Without this, there is no way to compute where the target appears from Earth's perspective.
**Code**: `src/vsop87.c:GetVsop87Coor()` with `body = 2` (VSOP87 uses 0-indexed: Venus=1, Earth=2, Mars=3, etc.)
3. **Geocentric ecliptic vector**
Subtract Earth's heliocentric position from the target's:
$$
\vec{r}_\text{geo} = \vec{r}_\text{target} - \vec{r}_\text{Earth}
$$
The result is the target's position as seen from Earth's center, still in the ecliptic J2000 frame. For the Moon, ELP2000-82B provides this directly.
**Code**: `src/planet_funcs.c:planet_observe()`, lines computing `geo_ecl_au[3]`
4. **Ecliptic to equatorial rotation**
The ecliptic and equatorial planes are tilted relative to each other by the obliquity of the ecliptic. At J2000, this angle is:
$$
\varepsilon_0 = 23.4392911\degree = 0.40909280422232897\ \text{rad}
$$
The rotation is around the X-axis (the vernal equinox direction, which is shared between both frames):
$$
\begin{bmatrix} x_\text{equ} \\ y_\text{equ} \\ z_\text{equ} \end{bmatrix}
= \begin{bmatrix} 1 & 0 & 0 \\ 0 & \cos\varepsilon_0 & -\sin\varepsilon_0 \\ 0 & \sin\varepsilon_0 & \cos\varepsilon_0 \end{bmatrix}
\begin{bmatrix} x_\text{ecl} \\ y_\text{ecl} \\ z_\text{ecl} \end{bmatrix}
$$
This uses the J2000 obliquity, not the obliquity of date, because both VSOP87 and the subsequent precession model are referenced to J2000.
**Code**: `src/planet_funcs.c:ecliptic_to_equatorial()` (via `astro_math.h`)
5. **Precession from J2000 to date**
The equatorial coordinate system itself rotates slowly due to lunisolar and planetary precession. Right ascension and declination at J2000 must be precessed to the epoch of observation.
pg_orrery uses the IAU 1976 precession model (Lieske et al., 1977), which expresses the three Euler angles $\zeta_A$, $z_A$, and $\theta_A$ as cubic polynomials in centuries from J2000:
$$
\zeta_A = 0\overset{\prime\prime}{.}6406161 \cdot T + 0\overset{\prime\prime}{.}0000839 \cdot T^2 + 0\overset{\prime\prime}{.}0000050 \cdot T^3
$$
The full rotation matrix $R_3(-z_A) \cdot R_2(\theta_A) \cdot R_3(-\zeta_A)$ transforms J2000 equatorial to equatorial of date.
**Code**: `src/precession.c:precess_j2000_to_date()`
6. **Sidereal time and hour angle**
To convert from the celestial sphere to the observer's local sky, we need the observer's relationship to the vernal equinox. This requires two quantities:
- **GMST** (Greenwich Mean Sidereal Time): the hour angle of the vernal equinox at Greenwich, computed from the Julian date using Vallado Eq. 3-47 (IAU 1982 model).
- **LST** (Local Sidereal Time): $\text{LST} = \text{GMST} + \lambda_\text{observer}$, where $\lambda$ is the observer's east longitude in radians.
- **Hour angle**: $h = \text{LST} - \alpha$, where $\alpha$ is the right ascension of date from stage 5.
The GMST formula:
$$
\text{GMST}_\text{sec} = 67310.54841 + (876600 \times 3600 + 8640184.812866) \cdot T + 0.093104 \cdot T^2 - 6.2 \times 10^{-6} \cdot T^3
$$
where $T = (JD - 2451545.0) / 36525.0$. The result in seconds is converted to radians: $\text{GMST}_\text{rad} = \text{GMST}_\text{sec} \times \pi / 43200$.
**Code**: `src/sidereal_time.c:gmst_from_jd()`
7. **Equatorial to horizontal**
Given hour angle $h$, declination $\delta$, and observer latitude $\phi$, compute azimuth $A$ and elevation $a$:
$$
\sin a = \sin\phi \sin\delta + \cos\phi \cos\delta \cos h
$$
$$
\tan A = \frac{-\sin h}{\cos\phi \tan\delta - \sin\phi \cos h}
$$
Azimuth is measured from north through east (0 = north, 90 = east, 180 = south, 270 = west).
The result is packed into a `pg_topocentric` struct with range computed from the geocentric distance ($\text{range}_\text{km} = d_\text{AU} \times 149597870.7$). Range rate is set to 0.0 for planetary observations (velocity computation is not yet implemented for the VSOP87 pipeline).
**Code**: `src/astro_math.h:observe_from_geocentric()` (shared by `planet_funcs.c`, `moon_funcs.c`, and `de_funcs.c`)
</Steps>
## Pipeline variants
The seven-stage pipeline applies to planets observed via VSOP87. Other observation targets use modified versions.
<Tabs>
<TabItem label="Moon (ELP2000-82B)">
Stages 1-3 are replaced by a single call to `GetElp82bCoor()`, which returns geocentric ecliptic coordinates directly. The remaining stages (4-7) are identical.
ELP2000-82B is a semi-analytical lunar theory with ~10 arcsecond accuracy. It accounts for the principal perturbations from the Sun, but not the full set of planetary perturbations included in the DE series.
</TabItem>
<TabItem label="Planetary moons">
Each moon theory (L1.2 for Galilean moons, TASS17 for Saturn, GUST86 for Uranus, MarsSat for Mars) computes the moon's position relative to its parent planet. The pipeline:
1. Compute parent planet heliocentric position via VSOP87
2. Compute moon position relative to parent in parent-equatorial frame
3. Transform to heliocentric ecliptic J2000 (parent offset + frame rotation)
4. Proceed from stage 3 of the standard pipeline (geocentric ecliptic)
</TabItem>
<TabItem label="DE ephemeris (v0.3.0)">
The `_de()` function variants replace stages 1-2 with JPL DE positions:
1. DE reader returns target position in ICRS equatorial frame
2. DE reader returns Earth position (derived from EMB - Moon/(1+EMRAT))
3. Both positions converted from ICRS equatorial to ecliptic J2000 via `equatorial_to_ecliptic()` at the provider boundary
4. Proceed from stage 3 of the standard pipeline (geocentric ecliptic)
Rule 7 of the constant chain of custody requires both target and Earth to come from the same provider. If either DE call fails, both fall back to VSOP87.
</TabItem>
<TabItem label="Satellites (SGP4)">
Satellites use a fundamentally different pipeline. SGP4 outputs TEME (True Equator, Mean Equinox) positions, not heliocentric ecliptic. The satellite pipeline:
1. SGP4/SDP4 propagation to TEME position/velocity (WGS-72)
2. GMST rotation to ECEF
3. ECEF to geodetic (WGS-84) or topocentric via SEZ transform
</TabItem>
<TabItem label="Stars">
Stars are effectively at infinite distance. The pipeline:
1. J2000 catalog coordinates (RA, Dec)
2. Precession to date (IAU 1976)
3. Sidereal time and hour angle
4. Equatorial to horizontal
No VSOP87 call, no geocentric vector, no distance computation. This makes star observation the fastest pipeline --- 714K observations per second.
</TabItem>
</Tabs>
## Why this pipeline and not another
Several simplifications are deliberate.
<Aside type="note" title="No nutation">
The pipeline uses precession but not nutation. For 1-arcsecond VSOP87 positions, the ~9-arcsecond nutation correction is below the noise floor of the ephemeris. Adding nutation would increase computation cost without improving practical accuracy.
</Aside>
**No aberration correction.** Annual aberration shifts apparent positions by up to 20 arcseconds, but for observation planning (which quadrant of the sky is Jupiter in tonight?) this is irrelevant. For sub-arcsecond planet positions, pg_orrery v0.3.0 supports [optional DE440/441 ephemeris files](/guides/de-ephemeris/); for apparent position corrections (aberration, light-time), use SPICE or Skyfield.
**No light-time iteration.** The positions returned are geometric, not apparent. Light-time corrections of a few minutes for the outer planets shift the apparent position by a fraction of an arcsecond at most --- again, below the VSOP87 accuracy floor.
**No atmospheric refraction.** Refraction near the horizon can shift apparent elevation by half a degree. pg_orrery reports geometric elevation; the user must apply refraction corrections for their local conditions if needed. This is a deliberate choice --- refraction depends on temperature, pressure, and humidity that pg_orrery does not model.
## Extending the pipeline
To add a new observation target, identify which stages change:
| New target | Stages replaced | Stages reused |
|-----------|-----------------|---------------|
| New moon theory | 1-3 (new theory + parent VSOP87) | 4-7 |
| DE ephemeris (v0.3.0) | 1-2 (DE positions for both target and Earth) | 3-7 |
| Near-Earth asteroid | 1 (Keplerian propagation) | 2-7 |
| Distant star | 1-3 (catalog lookup, no distance) | 5-7 (skip obliquity) |
The modularity of the pipeline means new targets require implementing only the first few stages. The coordinate transformation machinery from stage 4 onward is shared across all targets.

View File

@ -0,0 +1,217 @@
---
title: SGP4 Integration
sidebar:
order: 6
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
pg_orrery vendors Bill Gray's `sat_code` library (MIT license, Project Pluto) for SGP4/SDP4 propagation. The relevant source files are vendored into `src/sgp4/` with `.cpp` extensions renamed to `.c` --- the code contains zero C++ features and compiles as pure C99. This page covers why sat_code was chosen, how it integrates with PostgreSQL's build and execution model, and the error handling contract between the two codebases.
## Why sat_code
Three SGP4 implementations were evaluated. The choice came down to one question: which library can run inside a PostgreSQL backend without modification?
<Tabs>
<TabItem label="sat_code (chosen)">
**Pure C.** Despite upstream's `.cpp` file extensions, the code contains zero C++ features. pg_orrery vendors the files as `.c` and compiles them with `gcc`. The public API in `norad.h` is a flat C function interface: `SGP4_init()`, `SGP4()`, `SDP4_init()`, `SDP4()`, `parse_elements()`, `select_ephemeris()`.
**No global mutable state.** The propagator state lives in a caller-allocated `double params[N_SAT_PARAMS]` array. This maps directly to PostgreSQL's `palloc`-based memory model.
**Full SDP4.** Includes deep-space propagation with lunar/solar perturbations for GEO, Molniya, and GPS orbits.
**MIT license.** Compatible with the PostgreSQL License.
**Actively maintained.** Used in Bill Gray's Find_Orb production astrometry software.
</TabItem>
<TabItem label="Vallado reference (rejected)">
The canonical implementation from the STR#3 revision paper. Two problems:
1. Written in C++ with heavy use of global state. The propagator coefficients live in file-scope variables, making it impossible to declare functions `PARALLEL SAFE`.
2. License unclear for embedding in a PostgreSQL extension distributed as a shared library.
</TabItem>
<TabItem label="libsgp4 forks (rejected)">
Various GitHub forks, typically C++ class hierarchies assuming an object-per-satellite lifecycle. This conflicts with PostgreSQL's per-call execution model --- you cannot persist C++ objects across function invocations without managing their lifecycle in a memory context callback, adding complexity for no benefit.
</TabItem>
</Tabs>
## Compilation
sat_code's upstream files use `.cpp` extensions but contain no C++ features --- no classes, templates, namespaces, exceptions, or STL. The vendored copies in `src/sgp4/` are renamed to `.c` and compile with `gcc` alongside the rest of pg_orrery. There is no C/C++ boundary, no `g++`, and no `-lstdc++`.
```
src/*.c --[gcc]--> .o --|
src/sgp4/*.c --[gcc]--> .o --|--> pg_orrery.so
-lm
```
### Build rules
```makefile
# Vendored SGP4/SDP4 sources (pure C, from Bill Gray's sat_code, MIT license)
SGP4_DIR = src/sgp4
SGP4_SRCS = $(SGP4_DIR)/sgp4.c $(SGP4_DIR)/sdp4.c \
$(SGP4_DIR)/deep.c $(SGP4_DIR)/common.c \
$(SGP4_DIR)/basics.c $(SGP4_DIR)/get_el.c \
$(SGP4_DIR)/tle_out.c
SGP4_OBJS = $(SGP4_SRCS:.c=.o)
# Include vendored SGP4 headers for our C sources
PG_CPPFLAGS = -I$(SGP4_DIR)
# Pure C — no C++ runtime needed
SHLIB_LINK += -lm
```
PGXS handles the `-fPIC` flag and pattern rules for `.c` to `.o` compilation, so the vendored SGP4 files need no special build rules.
### Header inclusion
pg_orrery's C files include `norad.h` directly:
```c
#include "norad.h" /* vendored SGP4 public API */
#include "types.h" /* pg_orrery types and WGS-72/84 constants */
```
The `PG_CPPFLAGS = -I$(SGP4_DIR)` flag makes `norad.h` available without a path prefix.
## The SGP4 API surface
pg_orrery uses a small subset of sat_code's public functions.
### Initialization
```c
int select_ephemeris(const tle_t *tle);
```
Returns 0 for near-earth (SGP4) or 1 for deep-space (SDP4), based on the orbital period threshold of 225 minutes. Returns -1 if the mean motion or eccentricity is out of range --- an early indicator of an invalid TLE.
```c
void SGP4_init(double *params, const tle_t *tle);
void SDP4_init(double *params, const tle_t *tle);
```
Compute the propagator initialization coefficients and store them in the caller-allocated `params` array. This is the expensive step (~5x the cost of a single propagation), so pg_orrery performs it once per TLE and reuses the `params` array for SRF functions that propagate the same TLE to multiple times.
### Propagation
```c
int SGP4(double tsince, const tle_t *tle, const double *params,
double *pos, double *vel);
int SDP4(double tsince, const tle_t *tle, const double *params,
double *pos, double *vel);
```
Propagate to `tsince` minutes from epoch. Write position (km) and velocity (km/min) into caller-provided arrays. Return 0 on success or a negative error code.
<Aside type="note" title="Velocity units">
sat_code outputs velocity in km/min. pg_orrery converts to km/s at the boundary --- exactly once, in `sgp4_funcs.c` when populating the `pg_eci` struct. The conversion is `vel[i] / 60.0`. All downstream pg_orrery functions work in km/s.
</Aside>
### TLE parsing
```c
int parse_elements(const char *line1, const char *line2, tle_t *tle);
```
Parse two-line element text into a `tle_t` struct. Returns 0 on success. pg_orrery calls this in `tle_in()` to validate input at storage time.
```c
void write_elements_in_tle_format(char *obuff, const tle_t *tle);
```
Reconstruct text from parsed elements. Used in `tle_out()` for display.
## TLE struct conversion
pg_orrery stores TLEs in its own `pg_tle` struct (112 bytes, designed for PostgreSQL tuple storage). sat_code uses `tle_t` (a larger struct with additional fields for its own purposes). The conversion between them is a field-by-field copy with no unit conversion --- both use radians, radians/minute, and Julian dates.
```c
static void
pg_tle_to_sat_code(const pg_tle *src, tle_t *dst)
{
memset(dst, 0, sizeof(tle_t));
dst->epoch = src->epoch;
dst->xincl = src->inclination;
dst->xnodeo = src->raan;
dst->eo = src->eccentricity;
dst->omegao = src->arg_perigee;
dst->xmo = src->mean_anomaly;
dst->xno = src->mean_motion;
dst->xndt2o = src->mean_motion_dot;
dst->xndd6o = src->mean_motion_ddot;
dst->bstar = src->bstar;
/* ... identification fields ... */
}
```
This conversion is duplicated in `sgp4_funcs.c`, `coord_funcs.c`, and `pass_funcs.c`. Each file contains its own static copy. The duplication is intentional:
1. Each translation unit is self-contained --- no hidden coupling through shared internal functions.
2. The functions are small (under 20 lines). Binary size increase is negligible.
3. The compiler can inline them within each translation unit.
4. If the helpers ever need to diverge (e.g., `pass_funcs.c` working in km/min while `coord_funcs.c` works in km/s), they can do so independently.
## Error codes
sat_code returns integer error codes from `SGP4()` and `SDP4()`. pg_orrery classifies them by physical meaning and responds accordingly.
| Code | sat_code constant | Physical meaning | pg_orrery response |
|------|-------------------|------------------|-------------------|
| 0 | --- | Normal propagation | Return result |
| -1 | `SXPX_ERR_NEARLY_PARABOLIC` | Eccentricity $\geq 1$ | `ereport(ERROR)` |
| -2 | `SXPX_ERR_NEGATIVE_MAJOR_AXIS` | Orbit has decayed | `ereport(ERROR)` |
| -3 | `SXPX_WARN_ORBIT_WITHIN_EARTH` | Entire orbit below surface | `ereport(NOTICE)`, return result |
| -4 | `SXPX_WARN_PERIGEE_WITHIN_EARTH` | Perigee below surface | `ereport(NOTICE)`, return result |
| -5 | `SXPX_ERR_NEGATIVE_XN` | Negative mean motion | `ereport(ERROR)` |
| -6 | `SXPX_ERR_CONVERGENCE_FAIL` | Kepler equation diverged | `ereport(ERROR)` |
### The warning/error distinction
Codes -3 and -4 are warnings, not errors. A satellite with perigee within Earth is plausible during reentry or shortly after launch --- the state vector is still mathematically valid. The `NOTICE` tells the user the situation is unusual; the result is still returned.
Codes -1, -2, -5, and -6 indicate the propagator model has broken down. The output position would be meaningless. These raise `ereport(ERROR)`, which aborts the current query.
### Context-dependent handling
The error response changes based on the calling context:
| Context | Fatal error (-1, -2, -5, -6) | Warning (-3, -4) |
|---------|------------------------------|-------------------|
| Direct propagation (`sgp4_propagate`) | `ereport(ERROR)` --- abort query | `ereport(NOTICE)` --- return result |
| Safe propagation (`sgp4_propagate_safe`) | Return `NULL` | `ereport(NOTICE)` --- return result |
| Pass prediction (`elevation_at_jd`) | Return $-\pi$ elevation --- continue scan | Ignore --- return elevation |
| SRF series (`sgp4_propagate_series`) | `ereport(ERROR)` --- abort series | `ereport(NOTICE)` --- return result |
The pass prediction context is the most interesting. A TLE valid for part of a search window should not abort the entire pass search. Returning $-\pi$ radians (well below any physical horizon) causes the coarse scan to treat the time point as "satellite below horizon" and continue looking for passes at other times.
## Build integration
sat_code is vendored into `src/sgp4/` --- the minimal set of source files needed for SGP4/SDP4 propagation, committed directly into the pg_orrery repository. A `PROVENANCE.md` file in that directory records the upstream repository, the exact commit hash, and every modification made during vendoring.
This approach provides:
- **Pinned version.** The vendored commit is recorded in `src/sgp4/PROVENANCE.md`. Upstream changes do not affect pg_orrery until the files are explicitly re-vendored.
- **Clear provenance.** `PROVENANCE.md` documents the upstream repository (github.com/Bill-Gray/sat_code), commit hash, the `.cpp` to `.c` rename rationale, and a line-by-line list of every modification.
- **No submodule complexity.** Cloning the repository gets a complete, buildable tree. No `git submodule update --init` step, no risk of missing submodule state.
- **Pure C build.** Renaming `.cpp` to `.c` eliminates the `g++` and `-lstdc++` dependencies. The entire extension compiles with a single C compiler.
### Vendored files
| File | Purpose |
|------|---------|
| `sgp4.c` | SGP4 near-earth propagator |
| `sdp4.c` | SDP4 deep-space propagator |
| `deep.c` | Lunar/solar perturbation routines for SDP4 |
| `common.c` | Shared initialization code for SGP4/SDP4 |
| `basics.c` | Utility functions (angle normalization, etc.) |
| `get_el.c` | TLE parsing (`parse_elements()`) |
| `tle_out.c` | TLE text reconstruction |
| `norad.h` | Public API declarations, `tle_t` struct, constants |
| `norad_in.h` | Internal constants (WGS-72 values) |
| `PROVENANCE.md` | Upstream commit, modifications, verification notes |
| `LICENSE` | MIT license from upstream |
Other sat_code files (obs_eph.cpp, sat_id.cpp, etc.) are not vendored. pg_orrery uses sat_code strictly as a propagation library, not as a satellite identification or observation planning tool.

View File

@ -0,0 +1,168 @@
---
title: Theory-to-Code Mapping
sidebar:
order: 4
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Every equation in pg_orrery traces to a published, peer-reviewed source. This page provides the complete mapping between the celestial mechanics literature and the source files that implement each theory.
If a constant, algorithm, or formula appears in the code without a citation, that is a defect to be corrected.
## SGP4/SDP4 propagation
The core satellite propagation theory, implemented by Bill Gray's sat_code library (vendored into src/sgp4/).
| Theory | Source Paper | What it computes | Code location |
|--------|-------------|------------------|---------------|
| Mean element recovery | Brouwer (1959) | Original mean motion $n_0'$ and semi-major axis $a_0'$ from input TLE, removing secular $J_2$ perturbations | `src/sgp4/common.c:sxpall_common_init()` |
| Secular perturbations | Lane & Cranford (1969); Hoots & Roehrich STR#3 | Secular rates of $M$, $\omega$, and $\Omega$ due to $J_2$, $J_4$ | `src/sgp4/common.c:sxpx_common_init()` |
| Atmospheric drag | Hoots & Roehrich STR#3 | $B^*$ formulation of drag; $C_1$, $C_2$, $C_4$ coefficients; perigee-dependent $s$ parameter | `src/sgp4/common.c:sxpx_common_init()`; `src/sgp4/sgp4.c:SGP4_init()` |
| Short-period perturbations | Lane & Cranford (1969); Brouwer (1959) | Oscillatory corrections to radius, argument of latitude, node, and inclination | `src/sgp4/common.c:sxpx_posn_vel()` |
| Kepler equation | Classical | Newton-Raphson with second-order correction, bounded first step | `src/sgp4/common.c:sxpx_posn_vel()` |
| Deep-space resonance | Hujsak (1979) | Lunar/solar gravitational perturbations; geopotential resonance for 12-hour and 24-hour orbits | `src/sgp4/deep.c:Deep_dpinit()`, `Deep_dpsec()`, `Deep_dpper()` |
| Near-earth propagation | Hoots & Roehrich STR#3 | SGP4 main loop: secular + short-period + drag terms | `src/sgp4/sgp4.c:SGP4()` |
| Deep-space propagation | Hoots & Roehrich STR#3 | SDP4: SGP4 core + deep-space secular/periodic corrections | `src/sgp4/sdp4.c:SDP4()` |
| Near/deep selection | Hoots & Roehrich STR#3 | Period threshold: 225 minutes ($n < 2\pi/225$ rad/min) | `src/sgp4/norad.h:select_ephemeris()` |
### Primary reference
Hoots, F. R. & Roehrich, R. L. (1980). "Models for Propagation of NORAD Element Sets." Spacetrack Report No. 3, Aerospace Defense Command, Peterson AFB.
This is the canonical SGP4/SDP4 reference. All subsequent implementations, including Vallado's 2006 revision, trace back to this report.
## Coordinate transforms
| Theory | Source | What it computes | Code location |
|--------|--------|------------------|---------------|
| GMST | Vallado (2013) Eq. 3-47; IAU 1982 | Greenwich Mean Sidereal Time from Julian date | `src/sidereal_time.c:gmst_from_jd()` |
| TEME to ECEF | Vallado (2013) | Z-axis rotation by $-\text{GMST}$; velocity cross-product correction | `src/coord_funcs.c:teme_to_ecef()` |
| Geodetic from ECEF | Bowring (1976) | Iterative latitude from ECEF Cartesian on WGS-84 | `src/coord_funcs.c:ecef_to_geodetic()` |
| Topocentric transform | Standard SEZ | ECEF range vector rotated to South-East-Zenith; azimuth from north | `src/coord_funcs.c:ecef_to_topocentric()` |
| Observer to ECEF | Geodesy standard | WGS-84 ellipsoid surface point to Cartesian | `src/coord_funcs.c:observer_to_ecef()` |
| Range rate | Dot product | Projection of relative velocity onto line-of-sight unit vector | `src/coord_funcs.c:eci_to_topocentric()` |
| Semi-major axis from $n$ | Kepler's third law | $a = (k_e / n)^{2/3}$ in earth radii | `src/tle_type.c:tle_perigee()` |
| IAU 1976 precession | Lieske et al. (1977) | Three Euler angles $\zeta_A$, $z_A$, $\theta_A$ for precession from J2000 to date | `src/precession.c:precess_j2000_to_date()` |
| Ecliptic to equatorial | IAU | X-axis rotation by obliquity $\varepsilon_0 = 23.4392911\degree$ | `src/astro_math.h:ecliptic_to_equatorial()` |
| Equatorial to ecliptic | IAU | Inverse obliquity rotation (for DE ICRS→ecliptic) | `src/astro_math.h:equatorial_to_ecliptic()` |
| Observation pipeline | Standard | Geocentric ecliptic → equatorial → precess → topocentric | `src/astro_math.h:observe_from_geocentric()` |
### Primary references
- Vallado, D. A. (2013). *Fundamentals of Astrodynamics and Applications*, 4th ed. Microcosm Press.
- Lieske, J. H. et al. (1977). "Expressions for the Precession Quantities Based upon the IAU (1976) System of Astronomical Constants." *Astronomy & Astrophysics*, 58, 1-16.
- Bowring, B. R. (1976). "Transformation from Spatial to Geographical Coordinates." *Survey Review*, 23, 323-327.
## Planetary ephemerides
| Theory | Source | Bodies | Accuracy | Code location |
|--------|--------|--------|----------|---------------|
| VSOP87 | Bretagnon & Francou (1988) | Mercury through Neptune | ~1 arcsecond | `src/vsop87.c` |
| ELP2000-82B | Chapront-Touze & Chapront (1988) | Moon | ~10 arcseconds | `src/elp82b.c` |
| JPL DE440/441 (optional) | JPL (2021) | All bodies + Moon | ~0.1 milliarcsecond | `src/de_reader.c`, `src/eph_provider.c` |
### VSOP87
Bretagnon, P. & Francou, G. (1988). "Planetary Theories in Rectangular and Spherical Variables. VSOP87 Solutions." *Astronomy & Astrophysics*, 202, 309-315.
pg_orrery uses the VSOP87 rectangular ecliptic J2000 variant. The truncated coefficient tables provide full accuracy within the validity range of the theory (roughly 4000 BCE to 8000 CE for the inner planets, with degradation for the outer planets beyond $\pm$2000 years from J2000).
### ELP2000-82B
Chapront-Touze, M. & Chapront, J. (1988). "ELP 2000-85: A Semi-Analytical Lunar Ephemeris Adequate for Historical Times." *Astronomy & Astrophysics*, 190, 342-352.
The 82B revision is the version implemented. It provides geocentric ecliptic coordinates for the Moon, accounting for the principal perturbations from the Sun but not the complete set of planetary perturbations available in modern lunar ephemerides like DE421.
## Planetary moon theories
| Theory | Source | Moons | Accuracy | Code location |
|--------|--------|-------|----------|---------------|
| L1.2 | Lieske (1998) | Io, Europa, Ganymede, Callisto | ~1 arcsecond | `src/l12.c` |
| TASS17 | Vienne & Duriez (1995) | Mimas through Iapetus | ~1-5 arcseconds | `src/tass17.c` |
| GUST86 | Laskar & Jacobson (1987) | Miranda through Oberon | ~5-10 arcseconds | `src/gust86.c` |
| MarsSat | Jacobson (2010) | Phobos, Deimos | ~1 arcsecond | `src/marssat.c` |
### References
- Lieske, J. H. (1998). "Galilean Satellites of Jupiter." *Astronomy & Astrophysics Supplement Series*, 129, 205-217.
- Vienne, A. & Duriez, L. (1995). "TASS1.7: An Analytical Theory of the Motion of the Main Satellites of Saturn." *Astronomy & Astrophysics*, 297, 588-605.
- Laskar, J. & Jacobson, R. A. (1987). "GUST86: An Analytical Ephemeris of the Uranian Satellites." *Astronomy & Astrophysics*, 188, 212-224.
- Jacobson, R. A. (2010). "The Orbits and Masses of the Martian Satellites and the Libration of Phobos." *Astronomical Journal*, 139, 668-679.
## Transfer orbits
| Theory | Source | What it computes | Code location |
|--------|--------|------------------|---------------|
| Lambert solver | Izzo (2015) | Transfer velocity vectors given two positions and time of flight | `src/lambert.c` |
| Keplerian propagation | Classical | Two-body elliptic/hyperbolic orbit from elements | `src/elliptic_to_rectangular.c` |
### References
- Izzo, D. (2015). "Revisiting Lambert's Problem." *Celestial Mechanics and Dynamical Astronomy*, 121, 1-15.
The Izzo solver uses Householder iterations for fast convergence and handles both short-way and long-way transfers. pg_orrery uses the prograde (short-way) solution by default.
## Radio emission
| Theory | Source | What it computes | Code location |
|--------|--------|------------------|---------------|
| Carr source regions | Carr et al. (1983) | Jupiter-Io decametric burst probability from CML and Io phase | `src/radio_funcs.c` |
| CML computation | Standard | Jupiter System III Central Meridian Longitude | `src/radio_funcs.c` |
### Reference
Carr, T. D. et al. (1983). "Phenomenology of Magnetospheric Radio Emissions." *Physics of the Jovian Magnetosphere*, Cambridge University Press, 226-284.
## Vallado reference vectors
The Vallado 518 test vectors are the definitive verification dataset for SGP4 implementations. Each row specifies a NORAD ID, minutes since epoch, and expected position/velocity in TEME.
<Aside type="tip" title="Verification standard">
All 518 vectors must match to machine epsilon ($\sim 10^{-8}$ km position, $\sim 10^{-11}$ km/s velocity) before any other development proceeds. The test file lives at `test/data/vallado_518.csv`.
</Aside>
Sample from the verification suite:
```
# NORAD 00005 (Vanguard 1) - LEO, low eccentricity
# Minutes: 0.00 Expected X: 7022.465290 Y: -1400.082967 Z: 0.039550
# Minutes: 360.00 Expected X: -7154.031380 Y: -3783.176825 Z: -2073.655980
# NORAD 29238 (GPS BIIR-11) - MEO, near-circular
# Minutes: 0.00 Expected X: -22503.132440 Y: 14513.963880 Z: 180.989390
# NORAD 28350 (Galaxy 15) - GEO, deep-space SDP4
# Minutes: 0.00 Expected X: -33110.816260 Y: 26044.993650 Z: -20.725400
```
These vectors cover the full range of orbit types that pg_orrery handles: LEO (SGP4), MEO (SGP4), GEO (SDP4), high-eccentricity Molniya (SDP4), and deep-space GPS (SDP4). Any implementation that matches all 518 vectors is functionally equivalent to the Vallado reference.
## Source file index
A quick reference for finding the implementation of a specific theory.
| Source file | Theory/Function | Lines (approx) |
|-------------|----------------|-----------------|
| `src/vsop87.c` | VSOP87 planet positions | ~3000 (coefficient tables) |
| `src/elp82b.c` | ELP2000-82B Moon position | ~2000 (coefficient tables) |
| `src/l12.c` | L1.2 Galilean moons | ~800 |
| `src/tass17.c` | TASS17 Saturn moons | ~1200 |
| `src/gust86.c` | GUST86 Uranus moons | ~600 |
| `src/marssat.c` | MarsSat Mars moons | ~400 |
| `src/precession.c` | IAU 1976 precession | ~60 |
| `src/sidereal_time.c` | GMST computation | ~40 |
| `src/lambert.c` | Izzo Lambert solver | ~300 |
| `src/coord_funcs.c` | Coordinate transforms | ~650 |
| `src/pass_funcs.c` | Pass prediction algorithm | ~550 |
| `src/gist_tle.c` | GiST altitude-band index | ~400 |
| `src/planet_funcs.c` | VSOP87 observation pipeline | ~200 |
| `src/de_reader.c` | Clean-room JPL DE binary reader (Chebyshev/Clenshaw) | ~400 |
| `src/eph_provider.c` | DE provider dispatch, GUC, lazy init, frame rotation | ~350 |
| `src/de_funcs.c` | All `_de()` SQL function implementations | ~650 |
| `src/astro_math.h` | Shared math: obliquity rotation, observation pipeline | ~220 |
| `src/radio_funcs.c` | Jupiter radio emission | ~200 |
| `src/sgp4/sgp4.c` | SGP4 near-earth propagator | ~300 |
| `src/sgp4/sdp4.c` | SDP4 deep-space propagator | ~200 |
| `src/sgp4/deep.c` | Deep-space perturbations | ~800 |
| `src/sgp4/common.c` | Shared SGP4/SDP4 initialization | ~250 |

View File

@ -0,0 +1,120 @@
---
title: Installation
sidebar:
order: 2
---
import { Tabs, TabItem, Steps, Aside } from "@astrojs/starlight/components";
<Tabs>
<TabItem label="Docker (recommended)">
The fastest way to get pg_orrery running. The Docker image ships PostgreSQL 17 with pg_orrery pre-compiled.
<Steps>
1. Pull the image:
```bash
docker pull git.supported.systems/warehack.ing/pg_orrery:pg17
```
2. Start the container:
```bash
docker run -d --name pg_orrery \
-e POSTGRES_PASSWORD=orbit \
-p 5499:5432 \
git.supported.systems/warehack.ing/pg_orrery:pg17
```
3. Connect and enable the extension:
```bash
psql -h localhost -p 5499 -U postgres -c "CREATE EXTENSION pg_orrery;"
```
</Steps>
<Aside type="tip">
Port 5499 avoids conflicts with any existing PostgreSQL on 5432. Adjust as needed.
</Aside>
</TabItem>
<TabItem label="Build from Source">
Requires PostgreSQL 17 development headers and a C toolchain.
<Steps>
1. Clone the repository:
```bash
git clone https://git.supported.systems/warehack.ing/pg_orrery.git
cd pg_orrery
git submodule update --init
```
2. Build and install:
```bash
make PG_CONFIG=/usr/bin/pg_config
sudo make install PG_CONFIG=/usr/bin/pg_config
```
3. Enable in your database:
```sql
CREATE EXTENSION pg_orrery;
```
4. Verify installation:
```sql
SELECT planet_observe(5, '40.0N 105.3W 1655m'::observer, now());
```
</Steps>
<Aside type="note">
The build compiles pure C throughout --- both pg_orrery and the vendored SGP4/SDP4 library in `src/sgp4/`. No C++ compiler or runtime is required.
</Aside>
</TabItem>
<TabItem label="Docker Compose">
For integration with existing PostgreSQL-based applications.
```yaml
services:
db:
image: git.supported.systems/warehack.ing/pg_orrery:pg17
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-orbit}
POSTGRES_DB: ${POSTGRES_DB:-orbit}
ports:
- "${PGPORT:-5499}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
```
Then:
```bash
docker compose up -d
psql -h localhost -p 5499 -U postgres -d orbit -c "CREATE EXTENSION pg_orrery;"
```
</TabItem>
</Tabs>
## Running the test suite
If building from source, the regression tests verify all 68 functions across 12 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.
## Upgrading
If you have a previous version installed, upgrade in place:
```sql
-- From v0.1.0 (satellite-only) to v0.2.0 (solar system)
ALTER EXTENSION pg_orrery UPDATE TO '0.2.0';
-- From v0.2.0 to v0.3.0 (DE ephemeris support)
ALTER EXTENSION pg_orrery UPDATE TO '0.3.0';
```
Each migration adds new functions while preserving existing data and functions.

View File

@ -0,0 +1,116 @@
---
title: Quick Start
sidebar:
order: 3
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Five queries that show what pg_orrery can do. Each builds on the previous — from a single planet observation to planning an interplanetary trajectory.
<Aside type="tip">
All examples assume you have pg_orrery installed and `CREATE EXTENSION pg_orrery;` has been run. See [Installation](/getting-started/installation/) if you need to set that up first.
</Aside>
<Steps>
1. **Where is Jupiter right now?**
The `observer` type takes geodetic coordinates as a compact string. This observer is in Boulder, Colorado at 1655m elevation.
```sql
SELECT topo_azimuth(t) AS az,
topo_elevation(t) AS el,
topo_range(t) / 149597870.7 AS distance_au
FROM planet_observe(5, '40.0N 105.3W 1655m'::observer, now()) t;
```
Body ID 5 is Jupiter (the VSOP87 convention: 1=Mercury through 8=Neptune). The result gives azimuth and elevation in degrees, plus range in AU.
2. **What's the entire solar system doing?**
Use `generate_series` to loop over all 8 planets and compute their heliocentric positions:
```sql
SELECT body_id,
CASE body_id
WHEN 1 THEN 'Mercury' WHEN 2 THEN 'Venus'
WHEN 3 THEN 'Earth' WHEN 4 THEN 'Mars'
WHEN 5 THEN 'Jupiter' WHEN 6 THEN 'Saturn'
WHEN 7 THEN 'Uranus' WHEN 8 THEN 'Neptune'
END AS name,
round(helio_distance(planet_heliocentric(body_id, now()))::numeric, 4) AS distance_au
FROM generate_series(1, 8) AS body_id;
```
One query, eight planets, heliocentric distances in AU. No loops, no external libraries.
3. **Predict ISS passes over your location**
First, define a TLE and observer. Then predict all passes in the next 24 hours above 10 degrees elevation:
```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 pass_aos(p) AS rise_time,
pass_max_el(p) AS max_elevation,
pass_los(p) AS set_time
FROM iss, predict_passes(tle, '40.0N 105.3W 1655m'::observer,
now(), now() + interval '24 hours', 10.0) p;
```
The `10.0` parameter is the minimum elevation filter in degrees. `predict_passes` returns a set of `pass_event` records with AOS, TCA, and LOS times plus azimuth data.
4. **When will Jupiter produce radio bursts tonight?**
Jupiter emits powerful decametric radio bursts when Io is in certain orbital positions relative to Jupiter's Central Meridian Longitude. Predict the best windows:
```sql
SELECT t,
round(io_phase_angle(t)::numeric, 1) AS io_phase,
round(jupiter_cml('40.0N 105.3W 1655m'::observer, t)::numeric, 1) AS cml,
round(jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
)::numeric, 3) AS burst_prob
FROM generate_series(
now(),
now() + interval '12 hours',
interval '10 minutes'
) AS t
WHERE jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
) > 0.3;
```
This scans the next 12 hours in 10-minute steps and filters for windows where burst probability exceeds 30%. The underlying model uses the Carr et al. (1983) source regions A, B, C, and D.
5. **Plan an Earth-Mars transfer**
Use the Lambert solver to find the transfer orbit for a given departure and arrival date:
```sql
SELECT round(c3_departure::numeric, 2) AS c3_depart_km2s2,
round(c3_arrival::numeric, 2) AS c3_arrive_km2s2,
round(tof_days::numeric, 1) AS flight_days,
round(transfer_sma::numeric, 4) AS sma_au
FROM lambert_transfer(
3, 4, -- Earth to Mars
'2028-10-01'::timestamptz, -- departure
'2029-06-15'::timestamptz -- arrival
);
```
Body IDs 3 (Earth) and 4 (Mars). The result gives departure C3 (the energy you need to leave Earth), arrival C3, time of flight, and the transfer orbit's semi-major axis. For a full pork chop plot, wrap this in a `CROSS JOIN` of departure and arrival date ranges — see the [Interplanetary Trajectories](/guides/interplanetary-trajectories/) guide.
</Steps>
## Next steps
You've seen the five domains pg_orrery covers. For deeper dives:
- **[Tracking Satellites](/guides/tracking-satellites/)** — batch observation, conjunction screening, pass prediction workflows
- **[Observing the Solar System](/guides/observing-solar-system/)** — "what's up tonight?" queries, rise/set times, conjunctions
- **[JPL DE Ephemeris](/guides/de-ephemeris/)** — opt-in sub-milliarcsecond accuracy using JPL DE440/441 files
- **[The SQL Advantage](/workflow/sql-advantage/)** — why doing this in SQL changes what's possible

View File

@ -0,0 +1,64 @@
---
title: What is pg_orrery?
sidebar:
order: 1
---
import { Card, CardGrid, Aside } from "@astrojs/starlight/components";
An orrery is a clockwork model of the solar system — brass gears turning planets in their courses. pg_orrery is the same idea, built from Keplerian parameters and SQL instead of wheelwork. Where a mechanical orrery approximates orbits with gear ratios, a database orrery computes them from the six orbital elements that define each trajectory.
## The "PostGIS for space" analogy
PostGIS added spatial awareness to PostgreSQL — suddenly your database understood geometry, distance, and containment. pg_orrery does the same for celestial mechanics. Your database understands orbits, observation geometry, and the relationships between objects in the solar system. You can JOIN orbital computation results with any other table, filter with WHERE clauses, and let PostgreSQL's query planner parallelize the work.
## What it covers
| Domain | Theory | Key Functions | Accuracy |
|---|---|---|---|
| Satellites | SGP4/SDP4 (Brouwer, 1959) | `observe()`, `predict_passes()` | ~1 km (LEO, fresh TLE) |
| Planets | VSOP87 (Bretagnon, 1988) | `planet_observe()`, `planet_heliocentric()` | ~1 arcsecond |
| Sun | VSOP87 (Earth vector, inverted) | `sun_observe()` | ~1 arcsecond |
| 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 |
| 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 |
## Who it's for
<CardGrid>
<Card title="Satellite operators" icon="rocket">
You already have TLEs in PostgreSQL. Now your database can propagate them,
predict passes, and screen for conjunctions without leaving SQL. Batch
12,000 observations in 17ms.
</Card>
<Card title="Amateur astronomers & radio operators" icon="star">
Plan observation sessions entirely in SQL. "What planets are above 20
degrees tonight?" is a single query. Jupiter radio burst prediction
replaces the Windows-only Radio Jupiter Pro.
</Card>
<Card title="Mission planning enthusiasts" icon="right-caret">
Generate pork chop plots for interplanetary transfers as SQL CROSS JOINs.
The Lambert solver handles 800,000 solutions per second. Compare transfer
energies across launch windows without writing a line of Python.
</Card>
</CardGrid>
## What pg_orrery is NOT
<Aside type="caution" title="Honest limitations">
pg_orrery is a computation engine, not a complete application. Understanding what it doesn't do is as important as knowing what it does.
</Aside>
**Not a GUI.** pg_orrery returns numbers. Use Stellarium, GPredict, or STK for visualization. Use any plotting library to render its output.
**Not sub-arcsecond by default.** The built-in VSOP87 pipeline is accurate to about 1 arcsecond — sufficient for observation planning and visual astronomy. For precision work (dish pointing, occultation timing, astrometry), pg_orrery v0.3.0 supports [optional JPL DE440/441 ephemeris files](/guides/de-ephemeris/) that bring accuracy to ~0.1 milliarcsecond. DE is opt-in and requires a one-time GUC configuration.
**Not a TLE source.** Bring your own TLEs from Space-Track, CelesTrak, or any other provider. pg_orrery parses and propagates them; it doesn't fetch them.
**Not a replacement for SPICE.** No BSP kernel support, no light-time iteration, no aberration corrections at the IAU 2000A level. With DE enabled, pg_orrery matches SPICE on raw planet position accuracy — the remaining gap is in apparent-position corrections (aberration, light-time, nutation) that matter for sub-arcsecond apparent coordinates.
**Not a full mission design tool.** The Lambert solver handles ballistic two-body transfers — no low-thrust trajectories, no gravity assists, no multi-body optimization. For full mission design, use GMAT or poliastro.

View File

@ -0,0 +1,269 @@
---
title: Comets and Asteroids
sidebar:
order: 5
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
pg_orrery propagates comets and asteroids using two-body Keplerian mechanics. You provide six classical orbital elements from the Minor Planet Center (MPC) or any other source, and pg_orrery computes the body's heliocentric position at any time. Combined with Earth's position from VSOP87, you can observe the body from any location on Earth.
## How you do it today
Tracking comets and asteroids typically involves:
- **JPL Small-Body Database (SBDB)**: Look up an object, get its orbital elements, request an ephemeris. One object at a time, web-based.
- **Find_Orb**: Fit orbits from observations and propagate forward. Powerful but desktop-only, primarily for orbit determination rather than ephemeris computation.
- **Skyfield**: Can propagate comets from MPC elements, but you need to load a planetary ephemeris for the Earth's position and write the observation pipeline yourself.
- **Minor Planet Center (MPC)**: Publishes orbital elements for over 1.3 million objects. Getting batch ephemerides means downloading elements and running them through your own propagation code.
The pattern is familiar: download elements, propagate in Python or C, transform to observer coordinates, and import results into your database.
## What changes with pg_orrery
Two 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 |
`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.
The parameters map directly to MPC orbital element format:
| Parameter | MPC field | Units |
|---|---|---|
| `q` | Perihelion distance | AU |
| `e` | Eccentricity | dimensionless |
| `i` | Inclination | degrees |
| `omega` | Argument of perihelion | degrees |
| `Omega` | Longitude of ascending node | degrees |
| `T` | Perihelion time | Julian date |
## What pg_orrery does not replace
<Aside type="caution" title="Two-body limitations">
Keplerian propagation assumes the body is influenced only by the Sun. Real small bodies experience planetary perturbations that accumulate over time.
</Aside>
- **No perturbations.** Jupiter alone can shift a comet's position by degrees over a few years. Two-body propagation is most accurate near perihelion, within a few months of the elements' epoch.
- **No non-gravitational forces.** Comet outgassing produces accelerations not captured by Keplerian mechanics. For long-period comets far from the Sun, this is negligible. For short-period comets near perihelion, it matters.
- **No magnitude estimation.** pg_orrery returns position only. Comet brightness depends on heliocentric distance, geocentric distance, and a magnitude slope parameter that varies per comet.
- **No orbit determination.** pg_orrery propagates known orbits. It does not fit orbits from observations.
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.
## Try it
### Circular orbit sanity check
A body in a circular orbit at 1 AU with all angles zero should return to its starting position after one year:
```sql
-- At perihelion (T=0), position should be (1, 0, 0) AU
SELECT round(helio_x(kepler_propagate(
1.0, 0.0, 0.0, 0.0, 0.0,
2451545.0, -- J2000.0
'2000-01-01 12:00:00+00'))::numeric, 6) AS x,
round(helio_y(kepler_propagate(
1.0, 0.0, 0.0, 0.0, 0.0,
2451545.0,
'2000-01-01 12:00:00+00'))::numeric, 6) AS y;
```
At time = perihelion, the position is exactly (q, 0, 0) in the orbital plane. After a quarter orbit (~91 days), it moves to approximately (0, 1, 0).
### Eccentric elliptic orbit
An orbit with e=0.5 and q=0.5 AU has a semi-major axis of 1.0 AU and the same period as Earth, but a very different shape:
```sql
-- Position over one orbit
SELECT t::date AS date,
round(helio_distance(kepler_propagate(
0.5, 0.5, 0.0, 0.0, 0.0,
2451545.0, t))::numeric, 4) AS dist_au
FROM generate_series(
'2000-01-01 12:00:00+00'::timestamptz,
'2001-01-01 12:00:00+00'::timestamptz,
interval '30 days'
) AS t;
```
The distance ranges from 0.5 AU (perihelion) to 1.5 AU (aphelion). This is the classic comet behavior: fast and close to the Sun at perihelion, slow and distant at aphelion.
### Inclined orbit
Orbital inclination rotates the orbital plane out of the ecliptic:
```sql
-- A polar orbit (i=90 deg) at 1 AU
SELECT round(helio_x(kepler_propagate(
1.0, 0.0, 90.0, 0.0, 0.0,
2451545.0,
'2000-01-01 12:00:00+00'))::numeric, 6) AS x,
round(helio_z(kepler_propagate(
1.0, 0.0, 90.0, 0.0, 0.0,
2451545.0,
'2000-01-01 12:00:00+00'))::numeric, 6) AS z;
```
At perihelion, the position is still along the node line (x-axis) regardless of inclination. The inclination only shows up when the body moves away from the node.
### Hyperbolic orbit
Interstellar objects like 'Oumuamua travel on hyperbolic trajectories (e > 1):
```sql
-- Hyperbolic orbit: e=1.5, q=1.0 AU
-- At perihelion
SELECT round(helio_x(kepler_propagate(
1.0, 1.5, 0.0, 0.0, 0.0,
2451545.0,
'2000-01-01 12:00:00+00'))::numeric, 6) AS x_at_perihelion;
-- 6 months later: body is receding rapidly
SELECT round(helio_distance(kepler_propagate(
1.0, 1.5, 0.0, 0.0, 0.0,
2451545.0,
'2000-07-01 12:00:00+00'))::numeric, 2) AS dist_6mo;
```
The body approaches from infinity, swings past the Sun at perihelion distance, and departs on a hyperbola.
### Near-parabolic comet
Many long-period comets have eccentricities very close to 1.0. pg_orrery handles the parabolic case (e=1.0 exactly) with a dedicated Barker equation solver:
```sql
SELECT round(helio_x(kepler_propagate(
1.0, 1.0, 0.0, 0.0, 0.0,
2451545.0,
'2000-01-01 12:00:00+00'))::numeric, 6) AS x_parabolic;
```
### Track a comet with real MPC elements
Here is how you would observe a comet using elements from the Minor Planet Center. This example uses hypothetical elements for a Halley-type orbit:
<Steps>
1. **Get Earth's heliocentric position at the observation time:**
```sql
SELECT helio_x(planet_heliocentric(3, '2024-06-15 04:00:00+00')) AS ex,
helio_y(planet_heliocentric(3, '2024-06-15 04:00:00+00')) AS ey,
helio_z(planet_heliocentric(3, '2024-06-15 04:00:00+00')) AS ez;
```
2. **Observe the comet using all parameters together:**
```sql
WITH earth AS (
SELECT planet_heliocentric(3, '2024-06-15 04:00:00+00') AS pos
)
SELECT round(topo_azimuth(comet_observe(
0.587, 0.967, 162.3, 111.3, 58.4, 2446467.4,
helio_x(pos), helio_y(pos), helio_z(pos),
'40.0N 105.3W 1655m'::observer,
'2024-06-15 04:00:00+00'))::numeric, 1) AS az,
round(topo_elevation(comet_observe(
0.587, 0.967, 162.3, 111.3, 58.4, 2446467.4,
helio_x(pos), helio_y(pos), helio_z(pos),
'40.0N 105.3W 1655m'::observer,
'2024-06-15 04:00:00+00'))::numeric, 1) AS el,
round(topo_range(comet_observe(
0.587, 0.967, 162.3, 111.3, 58.4, 2446467.4,
helio_x(pos), helio_y(pos), helio_z(pos),
'40.0N 105.3W 1655m'::observer,
'2024-06-15 04:00:00+00'))::numeric, 0) AS range_km
FROM earth;
```
The orbital elements are: q=0.587 AU, e=0.967, i=162.3 deg, omega=111.3 deg, Omega=58.4 deg, T=JD 2446467.4.
</Steps>
### Store a comet catalog
For batch operations, store orbital elements in a table:
```sql
CREATE TABLE comets (
designation text PRIMARY KEY,
name text,
q_au float8 NOT NULL,
eccentricity float8 NOT NULL,
inclination_deg float8 NOT NULL,
arg_peri_deg float8 NOT NULL,
long_node_deg float8 NOT NULL,
perihelion_jd float8 NOT NULL,
epoch_jd float8
);
-- Insert a few examples
INSERT INTO comets VALUES
('1P', 'Halley', 0.587, 0.967, 162.3, 111.3, 58.4, 2446467.4, 2446480.0),
('2P', 'Encke', 0.336, 0.847, 11.8, 186.5, 334.6, 2460585.0, 2460600.0),
('67P', 'C-G', 1.243, 0.641, 7.0, 12.8, 50.1, 2457257.0, 2457260.0);
```
### Batch observe all comets
```sql
WITH earth AS (
SELECT planet_heliocentric(3, '2024-06-15 04:00:00+00') AS pos
)
SELECT c.name,
round(topo_azimuth(obs)::numeric, 1) AS az,
round(topo_elevation(obs)::numeric, 1) AS el,
round(topo_range(obs)::numeric, 0) AS range_km
FROM comets c, earth,
LATERAL comet_observe(
c.q_au, c.eccentricity, c.inclination_deg,
c.arg_peri_deg, c.long_node_deg, c.perihelion_jd,
helio_x(earth.pos), helio_y(earth.pos), helio_z(earth.pos),
'40.0N 105.3W 1655m'::observer,
'2024-06-15 04:00:00+00') obs
WHERE topo_elevation(obs) > 0
ORDER BY topo_elevation(obs) DESC;
```
This observes every comet in the catalog and filters to those above the horizon. The Earth position is computed once and reused for all comets.
### Heliocentric distance over time
Track how a comet's distance from the Sun changes through its orbit:
```sql
SELECT t::date AS date,
round(helio_distance(kepler_propagate(
0.336, 0.847, 11.8, 186.5, 334.6,
2460585.0, t))::numeric, 3) AS encke_dist_au
FROM generate_series(
'2024-01-01'::timestamptz,
'2024-12-31'::timestamptz,
interval '15 days'
) AS t;
```
Comet Encke (q=0.336 AU, e=0.847) ranges from 0.336 AU at perihelion to about 4.1 AU at aphelion. Its 3.3-year period means it passes through the inner solar system frequently.
### Verify: distance conservation for circular orbit
A useful sanity check. A circular orbit should maintain constant heliocentric distance:
```sql
SELECT t::date AS date,
round(helio_distance(kepler_propagate(
1.0, 0.0, 0.0, 0.0, 0.0,
2451545.0, t))::numeric, 6) AS dist_au
FROM generate_series(
'2000-01-01'::timestamptz,
'2001-01-01'::timestamptz,
interval '60 days'
) AS t;
```
Every row should read 1.000000 AU. If it does, the Kepler solver is working correctly.

View File

@ -0,0 +1,290 @@
---
title: Conjunction Screening
sidebar:
order: 8
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Conjunction screening identifies pairs of satellites that might approach each other closely enough to pose a collision risk. The brute-force approach -- computing pairwise distances for all objects in the catalog at every time step -- scales as O(n^2) and is impractical for large catalogs. pg_orrery solves this with a GiST index on the `tle` type that enables spatial filtering by altitude band and orbital inclination, reducing the candidate set before running full propagation.
## How you do it today
Operational conjunction screening uses several established tools and data sources:
- **STK/SOCRATES** (AGI): Commercial tool that monitors the catalog and generates close-approach reports. Industry standard for satellite operators. Expensive.
- **Space-Track CDMs**: The 18th Space Defense Squadron publishes Conjunction Data Messages (CDMs) for predicted close approaches. Free but requires registration and covers only US-tracked objects.
- **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.
## What changes with pg_orrery
pg_orrery attacks the problem in two stages:
**Stage 1: GiST index reduces candidates.** The GiST index on the `tle` column stores a 2-D key for each TLE: altitude band (perigee to apogee) and inclination range. The `&&` operator tests whether two TLEs occupy overlapping regions in this 2-D space. Only TLEs that share an altitude shell AND a similar inclination can possibly conjunct. This typically reduces 300 million pairs to a few thousand candidates.
**Stage 2: Full propagation verifies candidates.** For the remaining candidates, `tle_distance()` computes the actual Euclidean distance between two TLEs at a given time using full SGP4/SDP4 propagation. Step through time at the required resolution and filter to close approaches.
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 |
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).
## What pg_orrery does not replace
<Aside type="caution" title="Screening, not assessment">
GiST-based conjunction screening is a coarse filter. It finds candidates that share an orbital shell. It does not determine whether two objects will actually come close at a specific time.
</Aside>
- **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.
- **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.
## Try it
### Set up a test catalog
Create a small catalog with satellites at different orbital regimes:
```sql
CREATE TABLE catalog (
norad_id integer PRIMARY KEY,
name text NOT NULL,
tle tle NOT NULL
);
-- ISS (LEO, ~400km, inc 51.64 deg)
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');
-- Hubble (LEO, ~540km, inc 28.47 deg)
INSERT INTO catalog VALUES (20580, '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, inc 55.44 deg)
INSERT INTO catalog VALUES (28874, '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 but inc ~5 deg
INSERT INTO catalog VALUES (99901, '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');
```
### Create the GiST index
```sql
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.
### Check orbital parameters
Before screening, inspect the orbital characteristics of the catalog:
```sql
SELECT name,
round(tle_perigee(tle)::numeric, 0) AS perigee_km,
round(tle_apogee(tle)::numeric, 0) AS apogee_km,
round(tle_inclination(tle)::numeric, 1) AS inc_deg,
round(tle_period(tle)::numeric, 1) AS period_min
FROM catalog
ORDER BY tle_perigee(tle);
```
### Overlap queries with &&
Find all pairs of satellites in overlapping orbital shells:
```sql
SELECT a.name AS sat_a,
b.name AS sat_b,
a.tle && b.tle AS overlaps
FROM catalog a, catalog b
WHERE a.norad_id < b.norad_id
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 `<->`
The `<->` operator returns the minimum separation between altitude bands, in km:
```sql
SELECT a.name AS sat_a,
b.name AS sat_b,
round((a.tle <-> b.tle)::numeric, 0) AS alt_separation_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).
### GiST index scan: find overlapping orbits
Force the query planner to use the index and find all objects in the same shell as the ISS:
```sql
SET enable_seqscan = off;
SELECT name
FROM catalog
WHERE tle && (SELECT tle FROM catalog WHERE norad_id = 25544)
ORDER BY name;
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
Find the 3 closest objects to the ISS by altitude band separation, ordered by distance:
```sql
SET enable_seqscan = off;
SELECT name,
round((tle <-> (SELECT tle FROM catalog WHERE norad_id = 25544))::numeric, 0) AS alt_dist_km
FROM catalog
WHERE norad_id != 25544
ORDER BY tle <-> (SELECT tle FROM catalog WHERE norad_id = 25544)
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.
### Self-overlap is always true
Every TLE overlaps with itself:
```sql
SELECT name,
tle && tle AS self_overlap
FROM catalog
ORDER BY name;
```
All rows should return `true`.
### Full conjunction screening workflow
The complete two-stage workflow for a larger catalog:
<Steps>
1. **Build the catalog and index:**
```sql
-- Assuming your catalog table is already populated from CelesTrak or Space-Track
CREATE INDEX IF NOT EXISTS catalog_orbit_gist ON catalog USING gist (tle);
```
2. **Stage 1: GiST filter to find candidates for a target satellite:**
```sql
CREATE TEMPORARY TABLE candidates AS
SELECT c.norad_id, c.name, c.tle
FROM catalog c
WHERE c.tle && (SELECT tle FROM catalog WHERE norad_id = 25544)
AND c.norad_id != 25544;
```
For the ISS in a 25,000-object catalog, this typically returns a few hundred candidates.
3. **Stage 2: Time-resolved distance computation:**
```sql
WITH iss AS (
SELECT tle FROM catalog WHERE norad_id = 25544
)
SELECT c.name,
t AS check_time,
round(tle_distance(iss.tle, c.tle, t)::numeric, 1) AS dist_km
FROM candidates c, iss,
generate_series(
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
interval '1 minute'
) AS t
WHERE tle_distance(iss.tle, c.tle, t) < 25.0
ORDER BY dist_km;
```
This propagates each candidate pair at 1-minute resolution over 24 hours and filters to approaches within 25 km. Only the GiST candidates are checked, not the full catalog.
4. **Review results and take action.**
The output lists object name, time of closest approach, and distance. An analyst or automated system decides whether to issue a CDM, plan a maneuver, or accept the risk.
</Steps>
### Screening multiple target satellites
Extend the workflow to screen for conjunctions between any pair of objects in a subset:
```sql
-- All pairs in the LEO catalog (tle_perigee < 2000 km) that share an orbital shell
SELECT a.name AS sat_a,
b.name AS sat_b,
round((a.tle <-> b.tle)::numeric, 0) AS alt_sep_km,
round(tle_distance(a.tle, b.tle, '2024-01-01 12:00:00+00')::numeric, 0) AS actual_dist_km
FROM catalog a, catalog b
WHERE a.norad_id < b.norad_id
AND a.tle && b.tle
AND tle_perigee(a.tle) < 2000
AND tle_perigee(b.tle) < 2000
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.
</Aside>
### Monitoring over time
Run a conjunction check at regular intervals and store results for trend analysis:
```sql
CREATE TABLE conjunction_events (
id serial PRIMARY KEY,
sat_a integer NOT NULL,
sat_b integer NOT NULL,
event_time timestamptz NOT NULL,
dist_km float8 NOT NULL,
checked_at timestamptz DEFAULT now()
);
-- Periodic screening job (run daily or as needed)
INSERT INTO conjunction_events (sat_a, sat_b, event_time, dist_km)
WITH iss AS (
SELECT norad_id, tle FROM catalog WHERE norad_id = 25544
)
SELECT iss.norad_id, c.norad_id, t, tle_distance(iss.tle, c.tle, t)
FROM catalog c, iss,
generate_series(
now(),
now() + interval '7 days',
interval '5 minutes'
) AS t
WHERE c.tle && iss.tle
AND c.norad_id != iss.norad_id
AND tle_distance(iss.tle, c.tle, t) < 50.0;
```
This builds a history of close approaches that you can query, trend, and alert on. The GiST filter ensures it runs efficiently even against a full catalog.

View File

@ -0,0 +1,167 @@
---
title: JPL DE Ephemeris
sidebar:
order: 9
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
pg_orrery v0.3.0 adds optional support for JPL Development Ephemeris files (DE440/DE441), bringing positional accuracy from VSOP87's ~1 arcsecond to DE441's ~0.1 milliarcsecond. The upgrade path is designed so that nothing changes unless you opt in.
## When you need DE
VSOP87 is sufficient for most purposes --- visual observation planning, rise/set times, conjunction identification, telescope pointing at optical wavelengths. You need DE when:
- **GHz dish pointing.** A 10m dish at 10 GHz has a ~0.1 degree beamwidth. VSOP87's 1 arcsecond error is well within the beam, but systematic campaigns benefit from the tighter DE error budget.
- **Precision astrometry.** Comparing CCD field positions against computed planet positions at the sub-arcsecond level.
- **Occultation timing.** Predicting when a planet or asteroid will occult a star requires milliarcsecond precision in the planet's geocentric position.
- **Interplanetary mission design.** Lambert transfer C3 values are sensitive to planet position accuracy. DE441 eliminates VSOP87 as an error source.
<Aside type="tip" title="Zero-change default">
If you don't configure a DE file, all `_de()` functions silently fall back to VSOP87/ELP2000-82B. You get identical results to the non-DE variants, with no performance penalty. The extension ships with VSOP87 compiled in --- DE is strictly opt-in.
</Aside>
## Setup
<Steps>
1. **Obtain a DE file.** Download DE441 (~3.1 GB, covers -13200 to +17191) or DE440 (~115 MB, covers 1550 to 2650) from JPL:
```bash
# DE440 (smaller, covers 1550-2650, sufficient for most uses)
curl -O https://ssd.jpl.nasa.gov/ftp/eph/planets/Linux/de440/linux_p1550p2650.440
# DE441 (full range, large file)
curl -O https://ssd.jpl.nasa.gov/ftp/eph/planets/Linux/de441/linux_m13000p17000.441
```
2. **Place the file** where PostgreSQL can read it:
```bash
sudo mkdir -p /var/lib/postgres/pg_orrery
sudo cp linux_p1550p2650.440 /var/lib/postgres/pg_orrery/de440.bin
sudo chown postgres:postgres /var/lib/postgres/pg_orrery/de440.bin
```
3. **Set the GUC** (requires superuser):
```sql
ALTER SYSTEM SET pg_orrery.ephemeris_path = '/var/lib/postgres/pg_orrery/de440.bin';
SELECT pg_reload_conf();
```
4. **Verify:**
```sql
SELECT * FROM pg_orrery_ephemeris_info();
```
You should see `provider = 'JPL_DE'` with the file's JD range and version.
</Steps>
## Using DE functions
Every VSOP87 function has a `_de()` counterpart with the same signature:
<Tabs>
<TabItem label="VSOP87 (existing)">
```sql
-- Always VSOP87, always IMMUTABLE
SELECT topo_azimuth(planet_observe(4, '40.0N 105.3W 1655m'::observer, now())) AS mars_az;
```
</TabItem>
<TabItem label="DE (new)">
```sql
-- Uses DE if configured, falls back to VSOP87 otherwise. STABLE.
SELECT topo_azimuth(planet_observe_de(4, '40.0N 105.3W 1655m'::observer, now())) AS mars_az;
```
</TabItem>
</Tabs>
The `_de()` variants share the same parameter types, return types, and body ID conventions. You can swap between them by adding or removing `_de` from the function name.
### All planets with DE positions
```sql
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(topo_azimuth(planet_observe_de(body_id, '40.0N 105.3W 1655m'::observer, now()))::numeric, 4) AS az,
round(topo_elevation(planet_observe_de(body_id, '40.0N 105.3W 1655m'::observer, now()))::numeric, 4) AS el
FROM unnest(ARRAY[1,2,4,5,6,7,8]) AS body_id
WHERE topo_elevation(planet_observe_de(body_id, '40.0N 105.3W 1655m'::observer, now())) > 0
ORDER BY el DESC;
```
### Moon via DE
```sql
SELECT round(topo_azimuth(moon_observe_de('40.0N 105.3W 1655m'::observer, now()))::numeric, 2) AS az,
round(topo_elevation(moon_observe_de('40.0N 105.3W 1655m'::observer, now()))::numeric, 2) AS el,
round(topo_range(moon_observe_de('40.0N 105.3W 1655m'::observer, now()))::numeric, 0) AS range_km;
```
### Lambert transfers with DE positions
```sql
-- Earth-Mars transfer with DE-quality planet positions
SELECT * FROM lambert_transfer_de(3, 4, '2026-05-01 00:00:00+00', '2027-01-15 00:00:00+00');
```
### Cross-validation: DE vs VSOP87
Compare DE and VSOP87 results to see the sub-arcsecond difference:
```sql
SELECT body_id,
round(helio_distance(planet_heliocentric(body_id, '2024-06-21 12:00:00+00'))::numeric, 10) AS vsop87_r,
round(helio_distance(planet_heliocentric_de(body_id, '2024-06-21 12:00:00+00'))::numeric, 10) AS de_r,
round((helio_distance(planet_heliocentric_de(body_id, '2024-06-21 12:00:00+00'))
- helio_distance(planet_heliocentric(body_id, '2024-06-21 12:00:00+00')))::numeric * 149597870.7, 2) AS diff_km
FROM generate_series(1, 8) AS body_id;
```
## Accuracy comparison
| Provider | Theory | Accuracy | Volatility | Data Source |
|----------|--------|----------|------------|-------------|
| VSOP87 | Fourier series (Bretagnon 1988) | ~1 arcsecond (inner planets), up to 40 arcseconds (Neptune, +/- 4000 yr) | IMMUTABLE | Compiled-in coefficients |
| ELP2000-82B | Fourier series (Chapront 1983) | ~2 arcseconds (longitude) | IMMUTABLE | Compiled-in coefficients |
| JPL DE440 | Numerical integration | ~0.1 milliarcsecond | STABLE | External binary file (115 MB) |
| JPL DE441 | Numerical integration | ~0.1 milliarcsecond | STABLE | External binary file (3.1 GB) |
<Aside type="note" title="The volatility trade-off">
IMMUTABLE functions can be used in expression indexes and are constant-folded during planning. STABLE functions execute once per row per statement. If you have expression indexes on VSOP87 functions (e.g., almanac tables), keep them on the non-DE variants. Use DE functions for queries where accuracy matters more than planning-time optimization.
</Aside>
## Diagnostics
The `pg_orrery_ephemeris_info()` function reports the current state:
```sql
SELECT * FROM pg_orrery_ephemeris_info();
```
| Column | Type | Description |
|--------|------|-------------|
| `provider` | text | `'VSOP87'` or `'JPL_DE'` |
| `file_path` | text | Path to DE file (empty if VSOP87-only) |
| `start_jd` | float8 | First Julian Date in the DE file |
| `end_jd` | float8 | Last Julian Date in the DE file |
| `version` | int4 | DE version number (440, 441, etc.) |
| `au_km` | float8 | AU value from the DE header |
When no DE file is configured, `provider` returns `'VSOP87'` and the remaining columns are defaults.
## Fallback behavior
DE functions never fail silently. The fallback rules:
1. **No GUC set** (default): Fall back to VSOP87/ELP2000-82B silently. No NOTICE, no overhead.
2. **GUC set, file works**: Use DE positions.
3. **GUC set, query-time failure** (JD out of range, I/O error): Emit `NOTICE` explaining the fallback, then use VSOP87/ELP2000-82B.
This means `_de()` functions always return a valid result. They never abort due to DE-specific issues --- at worst, they degrade to VSOP87 accuracy with a NOTICE explaining why.

View File

@ -0,0 +1,271 @@
---
title: Interplanetary Trajectories
sidebar:
order: 7
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
pg_orrery includes a Lambert solver for computing ballistic transfer orbits between any two planets. Given a departure body, arrival body, departure time, and arrival time, the solver returns the transfer orbit's energy characteristics: departure C3, arrival C3, v-infinity, time of flight, and transfer semi-major axis. The function processes about 800,000 solutions per second, which means pork chop plots -- the standard visualization for launch window analysis -- become SQL CROSS JOINs.
## How you do it today
Interplanetary trajectory design is one of the more specialized areas of orbital mechanics:
- **GMAT** (General Mission Analysis Tool): NASA's open-source mission design software. Full-featured but steep learning curve. GUI-driven, script-extensible, not designed for batch parameter sweeps.
- **NASA Trajectory Browser**: Web-based tool for browsing pre-computed transfer opportunities. Limited to pre-defined targets and time windows.
- **poliastro** (Python): Astrodynamics library with Lambert solvers, orbit plotting, and planetary position computation. Good for one-off analysis; batch sweeps require writing loops.
- **STK/Astrogator**: Commercial tool with advanced trajectory design. Expensive, steep learning curve.
For all of these, the workflow is: pick a departure date, pick an arrival date, run the solver, record the result. To build a pork chop plot, you sweep a grid of departure and arrival dates and collect results. In Python, this means nested loops. In GMAT, this means scripted batch runs.
## What changes with pg_orrery
Two functions handle the complete Lambert problem:
| Function | Returns | Use case |
|---|---|---|
| `lambert_transfer(dep_id, arr_id, dep_time, arr_time)` | RECORD with 6 fields | Full transfer orbit characterization |
| `lambert_c3(dep_id, arr_id, dep_time, arr_time)` | float8 | Departure C3 only (for pork chop plots) |
The `lambert_transfer()` output fields:
| Field | Units | Meaning |
|---|---|---|
| `c3_departure` | km^2/s^2 | Launch energy: the kinetic energy per unit mass above escape velocity at departure |
| `c3_arrival` | km^2/s^2 | Arrival energy: excess velocity squared at the target planet |
| `v_inf_departure` | km/s | Hyperbolic excess speed at departure (sqrt of C3) |
| `v_inf_arrival` | km/s | Hyperbolic excess speed at arrival |
| `tof_days` | days | Time of flight |
| `transfer_sma` | AU | Semi-major axis of the transfer ellipse |
Body IDs match the VSOP87 convention: 1=Mercury, 2=Venus, 3=Earth, 4=Mars, 5=Jupiter, 6=Saturn, 7=Uranus, 8=Neptune. The departure body is almost always Earth (3), but the solver works for any planet-to-planet combination.
## What pg_orrery does not replace
<Aside type="caution" title="Ballistic two-body only">
The Lambert solver computes the conic section connecting two positions in a central gravity field. Real interplanetary trajectories involve more physics.
</Aside>
- **No gravity assists.** Voyager, Cassini, and New Horizons all used flyby maneuvers to gain velocity from intermediate planets. The Lambert solver computes direct transfers only.
- **No low-thrust trajectories.** Ion drives and solar sails produce continuous thrust. Lambert assumes an instantaneous departure burn and coast to arrival.
- **No three-body effects.** The solver uses heliocentric two-body mechanics. Sphere-of-influence transitions, Lagrange point dynamics, and lunar gravity assists are not modeled.
- **No launch vehicle constraints.** The solver returns C3, which determines the required launch energy. Mapping C3 to a specific rocket's payload capacity is a separate analysis.
- **No aerocapture or entry design.** The arrival C3 determines how much delta-v is needed for orbit insertion, but pg_orrery does not compute the insertion burn itself.
For mission design beyond first-order feasibility analysis, use GMAT or poliastro with patched-conic or N-body propagation.
## Try it
### Earth-Mars transfer
The classic trajectory design problem. A 2026 departure window:
```sql
SELECT round(c3_departure::numeric, 2) AS c3_dep_km2s2,
round(c3_arrival::numeric, 2) AS c3_arr_km2s2,
round(v_inf_departure::numeric, 2) AS vinf_dep_kms,
round(v_inf_arrival::numeric, 2) AS vinf_arr_kms,
round(tof_days::numeric, 0) AS flight_days,
round(transfer_sma::numeric, 4) AS sma_au
FROM lambert_transfer(
3, 4, -- Earth to Mars
'2026-05-01 00:00:00+00'::timestamptz, -- departure
'2027-01-15 00:00:00+00'::timestamptz -- arrival
);
```
For a typical Earth-Mars transfer, expect departure C3 in the 8-20 km^2/s^2 range, flight times of 200-300 days, and a transfer SMA of roughly 1.2-1.5 AU.
### Earth-Venus transfer
Venus transfers require less energy than Mars because Venus is closer to the Sun:
```sql
SELECT round(c3_departure::numeric, 2) AS c3_dep,
round(tof_days::numeric, 0) AS flight_days,
round(transfer_sma::numeric, 4) AS sma_au
FROM lambert_transfer(
3, 2, -- Earth to Venus
'2026-06-01 00:00:00+00'::timestamptz,
'2026-10-15 00:00:00+00'::timestamptz
);
```
Typical Earth-Venus C3 is 5-15 km^2/s^2 with flight times of 100-200 days.
### Earth-Jupiter transfer
A direct ballistic transfer to Jupiter requires significant energy:
```sql
SELECT round(c3_departure::numeric, 0) AS c3_dep,
round(tof_days::numeric, 0) AS flight_days
FROM lambert_transfer(
3, 5, -- Earth to Jupiter
'2026-01-01 00:00:00+00'::timestamptz,
'2028-06-01 00:00:00+00'::timestamptz
);
```
Expect C3 in the 70-100+ km^2/s^2 range. This is why real Jupiter missions use Venus and Earth gravity assists to reduce the required launch energy.
### Using lambert_c3 for quick comparisons
When you only need the departure energy and not the full transfer characterization:
```sql
SELECT round(lambert_c3(3, 4,
'2026-05-01 00:00:00+00'::timestamptz,
'2027-01-15 00:00:00+00'::timestamptz)::numeric, 2) AS c3;
```
`lambert_c3()` returns just the departure C3 as a single float. It is faster than `lambert_transfer()` when you do not need the other output fields.
### Mini pork chop plot
A pork chop plot shows departure C3 as a function of departure date and arrival date. This is the fundamental tool for launch window analysis. Generate one in SQL with a CROSS JOIN:
```sql
SELECT dep::date AS departure,
arr::date AS arrival,
round(lambert_c3(3, 4, dep, arr)::numeric, 1) AS c3
FROM generate_series(
'2026-04-01'::timestamptz,
'2026-06-01'::timestamptz,
interval '10 days'
) AS dep
CROSS JOIN generate_series(
'2027-01-01'::timestamptz,
'2027-03-01'::timestamptz,
interval '10 days'
) AS arr
ORDER BY c3;
```
The lowest C3 values correspond to the optimal departure/arrival combination. This mini grid has about 40 points; a real pork chop plot uses finer resolution.
### Full pork chop plot: 150x150 grid
For a publication-quality pork chop plot, use 1-day resolution over a wide window:
```sql
SELECT dep::date AS departure,
arr::date AS arrival,
round(lambert_c3(3, 4, dep, arr)::numeric, 2) AS c3
FROM generate_series(
'2026-01-01'::timestamptz,
'2026-06-01'::timestamptz,
interval '1 day'
) AS dep
CROSS JOIN generate_series(
'2026-09-01'::timestamptz,
'2027-06-01'::timestamptz,
interval '1 day'
) AS arr;
```
This generates approximately 150 x 270 = 40,500 transfer solutions. At 800,000 solutions per second, the query completes in well under a second. The result is a table you can feed into any contour plot library.
### Compare transfer windows
Look at multiple departure windows side by side:
```sql
WITH windows AS (
SELECT '2026 window' AS window, '2026-05-01'::timestamptz AS dep, '2027-01-15'::timestamptz AS arr
UNION ALL
SELECT '2028 window', '2028-10-01', '2029-06-15'
UNION ALL
SELECT '2031 window', '2031-02-01', '2031-10-01'
)
SELECT window,
dep::date AS departure,
arr::date AS arrival,
round(c3_departure::numeric, 2) AS c3_dep,
round(c3_arrival::numeric, 2) AS c3_arr,
round(tof_days::numeric, 0) AS flight_days,
round(transfer_sma::numeric, 4) AS sma_au
FROM windows,
LATERAL lambert_transfer(3, 4, dep, arr);
```
Earth-Mars launch windows repeat approximately every 26 months (the synodic period). Some windows are more favorable than others because Mars's orbit is significantly eccentric.
### Find the optimal departure date
Use a fine sweep over departure dates with a fixed arrival date to find the minimum C3:
```sql
SELECT dep::date AS departure,
round(lambert_c3(3, 4, dep,
'2027-01-15 00:00:00+00'::timestamptz)::numeric, 2) AS c3
FROM generate_series(
'2026-03-01'::timestamptz,
'2026-08-01'::timestamptz,
interval '1 day'
) AS dep
ORDER BY lambert_c3(3, 4, dep,
'2027-01-15 00:00:00+00'::timestamptz)
LIMIT 10;
```
The top 10 rows show the departure dates with the lowest launch energy for a fixed arrival on 2027-01-15. In practice, you would sweep both departure and arrival together (the full pork chop plot), but fixing one dimension is useful for understanding the sensitivity.
### Venus-Mars via Earth (multi-leg comparison)
While the Lambert solver does not compute multi-leg gravity assists directly, you can compare the energy requirements of each leg independently:
```sql
-- Earth to Venus (leg 1)
SELECT 'Earth-Venus' AS leg,
round(c3_departure::numeric, 2) AS c3_dep,
round(tof_days::numeric, 0) AS flight_days
FROM lambert_transfer(3, 2,
'2026-06-01'::timestamptz, '2026-10-15'::timestamptz)
UNION ALL
-- Earth to Mars (direct)
SELECT 'Earth-Mars (direct)',
round(c3_departure::numeric, 2),
round(tof_days::numeric, 0)
FROM lambert_transfer(3, 4,
'2026-05-01'::timestamptz, '2027-01-15'::timestamptz)
UNION ALL
-- Earth to Jupiter (direct)
SELECT 'Earth-Jupiter (direct)',
round(c3_departure::numeric, 0),
round(tof_days::numeric, 0)
FROM lambert_transfer(3, 5,
'2026-01-01'::timestamptz, '2028-06-01'::timestamptz);
```
This shows why gravity assists exist: the direct Earth-Jupiter C3 is many times higher than the Earth-Venus C3. By flying to Venus first and using its gravity to redirect, missions can reach Jupiter with far less launch energy.
### Sanity checks
Verify the solver produces physically reasonable results:
```sql
-- C3 should be positive and less than 200 for any Earth-Mars transfer
SELECT lambert_c3(3, 4,
'2026-05-01'::timestamptz,
'2027-01-15'::timestamptz) > 0 AS positive,
lambert_c3(3, 4,
'2026-05-01'::timestamptz,
'2027-01-15'::timestamptz) < 200 AS reasonable;
-- Transfer SMA should be between Earth and Mars orbits
SELECT transfer_sma > 0.8 AS above_venus,
transfer_sma < 5.0 AS below_jupiter
FROM lambert_transfer(3, 4,
'2026-05-01'::timestamptz,
'2027-01-15'::timestamptz);
```
<Aside type="note" title="Error handling">
The solver raises an error for degenerate cases: same body for departure and arrival, or arrival before departure. `lambert_c3()` returns NULL if the solver fails to converge for extreme geometries.
</Aside>

View File

@ -0,0 +1,279 @@
---
title: Jupiter Radio Bursts
sidebar:
order: 6
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Jupiter is the strongest radio source in the solar system after the Sun. Its decametric emissions (roughly 15-40 MHz) occur when Io passes through specific orbital positions relative to Jupiter's rotating magnetic field. pg_orrery computes the two geometry parameters that govern these bursts -- Io phase angle and Jupiter Central Meridian Longitude -- and maps them to an empirical burst probability using the Carr et al. (1983) source regions.
This is the feature built for the Radio JOVE community. There are 500-1000 active Radio JOVE operators worldwide, and until pg_orrery, the standard prediction tool was Radio Jupiter Pro -- a Windows-only desktop application. Batch prediction, calendar generation, and integration with observation scheduling are now possible in SQL.
## How you do it today
Jupiter radio observation planning has relied on a small set of tools:
- **Radio Jupiter Pro** (Windows): The standard tool. Shows a real-time display of Io phase, CML, and burst probability. Single-observer, single-time, no batch output. Windows-only.
- **Manual CML/Io-phase charts**: Published charts (Carr, Desch, Alexander 1983) show which CML-Io phase combinations produce bursts. Observers print the chart and overlay their observing window by hand.
- **Radio-SkyPipe** and **SkyPipe II**: Recording software that can trigger on signal, but prediction is separate.
- **Spreadsheets**: Some operators maintain Excel sheets that compute CML from Jupiter's rotation rate. Error-prone, per-session.
The problem: planning an observation campaign over weeks or months means running Radio Jupiter Pro repeatedly for each night, eyeballing the probability, and writing down the good windows. There is no way to generate a calendar of optimal observation windows in one operation.
## What changes with pg_orrery
Three functions cover the complete Jupiter radio prediction pipeline:
| Function | Returns | What it computes |
|---|---|---|
| `io_phase_angle(time)` | degrees [0, 360) | Io's orbital position. 0 = superior conjunction (behind Jupiter). |
| `jupiter_cml(observer, time)` | degrees [0, 360) | Central Meridian Longitude, System III (1965.0). Light-time corrected. |
| `jupiter_burst_probability(io_phase, cml)` | 0.0 to 1.0 | Empirical probability based on Carr source regions. |
The probability function encodes four source regions from the Carr et al. (1983) model:
| Source | CML Range | Io Phase Range | Probability | Description |
|---|---|---|---|---|
| **A** | 200-260 | 195-265 | 0.8 | Strongest. Io-related, occurs when Io is at superior conjunction. |
| **B** | 100-200 | 60-150 | 0.5 | Io-related, occurs when Io is ~90 degrees ahead of Jupiter. |
| **C** | 300-20 | 220-310 | 0.3 | Weaker. Non-Io component, occurs at specific CML ranges. |
| **D** | 350-60 | 80-140 | 0.2 | Weakest of the four. Non-Io related. |
Outside these regions, the probability is 0.0. Overlapping regions combine to the higher probability.
## What pg_orrery does not replace
<Aside type="caution" title="Empirical model">
The burst probability is a statistical average from decades of observations. Individual bursts are stochastic -- a high probability window can produce nothing, and occasional bursts appear outside predicted windows.
</Aside>
- **No signal detection.** pg_orrery predicts when bursts are likely, not whether one is occurring. Use Radio-SkyPipe or SDR software for actual signal capture.
- **No frequency prediction.** The model predicts occurrence probability, not the specific frequency structure (L-bursts vs. S-bursts) or intensity.
- **No RFI assessment.** Local radio interference is often the biggest obstacle to Jupiter observation. pg_orrery does not model your local RF environment.
- **No receiver pointing.** At 20 MHz, most receivers use fixed dipole antennas. Pointing is not an issue, but Jupiter must be above the horizon. Combine with `planet_observe(5, ...)` to check elevation.
## Try it
### Check current conditions
What are the Io phase and CML right now?
```sql
SELECT round(io_phase_angle(now())::numeric, 1) AS io_phase,
round(jupiter_cml('40.0N 105.3W 1655m'::observer, now())::numeric, 1) AS cml,
round(jupiter_burst_probability(
io_phase_angle(now()),
jupiter_cml('40.0N 105.3W 1655m'::observer, now())
)::numeric, 3) AS burst_prob;
```
### Best burst windows tonight
Scan the next 12 hours in 10-minute steps and find windows where burst probability exceeds 30%:
```sql
SELECT t,
round(io_phase_angle(t)::numeric, 1) AS io_phase,
round(jupiter_cml('40.0N 105.3W 1655m'::observer, t)::numeric, 1) AS cml,
round(jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
)::numeric, 3) AS prob
FROM generate_series(
now(),
now() + interval '12 hours',
interval '10 minutes'
) AS t
WHERE jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
) > 0.3
ORDER BY t;
```
<Aside type="tip" title="Also check Jupiter elevation">
A high burst probability means nothing if Jupiter is below the horizon. Combine this with a `planet_observe(5, ...)` check:
```sql
AND topo_elevation(planet_observe(5,
'40.0N 105.3W 1655m'::observer, t)) > 10
```
</Aside>
### Best windows tonight with horizon check
The complete query: burst probability above threshold AND Jupiter above the horizon:
```sql
SELECT t,
round(io_phase_angle(t)::numeric, 1) AS io_phase,
round(jupiter_cml('40.0N 105.3W 1655m'::observer, t)::numeric, 1) AS cml,
round(jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
)::numeric, 3) AS prob,
round(topo_elevation(planet_observe(5,
'40.0N 105.3W 1655m'::observer, t))::numeric, 1) AS jupiter_el
FROM generate_series(
'2024-03-15 00:00:00+00'::timestamptz,
'2024-03-15 12:00:00+00'::timestamptz,
interval '10 minutes'
) AS t
WHERE jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
) > 0.0
AND topo_elevation(planet_observe(5,
'40.0N 105.3W 1655m'::observer, t)) > 10
ORDER BY prob DESC, t;
```
### 30-day observation calendar
Generate a calendar of the best observation windows over an entire month:
```sql
WITH windows AS (
SELECT t,
io_phase_angle(t) AS io_phase,
jupiter_cml('40.0N 105.3W 1655m'::observer, t) AS cml,
jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
) AS prob,
topo_elevation(planet_observe(5,
'40.0N 105.3W 1655m'::observer, t)) AS jupiter_el
FROM generate_series(
'2024-03-01 00:00:00+00'::timestamptz,
'2024-03-31 00:00:00+00'::timestamptz,
interval '10 minutes'
) AS t
)
SELECT t::date AS date,
t::time AS utc_time,
round(io_phase::numeric, 1) AS io_phase,
round(cml::numeric, 1) AS cml,
round(prob::numeric, 2) AS prob,
round(jupiter_el::numeric, 1) AS jup_el
FROM windows
WHERE prob >= 0.5
AND jupiter_el > 15
ORDER BY date, utc_time;
```
This finds every 10-minute window in March 2024 where burst probability is at least 50% and Jupiter is more than 15 degrees above the horizon. The result is a printable observation calendar.
### Identify Carr source regions
Determine which source region is responsible for a given prediction:
```sql
WITH sources AS (
SELECT 'Source A' AS region, 200.0 AS cml_lo, 260.0 AS cml_hi,
195.0 AS io_lo, 265.0 AS io_hi, 0.8 AS prob
UNION ALL SELECT 'Source B', 100.0, 200.0, 60.0, 150.0, 0.5
UNION ALL SELECT 'Source C', 300.0, 380.0, 220.0, 310.0, 0.3
UNION ALL SELECT 'Source D', 350.0, 420.0, 80.0, 140.0, 0.2
)
SELECT t,
round(io_phase_angle(t)::numeric, 1) AS io,
round(jupiter_cml('40.0N 105.3W 1655m'::observer, t)::numeric, 1) AS cml,
s.region,
s.prob
FROM generate_series(
'2024-03-15 00:00:00+00'::timestamptz,
'2024-03-15 12:00:00+00'::timestamptz,
interval '15 minutes'
) AS t
CROSS JOIN sources s
WHERE io_phase_angle(t) BETWEEN s.io_lo AND s.io_hi
AND (jupiter_cml('40.0N 105.3W 1655m'::observer, t)
BETWEEN s.cml_lo AND LEAST(s.cml_hi, 360.0)
OR jupiter_cml('40.0N 105.3W 1655m'::observer, t) + 360.0
BETWEEN s.cml_lo AND s.cml_hi)
ORDER BY t, s.prob DESC;
```
<Aside type="note" title="CML wrapping">
Source C and D straddle the 360/0 degree boundary. The query handles this by checking both the unwrapped CML and CML+360.
</Aside>
### Io orbital phase rate
Io completes an orbit in about 1.77 days. Watch the phase angle advance over a full orbit:
```sql
SELECT t,
round(io_phase_angle(t)::numeric, 1) AS io_phase
FROM generate_series(
'2024-03-15 00:00:00+00'::timestamptz,
'2024-03-16 18:00:00+00'::timestamptz,
interval '2 hours'
) AS t;
```
The phase should advance roughly 203 degrees per day (360 / 1.77).
### Jupiter CML rotation
Jupiter's System III rotation period is 9h 55m 29.7s. Watch the CML cycle through 360 degrees:
```sql
SELECT t,
round(jupiter_cml('40.0N 105.3W 1655m'::observer, t)::numeric, 1) AS cml
FROM generate_series(
'2024-03-15 00:00:00+00'::timestamptz,
'2024-03-15 10:00:00+00'::timestamptz,
interval '30 minutes'
) AS t;
```
The CML completes one full rotation in just under 10 hours, meaning the same magnetic field geometry repeats roughly 2.4 times per day. This is why Jupiter radio observation windows can occur multiple times per night.
### Probability heatmap data
Generate the data for a CML vs. Io-phase probability plot (the classic Carr diagram):
```sql
SELECT io_phase,
cml,
jupiter_burst_probability(io_phase, cml) AS prob
FROM generate_series(0, 355, 5) AS io_phase,
generate_series(0, 355, 5) AS cml
WHERE jupiter_burst_probability(io_phase, cml) > 0;
```
This produces a 72x72 grid (5-degree resolution) of probability values, showing exactly the four Carr source regions. The output can be fed to any heatmap visualization tool.
### Multi-observer comparison
Compare burst windows for operators at different longitudes. The CML depends on observer position because of light-time correction:
```sql
WITH observers(name, obs) AS (VALUES
('Boulder, CO', '40.0N 105.3W 1655m'::observer),
('Gainesville, FL', '29.6N 82.3W 30m'::observer),
('Paris, FR', '48.9N 2.3E 75m'::observer)
)
SELECT o.name,
t,
round(jupiter_cml(o.obs, t)::numeric, 1) AS cml,
round(jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml(o.obs, t)
)::numeric, 3) AS prob
FROM observers o,
generate_series(
'2024-03-15 02:00:00+00'::timestamptz,
'2024-03-15 06:00:00+00'::timestamptz,
interval '30 minutes'
) AS t
WHERE jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml(o.obs, t)
) > 0.3
ORDER BY t, o.name;
```
The Io phase is the same for all observers (it depends only on time), but the CML varies slightly due to light-time differences. For observers on the same continent, the difference is negligible. Comparing North America to Europe shows a measurable shift.

View File

@ -0,0 +1,260 @@
---
title: Observing the Solar System
sidebar:
order: 2
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
pg_orrery computes positions for all eight planets (VSOP87), the Sun, and the Moon (ELP2000-82B). Every observation returns the same `topocentric` type: azimuth, elevation, range, and range rate from a given observer at a given time. The solar system becomes queryable with standard SQL.
## How you do it today
Knowing where planets are involves one of a few approaches:
- **Stellarium** gives you a beautiful real-time sky view. You scrub time, click objects, read coordinates. Not scriptable, not batch-queryable.
- **JPL Horizons** computes high-precision ephemerides via web form or API. Accurate to milliarcseconds. One object per request, rate-limited.
- **Skyfield** (Python) loads JPL DE441 ephemerides and computes positions with sub-arcsecond accuracy. Excellent for one-off scripts; batch processing over large time ranges or many observers means writing loops.
- **Astropy** provides coordinate frames, time systems, and ERFA wrappers. Powerful, but computing "what's above the horizon right now" requires assembling several components.
All of these produce results that live outside your database. If you want to correlate planet positions with weather data, observation logs, or satellite passes, you export, import, and join.
## What changes with pg_orrery
All planets, the Sun, and the Moon are available as SQL function calls. The functions take an observer and a timestamp, and return topocentric coordinates. You can sweep all eight planets, generate time series, filter by elevation, and join with other tables in the same query.
Key functions:
| Function | What it computes |
|---|---|
| `planet_observe(body_id, observer, time)` | Topocentric az/el/range for a planet |
| `planet_heliocentric(body_id, time)` | Heliocentric ecliptic J2000 position (AU) |
| `sun_observe(observer, time)` | Topocentric Sun position |
| `moon_observe(observer, time)` | Topocentric Moon position (ELP2000-82B) |
Body IDs follow the VSOP87 convention: 1=Mercury, 2=Venus, 3=Earth, 4=Mars, 5=Jupiter, 6=Saturn, 7=Uranus, 8=Neptune. Body 0 returns the Sun at the heliocentric origin (all zeros).
## What pg_orrery does not replace
<Aside type="caution" title="Accuracy trade-offs">
VSOP87 and ELP2000-82B are analytic theories. They trade the last bits of precision for computational speed and zero external data dependencies.
</Aside>
- **VSOP87 accuracy is about 1 arcsecond.** JPL DE441 (used by Skyfield and SPICE) achieves 0.001 arcsecond. For visual observation planning, 1 arcsecond is more than sufficient. For precision astrometry or GHz dish pointing, pg_orrery v0.3.0 supports [optional DE440/441 ephemeris files](/guides/de-ephemeris/) with sub-milliarcsecond accuracy.
- **ELP2000-82B accuracy is about 10 arcseconds** for the Moon. Good enough for knowing when the Moon is up, what phase it is in, and whether it will interfere with observations. Not sufficient for occultation timing.
- **No light-time iteration.** pg_orrery computes geometric positions, not apparent positions. The difference matters at the milliarcsecond level.
- **No atmospheric refraction.** Objects near the horizon appear slightly higher than their geometric position. pg_orrery does not apply refraction corrections.
## Try it
### Where is Jupiter right now?
The simplest possible observation query:
```sql
SELECT topo_azimuth(t) AS azimuth,
topo_elevation(t) AS elevation,
topo_range(t) / 149597870.7 AS distance_au
FROM planet_observe(5, '40.0N 105.3W 1655m'::observer, now()) t;
```
Body ID 5 is Jupiter. The range comes back in km; dividing by 149,597,870.7 converts to AU.
### What is up tonight?
Sweep all eight planets plus the Sun and Moon. Filter to objects above the horizon:
<Tabs>
<TabItem label="Planets only">
```sql
SELECT CASE body_id
WHEN 1 THEN 'Mercury' WHEN 2 THEN 'Venus'
WHEN 3 THEN 'Earth' WHEN 4 THEN 'Mars'
WHEN 5 THEN 'Jupiter' WHEN 6 THEN 'Saturn'
WHEN 7 THEN 'Uranus' WHEN 8 THEN 'Neptune'
END AS planet,
round(topo_azimuth(obs)::numeric, 1) AS az,
round(topo_elevation(obs)::numeric, 1) AS el,
round((topo_range(obs) / 149597870.7)::numeric, 3) AS dist_au
FROM generate_series(1, 8) AS body_id,
LATERAL planet_observe(body_id, '40.0N 105.3W 1655m'::observer,
'2024-06-21 04:00:00+00') obs
WHERE body_id != 3 -- cannot observe Earth from Earth
AND topo_elevation(obs) > 0
ORDER BY topo_elevation(obs) DESC;
```
</TabItem>
<TabItem label="Planets + Sun + Moon">
```sql
-- Combine planets, Sun, and Moon into one result set
WITH observations AS (
-- All planets except Earth
SELECT CASE body_id
WHEN 1 THEN 'Mercury' WHEN 2 THEN 'Venus'
WHEN 4 THEN 'Mars' WHEN 5 THEN 'Jupiter'
WHEN 6 THEN 'Saturn' WHEN 7 THEN 'Uranus'
WHEN 8 THEN 'Neptune'
END AS name,
planet_observe(body_id,
'40.0N 105.3W 1655m'::observer,
'2024-06-21 04:00:00+00') AS obs
FROM generate_series(1, 8) AS body_id
WHERE body_id != 3
UNION ALL
SELECT 'Sun',
sun_observe('40.0N 105.3W 1655m'::observer,
'2024-06-21 04:00:00+00')
UNION ALL
SELECT 'Moon',
moon_observe('40.0N 105.3W 1655m'::observer,
'2024-06-21 04:00:00+00')
)
SELECT name,
round(topo_azimuth(obs)::numeric, 1) AS az,
round(topo_elevation(obs)::numeric, 1) AS el,
round((topo_range(obs) / 149597870.7)::numeric, 4) AS dist_au
FROM observations
WHERE topo_elevation(obs) > 0
ORDER BY topo_elevation(obs) DESC;
```
</TabItem>
</Tabs>
### Solar system status: heliocentric distances
See where every planet is relative to the Sun:
```sql
SELECT body_id,
CASE body_id
WHEN 1 THEN 'Mercury' WHEN 2 THEN 'Venus'
WHEN 3 THEN 'Earth' WHEN 4 THEN 'Mars'
WHEN 5 THEN 'Jupiter' WHEN 6 THEN 'Saturn'
WHEN 7 THEN 'Uranus' WHEN 8 THEN 'Neptune'
END AS planet,
round(helio_distance(planet_heliocentric(body_id, now()))::numeric, 4) AS dist_au,
round(helio_x(planet_heliocentric(body_id, now()))::numeric, 4) AS x_au,
round(helio_y(planet_heliocentric(body_id, now()))::numeric, 4) AS y_au,
round(helio_z(planet_heliocentric(body_id, now()))::numeric, 4) AS z_au
FROM generate_series(1, 8) AS body_id;
```
The heliocentric coordinates are in the ecliptic J2000 frame. X points toward the vernal equinox, Z toward the north ecliptic pole.
### Planet elevation over one night
Track Jupiter's elevation from sunset to sunrise:
```sql
SELECT t,
round(topo_elevation(
planet_observe(5, '40.0N 105.3W 1655m'::observer, t)
)::numeric, 1) AS jupiter_el,
round(topo_azimuth(
planet_observe(5, '40.0N 105.3W 1655m'::observer, t)
)::numeric, 1) AS jupiter_az
FROM generate_series(
'2024-06-21 02:00:00+00'::timestamptz, -- ~8pm MDT
'2024-06-21 12:00:00+00'::timestamptz, -- ~6am MDT
interval '30 minutes'
) AS t
WHERE topo_elevation(
planet_observe(5, '40.0N 105.3W 1655m'::observer, t)
) > 0;
```
This produces a time series of Jupiter's position through the night, filtered to only the hours it is above the horizon. Replace body ID 5 with any other planet.
### Sun position through the day
Useful for solar panel analysis, sunrise/sunset approximation, or photography planning:
```sql
SELECT t,
round(topo_azimuth(sun_observe('40.0N 105.3W 1655m'::observer, t))::numeric, 1) AS az,
round(topo_elevation(sun_observe('40.0N 105.3W 1655m'::observer, t))::numeric, 1) AS el
FROM generate_series(
'2024-06-21 12:00:00+00'::timestamptz, -- ~6am MDT
'2024-06-22 03:00:00+00'::timestamptz, -- ~9pm MDT
interval '15 minutes'
) AS t;
```
At the summer solstice from Boulder, the Sun reaches about 73 degrees elevation at local noon, rising in the northeast and setting in the northwest.
### Moon range check
The Moon's distance varies between about 356,000 km (perigee) and 407,000 km (apogee):
```sql
SELECT t::date AS date,
round(topo_range(moon_observe('40.0N 105.3W 1655m'::observer, t))::numeric, 0) AS range_km
FROM generate_series(
'2024-01-01'::timestamptz,
'2024-02-01'::timestamptz,
interval '1 day'
) AS t;
```
### Multi-observer comparison
Compare planet visibility from two different locations:
```sql
WITH observers AS (
SELECT 'Boulder, CO' AS location, '40.0N 105.3W 1655m'::observer AS obs
UNION ALL
SELECT 'Sydney, AU', '33.9S 151.2E 58m'::observer
)
SELECT o.location,
CASE body_id
WHEN 5 THEN 'Jupiter' WHEN 6 THEN 'Saturn'
END AS planet,
round(topo_elevation(
planet_observe(body_id, o.obs, '2024-06-21 10:00:00+00')
)::numeric, 1) AS elevation
FROM observers o,
generate_series(5, 6) AS body_id
ORDER BY o.location, body_id;
```
### Earth heliocentric sanity check
Earth's distance from the Sun should be about 0.983 AU at perihelion (early January) and 1.017 AU at aphelion (early July):
```sql
SELECT 'perihelion' AS point,
round(helio_distance(
planet_heliocentric(3, '2024-01-03 12:00:00+00')
)::numeric, 4) AS earth_au
UNION ALL
SELECT 'aphelion',
round(helio_distance(
planet_heliocentric(3, '2024-07-05 12:00:00+00')
)::numeric, 4);
```
This is a useful sanity check when verifying the extension is installed correctly.
### Solar-terrestrial geometry
When does the Sun cross specific elevation thresholds? Find solar noon and the elevation at specific times:
```sql
-- Sample the Sun every minute around local noon to find peak elevation
SELECT t,
round(topo_elevation(sun_observe('40.0N 105.3W 1655m'::observer, t))::numeric, 2) AS el
FROM generate_series(
'2024-06-21 17:30:00+00'::timestamptz,
'2024-06-21 18:30:00+00'::timestamptz,
interval '1 minute'
) AS t
ORDER BY topo_elevation(sun_observe('40.0N 105.3W 1655m'::observer, t)) DESC
LIMIT 5;
```
The highest elevation reading approximates solar noon. For Boulder at the summer solstice, expect about 73 degrees.

View File

@ -0,0 +1,282 @@
---
title: Planetary Moons
sidebar:
order: 3
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
pg_orrery computes positions for 19 planetary moons across four systems: the four Galilean moons of Jupiter, eight moons of Saturn, five moons of Uranus, and two moons of Mars. Each uses a dedicated analytic theory optimized for that system.
## How you do it today
Observing planetary moons usually means one of:
- **Stellarium**: Renders moon positions graphically. Good for identifying which moon is which at the eyepiece. Not scriptable.
- **JPL Horizons**: Computes precise ephemerides for any solar system body, including all natural satellites. One query per moon, rate-limited web API.
- **Skyfield**: Can load JPL satellite ephemeris kernels (BSP files) for high-precision moon positions. Requires downloading and managing kernel files.
- **IMCCE**: Provides specialized ephemeris services for natural satellites. Web-based, per-body queries.
The common limitation: getting positions for many moons at many times means many separate requests or script iterations. Comparing moon positions across systems (all of Jupiter's moons vs. all of Saturn's) requires stitching results together outside the ephemeris tool.
## What changes with pg_orrery
Four observation functions cover all 19 moons:
| Function | Theory | Moons | Accuracy |
|---|---|---|---|
| `galilean_observe(body_id, observer, time)` | L1.2 (Lieske, 1998) | 4 (Io, Europa, Ganymede, Callisto) | ~1 arcsecond |
| `saturn_moon_observe(body_id, observer, time)` | TASS 1.7 (Vienne & Duriez, 1995) | 8 (Mimas through Hyperion) | ~1-5 arcseconds |
| `uranus_moon_observe(body_id, observer, time)` | GUST86 (Laskar & Jacobson, 1987) | 5 (Miranda through Oberon) | ~2-10 arcseconds |
| `mars_moon_observe(body_id, observer, time)` | MarsSat (Jacobson, 2010) | 2 (Phobos, Deimos) | ~1-5 arcseconds |
All functions return the same `topocentric` type. Every moon is identified by a system-specific body ID (integer).
## Body ID reference
<Tabs>
<TabItem label="Jupiter (Galilean)">
| ID | Moon | Orbital Period | Notes |
|---|---|---|---|
| 0 | Io | 1.77 days | Volcanic, drives Jupiter radio bursts |
| 1 | Europa | 3.55 days | Subsurface ocean candidate |
| 2 | Ganymede | 7.15 days | Largest moon in the solar system |
| 3 | Callisto | 16.69 days | Most heavily cratered body |
</TabItem>
<TabItem label="Saturn">
| ID | Moon | Orbital Period | Notes |
|---|---|---|---|
| 0 | Mimas | 0.94 days | "Death Star" crater |
| 1 | Enceladus | 1.37 days | Cryovolcanic plumes |
| 2 | Tethys | 1.89 days | Odysseus crater |
| 3 | Dione | 2.74 days | Ice cliffs |
| 4 | Rhea | 4.52 days | Second-largest Saturn moon |
| 5 | Titan | 15.95 days | Thick atmosphere, hydrocarbon lakes |
| 6 | Iapetus | 79.32 days | Two-tone coloring |
| 7 | Hyperion | 21.28 days | Chaotic rotation |
</TabItem>
<TabItem label="Uranus">
| ID | Moon | Orbital Period | Notes |
|---|---|---|---|
| 0 | Miranda | 1.41 days | Extreme surface features |
| 1 | Ariel | 2.52 days | Youngest surface |
| 2 | Umbriel | 4.14 days | Dark, heavily cratered |
| 3 | Titania | 8.71 days | Largest Uranus moon |
| 4 | Oberon | 13.46 days | Most distant major moon |
</TabItem>
<TabItem label="Mars">
| ID | Moon | Orbital Period | Notes |
|---|---|---|---|
| 0 | Phobos | 0.32 days | Slowly spiraling inward |
| 1 | Deimos | 1.26 days | Slowly receding |
</TabItem>
</Tabs>
## What pg_orrery does not replace
<Aside type="caution" title="Theory limitations">
Each analytic theory has a valid time range and accuracy envelope. The theories embedded in pg_orrery are designed for current-epoch observation planning, not historical or far-future ephemeris work.
</Aside>
- **Not sub-arcsecond.** The analytic theories produce positions accurate to a few arcseconds at best. For astrometric reduction or spacecraft navigation, use JPL ephemerides via SPICE or Skyfield.
- **No mutual events.** pg_orrery does not predict eclipses, occultations, or transits between moons. Use IMCCE's MULTISAT service for mutual event predictions.
- **No libration or physical ephemerides.** The functions return topocentric position only — no rotation state, no sub-observer longitude, no apparent disk size.
- **19 moons, not hundreds.** Only the major moons with well-characterized analytic theories are included. Irregular satellites, small inner moons, and ring-embedded moonlets are not covered.
## Try it
### Observe all four Galilean moons
```sql
SELECT CASE moon_id
WHEN 0 THEN 'Io' WHEN 1 THEN 'Europa'
WHEN 2 THEN 'Ganymede' WHEN 3 THEN 'Callisto'
END AS moon,
round(topo_azimuth(obs)::numeric, 1) AS az,
round(topo_elevation(obs)::numeric, 1) AS el,
round(topo_range(obs)::numeric, 0) AS range_km
FROM generate_series(0, 3) AS moon_id,
LATERAL galilean_observe(moon_id,
'40.0N 105.3W 1655m'::observer,
'2024-03-15 03:00:00+00') obs;
```
The range values should cluster near Jupiter's range (about 4-6 AU or 600-900 million km), since the Galilean moons orbit within 0.013 AU of Jupiter.
### Compare Galilean moon ranges to Jupiter
Verify that the moons are near their parent planet:
```sql
SELECT 'Jupiter' AS body,
round(topo_range(planet_observe(5, '40.0N 105.3W 1655m'::observer,
'2024-03-15 03:00:00+00'))::numeric, -4) AS range_km
UNION ALL
SELECT CASE moon_id
WHEN 0 THEN 'Io' WHEN 1 THEN 'Europa'
WHEN 2 THEN 'Ganymede' WHEN 3 THEN 'Callisto'
END,
round(topo_range(galilean_observe(moon_id,
'40.0N 105.3W 1655m'::observer,
'2024-03-15 03:00:00+00'))::numeric, -4)
FROM generate_series(0, 3) AS moon_id;
```
The moon ranges should differ from Jupiter's by at most a few million km. Io orbits closest; Callisto, the farthest Galilean moon, sits about 1.9 million km from Jupiter.
### All eight Saturn moons
```sql
SELECT CASE moon_id
WHEN 0 THEN 'Mimas' WHEN 1 THEN 'Enceladus'
WHEN 2 THEN 'Tethys' WHEN 3 THEN 'Dione'
WHEN 4 THEN 'Rhea' WHEN 5 THEN 'Titan'
WHEN 6 THEN 'Iapetus' WHEN 7 THEN 'Hyperion'
END AS moon,
round(topo_azimuth(obs)::numeric, 1) AS az,
round(topo_elevation(obs)::numeric, 1) AS el,
round(topo_range(obs)::numeric, 0) AS range_km
FROM generate_series(0, 7) AS moon_id,
LATERAL saturn_moon_observe(moon_id,
'40.0N 105.3W 1655m'::observer,
'2024-06-15 03:00:00+00') obs;
```
### Uranus moons
The five major Uranian moons are faint targets, but their positions are still useful for planning deep imaging sessions:
```sql
SELECT CASE moon_id
WHEN 0 THEN 'Miranda' WHEN 1 THEN 'Ariel'
WHEN 2 THEN 'Umbriel' WHEN 3 THEN 'Titania'
WHEN 4 THEN 'Oberon'
END AS moon,
round(topo_azimuth(obs)::numeric, 1) AS az,
round(topo_elevation(obs)::numeric, 1) AS el,
round(topo_range(obs)::numeric, 0) AS range_km
FROM generate_series(0, 4) AS moon_id,
LATERAL uranus_moon_observe(moon_id,
'40.0N 105.3W 1655m'::observer,
'2024-06-15 03:00:00+00') obs;
```
### Mars moons
Phobos and Deimos are challenging visual targets due to Mars glare, but their positions are computed at every epoch:
```sql
SELECT CASE moon_id
WHEN 0 THEN 'Phobos'
WHEN 1 THEN 'Deimos'
END AS moon,
round(topo_azimuth(obs)::numeric, 1) AS az,
round(topo_elevation(obs)::numeric, 1) AS el,
round(topo_range(obs)::numeric, 0) AS range_km
FROM generate_series(0, 1) AS moon_id,
LATERAL mars_moon_observe(moon_id,
'40.0N 105.3W 1655m'::observer,
'2024-01-15 06:00:00+00') obs;
```
### All 19 moons at once
A single query that observes every supported moon in the solar system:
```sql
WITH all_moons AS (
-- Galilean moons (Jupiter)
SELECT 'Jupiter' AS parent,
CASE id WHEN 0 THEN 'Io' WHEN 1 THEN 'Europa'
WHEN 2 THEN 'Ganymede' WHEN 3 THEN 'Callisto'
END AS moon,
galilean_observe(id, '40.0N 105.3W 1655m'::observer,
'2024-06-15 03:00:00+00') AS obs
FROM generate_series(0, 3) AS id
UNION ALL
-- Saturn moons
SELECT 'Saturn',
CASE id WHEN 0 THEN 'Mimas' WHEN 1 THEN 'Enceladus'
WHEN 2 THEN 'Tethys' WHEN 3 THEN 'Dione'
WHEN 4 THEN 'Rhea' WHEN 5 THEN 'Titan'
WHEN 6 THEN 'Iapetus' WHEN 7 THEN 'Hyperion'
END,
saturn_moon_observe(id, '40.0N 105.3W 1655m'::observer,
'2024-06-15 03:00:00+00')
FROM generate_series(0, 7) AS id
UNION ALL
-- Uranus moons
SELECT 'Uranus',
CASE id WHEN 0 THEN 'Miranda' WHEN 1 THEN 'Ariel'
WHEN 2 THEN 'Umbriel' WHEN 3 THEN 'Titania'
WHEN 4 THEN 'Oberon'
END,
uranus_moon_observe(id, '40.0N 105.3W 1655m'::observer,
'2024-06-15 03:00:00+00')
FROM generate_series(0, 4) AS id
UNION ALL
-- Mars moons
SELECT 'Mars',
CASE id WHEN 0 THEN 'Phobos' WHEN 1 THEN 'Deimos' END,
mars_moon_observe(id, '40.0N 105.3W 1655m'::observer,
'2024-06-15 03:00:00+00')
FROM generate_series(0, 1) AS id
)
SELECT parent,
moon,
round(topo_azimuth(obs)::numeric, 1) AS az,
round(topo_elevation(obs)::numeric, 1) AS el,
round((topo_range(obs) / 149597870.7)::numeric, 3) AS dist_au
FROM all_moons
WHERE topo_elevation(obs) > 0
ORDER BY parent, moon;
```
This returns every visible moon from Boulder at the specified time. 19 moons, 19 function calls, one result set.
### Track Galilean moon positions over time
Watch Io complete part of its 1.77-day orbit around Jupiter:
```sql
SELECT t,
round(topo_range(galilean_observe(0, '40.0N 105.3W 1655m'::observer, t))::numeric, 0) AS io_range_km,
round(topo_range(planet_observe(5, '40.0N 105.3W 1655m'::observer, t))::numeric, 0) AS jupiter_range_km,
round((topo_range(galilean_observe(0, '40.0N 105.3W 1655m'::observer, t))
- topo_range(planet_observe(5, '40.0N 105.3W 1655m'::observer, t)))::numeric, 0) AS separation_km
FROM generate_series(
'2024-03-15 00:00:00+00'::timestamptz,
'2024-03-16 18:00:00+00'::timestamptz,
interval '2 hours'
) AS t;
```
The `separation_km` column shows Io oscillating between being closer and farther than Jupiter as seen from Earth — the projection of its orbit along the line of sight.
### Titan observation windows
Find when Titan is above the horizon over a week:
```sql
SELECT t::date AS date,
round(topo_elevation(
saturn_moon_observe(5, '40.0N 105.3W 1655m'::observer, t)
)::numeric, 1) AS titan_el
FROM generate_series(
'2024-06-15 00:00:00+00'::timestamptz,
'2024-06-22 00:00:00+00'::timestamptz,
interval '1 hour'
) AS t
WHERE topo_elevation(
saturn_moon_observe(5, '40.0N 105.3W 1655m'::observer, t)
) > 10
ORDER BY t;
```
Titan (body ID 5 in the Saturn system) is the only moon in the solar system with a thick atmosphere, making it a frequent target for amateur imaging.

View File

@ -0,0 +1,256 @@
---
title: Star Catalogs
sidebar:
order: 4
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
pg_orrery computes topocentric positions for any star given its J2000 right ascension and declination. Feed it a star catalog table and you can observe hundreds of thousands of stars in a single query. The function applies IAU 1976 precession to bring J2000 coordinates to the observation epoch, then transforms to horizon coordinates for a given observer.
## How you do it today
Computing where a star appears in the sky involves:
- **Stellarium**: Type a star name, get its current position. Not queryable, not batchable.
- **Astropy + catalog**: Load the Hipparcos or Tycho-2 catalog, apply precession/nutation/aberration, transform to alt-az. Accurate, but per-object Python calls.
- **Skyfield**: Wraps the Hipparcos catalog with high-precision coordinate transforms. Clean API, but processing a full catalog means iterating over rows.
- **SIMBAD/VizieR**: Query astronomical databases for catalog data. Returns J2000 coordinates; you still need to transform to local horizon coordinates yourself.
The bottleneck is the same as with planets: the computation happens outside your database. If your observation log, scheduling system, or data pipeline lives in PostgreSQL, you export catalog data, compute positions externally, and import the results.
## What changes with pg_orrery
`star_observe()` takes J2000 RA (in hours) and Dec (in degrees), an observer, and a time. It returns a `topocentric` with azimuth, elevation, and zero range (stars are treated as infinitely distant). The function applies IAU 1976 precession and the standard equatorial-to-horizontal transform.
`star_observe_safe()` does the same but returns NULL for invalid inputs (RA outside 0-24 hours, Dec outside +/-90 degrees). Use it for batch queries over catalog tables that might contain bad rows.
The key performance characteristic: star observation processes at about 714,000 observations per second. A 100,000-star catalog can be fully observed from any location at any time in under 150ms.
## What pg_orrery does not replace
<Aside type="caution" title="Precision boundaries">
Star observation in pg_orrery uses IAU 1976 precession only. The missing corrections are small for observation planning but significant for precision astrometry.
</Aside>
- **No nutation.** IAU 1976 precession alone introduces errors up to ~10 arcseconds over a few decades. For visual observation planning, this is negligible. For sub-arcsecond work, use SOFA/ERFA routines.
- **No proper motion.** Barnard's Star moves 10 arcseconds/year. pg_orrery treats catalog coordinates as fixed. If your catalog includes proper motion columns, you can pre-apply the correction in SQL before calling `star_observe()`.
- **No aberration.** Annual aberration displaces star positions by up to ~20 arcseconds. This matters for precision pointing but not for finding stars at the eyepiece.
- **No parallax.** Stellar parallax is at most ~0.8 arcseconds (Proxima Centauri). Not a concern for observation planning.
- **Range is zero.** Stars are treated as infinitely far. The `topo_range()` accessor returns 0 for star observations.
## Try it
### Observe well-known stars
The bright navigational stars and their J2000 coordinates:
<Tabs>
<TabItem label="Individual stars">
```sql
-- Polaris: RA 2h 31m 49s = 2.530303h, Dec +89.2641 deg
SELECT 'Polaris' AS star,
round(topo_azimuth(star_observe(
2.530303, 89.2641,
'40.0N 105.3W 1655m'::observer,
now()))::numeric, 1) AS az,
round(topo_elevation(star_observe(
2.530303, 89.2641,
'40.0N 105.3W 1655m'::observer,
now()))::numeric, 1) AS el;
```
From Boulder (latitude ~40 N), Polaris should be at roughly 40 degrees elevation, near due north (azimuth ~0/360).
</TabItem>
<TabItem label="Multiple stars">
```sql
-- Observe several bright stars at once
WITH stars(name, ra_h, dec_deg) AS (VALUES
('Polaris', 2.530303, 89.2641),
('Sirius', 6.752478, -16.7161),
('Vega', 18.615650, 38.7837),
('Betelgeuse', 5.919529, 7.4070),
('Rigel', 5.242299, -8.2016),
('Arcturus', 14.261027, 19.1824),
('Capella', 5.278155, 46.0076),
('Procyon', 7.655033, 5.2250)
)
SELECT name,
round(topo_azimuth(obs)::numeric, 1) AS az,
round(topo_elevation(obs)::numeric, 1) AS el
FROM stars,
LATERAL star_observe(ra_h, dec_deg,
'40.0N 105.3W 1655m'::observer,
'2024-01-15 03:00:00+00') obs
WHERE topo_elevation(obs) > 0
ORDER BY topo_elevation(obs) DESC;
```
This observes eight bright stars and filters to those above the horizon. The `LATERAL` keyword lets PostgreSQL call `star_observe()` once per star.
</TabItem>
</Tabs>
### Build a star catalog table
For batch operations, store catalog data in a table. Here is a minimal schema using Hipparcos-style data:
```sql
CREATE TABLE star_catalog (
hip_id integer PRIMARY KEY,
name text,
ra_hours float8 NOT NULL,
dec_deg float8 NOT NULL,
vmag float8, -- visual magnitude
spectral text
);
-- Insert a few bright stars for demonstration
INSERT INTO star_catalog VALUES
(11767, 'Polaris', 2.530303, 89.2641, 1.98, 'F7Ib'),
(32349, 'Sirius', 6.752478, -16.7161, -1.46, 'A1V'),
(91262, 'Vega', 18.615650, 38.7837, 0.03, 'A0V'),
(27989, 'Betelgeuse', 5.919529, 7.4070, 0.42, 'M1Ia'),
(24436, 'Rigel', 5.242299, -8.2016, 0.13, 'B8Ia'),
(69673, 'Arcturus', 14.261027, 19.1824, -0.05, 'K1III'),
(24608, 'Capella', 5.278155, 46.0076, 0.08, 'G8III'),
(37279, 'Procyon', 7.655033, 5.2250, 0.34, 'F5IV'),
(7588, 'Achernar', 1.628556, -57.2367, 0.46, 'B3V'),
(80763, 'Antares', 16.490128, -26.4320, 0.96, 'M1Ib');
```
### Batch observe the catalog
Observe every star in the catalog from a given location and time:
```sql
SELECT name,
vmag,
round(topo_azimuth(obs)::numeric, 1) AS az,
round(topo_elevation(obs)::numeric, 1) AS el
FROM star_catalog,
LATERAL star_observe_safe(ra_hours, dec_deg,
'40.0N 105.3W 1655m'::observer,
'2024-01-15 03:00:00+00') obs
WHERE obs IS NOT NULL
AND topo_elevation(obs) > 0
ORDER BY vmag;
```
`star_observe_safe()` returns NULL if the catalog contains invalid coordinates, so the query runs cleanly over the full table. The `WHERE obs IS NOT NULL` clause filters those out.
### What is visible tonight, brighter than magnitude 2?
```sql
SELECT name,
vmag,
spectral,
round(topo_azimuth(obs)::numeric, 1) AS az,
round(topo_elevation(obs)::numeric, 1) AS el
FROM star_catalog,
LATERAL star_observe_safe(ra_hours, dec_deg,
'40.0N 105.3W 1655m'::observer,
'2024-07-15 04:00:00+00') obs
WHERE obs IS NOT NULL
AND topo_elevation(obs) > 10
AND vmag < 2.0
ORDER BY vmag;
```
This finds all bright stars above 10 degrees elevation from Boulder on a July evening. Replace the time and observer for your own conditions.
### Track a star through the night
Watch Vega rise, culminate, and set:
```sql
SELECT t,
round(topo_azimuth(star_observe(
18.615650, 38.7837,
'40.0N 105.3W 1655m'::observer, t
))::numeric, 1) AS az,
round(topo_elevation(star_observe(
18.615650, 38.7837,
'40.0N 105.3W 1655m'::observer, t
))::numeric, 1) AS el
FROM generate_series(
'2024-07-15 02:00:00+00'::timestamptz,
'2024-07-15 12:00:00+00'::timestamptz,
interval '30 minutes'
) AS t
WHERE topo_elevation(star_observe(
18.615650, 38.7837,
'40.0N 105.3W 1655m'::observer, t
)) > 0;
```
Vega culminates at nearly 89 degrees elevation from Boulder — it passes almost directly overhead on summer nights.
### Precession demonstration
The same star at J2000.0 epoch vs. 25 years later. IAU 1976 precession shifts the apparent position:
```sql
SELECT 'J2000.0 epoch' AS epoch,
round(topo_elevation(star_observe(
2.530303, 89.2641,
'40.0N 105.3W 1655m'::observer,
'2000-01-01 12:00:00+00'
))::numeric, 2) AS polaris_el
UNION ALL
SELECT '2025 epoch',
round(topo_elevation(star_observe(
2.530303, 89.2641,
'40.0N 105.3W 1655m'::observer,
'2025-06-15 04:00:00+00'
))::numeric, 2);
```
The elevation changes by a fraction of a degree over 25 years. This is precession in action: the Earth's rotational axis slowly traces a circle in space.
### Cross-match with observation logs
If you keep an observation log in PostgreSQL, you can join it with star positions:
```sql
-- Hypothetical observation log table
CREATE TABLE obs_log (
id serial PRIMARY KEY,
target_hip integer REFERENCES star_catalog(hip_id),
obs_time timestamptz NOT NULL,
observer observer NOT NULL,
notes text
);
-- What was the elevation of each target at the time of observation?
SELECT l.obs_time,
s.name,
round(topo_elevation(
star_observe(s.ra_hours, s.dec_deg, l.observer, l.obs_time)
)::numeric, 1) AS actual_el,
l.notes
FROM obs_log l
JOIN star_catalog s ON s.hip_id = l.target_hip
ORDER BY l.obs_time;
```
This retroactively computes the sky position of every logged target at the time it was observed. Useful for data quality checks — an observation logged at 5 degrees elevation might be suspect.
### Full catalog performance
With a full Hipparcos catalog loaded (118,218 stars), a full-catalog observation runs at about 714,000 stars per second:
```sql
-- Time a full catalog sweep (for benchmarking)
EXPLAIN ANALYZE
SELECT count(*)
FROM star_catalog,
LATERAL star_observe_safe(ra_hours, dec_deg,
'40.0N 105.3W 1655m'::observer,
now()) obs
WHERE obs IS NOT NULL
AND topo_elevation(obs) > 0;
```
The exact throughput depends on hardware, but the function is `PARALLEL SAFE`, so PostgreSQL will distribute the work across available cores on large catalogs.

View File

@ -0,0 +1,324 @@
---
title: Tracking Satellites
sidebar:
order: 1
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Satellite tracking is the domain pg_orrery was originally built for. The core idea: instead of propagating TLEs one at a time in Python and then writing results to your database, move the propagation into the database itself. The satellite catalog becomes a live, queryable model of near-Earth space.
## How you do it today
Most satellite tracking workflows follow the same pattern:
1. **Download TLEs** from Space-Track or CelesTrak into a file or database table.
2. **Propagate** each TLE in Python (python-sgp4, Skyfield) or C++ (libsgp4) to get position/velocity at a given time.
3. **Transform** ECI coordinates to observer-relative look angles (azimuth, elevation, range).
4. **Predict passes** by stepping through time and finding horizon crossings.
5. **Screen for conjunctions** by computing pairwise distances between objects.
Tools like GPredict handle this with a GUI. Skyfield wraps python-sgp4 with a clean API. CelesTrak's GP data service provides pre-propagated state vectors. Each tool handles one satellite, one observer, one time at a time.
The bottleneck shows up when you need to process the catalog. Propagating 12,000 TLEs for a single epoch in Python takes seconds. Joining the results against a frequency database or an owner table requires exporting to CSV, loading into a database, and running the join. Pass prediction for a constellation of 100+ satellites means nested loops. Conjunction screening for the full catalog means O(n^2) pairwise comparisons.
## What changes with pg_orrery
pg_orrery implements SGP4/SDP4 (Brouwer, 1959; Hoots & Roehrich, 1980) as native PostgreSQL functions. The `tle` type stores parsed mean elements directly in a column. Propagation, observation, and pass prediction are SQL function calls that operate on that column.
What this means in practice:
- **Batch observation** of the entire catalog is a single `SELECT`. PostgreSQL parallelizes across cores.
- **Joining** satellite positions with metadata (owner, frequency, purpose) is a standard SQL `JOIN`.
- **Pass prediction** over a time window for many satellites uses `LATERAL JOIN` with `predict_passes()`.
- **Conjunction screening** uses a GiST index on the `tle` column, reducing O(n^2) comparisons to index scans.
The `observe_safe()` function returns NULL instead of raising an error when a TLE has decayed or diverged. This keeps batch queries running even when the catalog contains stale elements.
## What pg_orrery does not replace
<Aside type="caution" title="Know the boundaries">
pg_orrery propagates TLEs and computes look angles. It does not replace the full satellite operations stack.
</Aside>
- **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.
- **No orbit determination.** pg_orrery propagates existing TLEs. It does not fit orbits from observations.
- **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.
## Try it
### Set up a satellite catalog
Create a table that stores TLEs alongside metadata. This mirrors what you would have if you ingest CelesTrak data:
```sql
CREATE TABLE satellites (
norad_id integer PRIMARY KEY,
name text NOT NULL,
tle tle NOT NULL,
owner text,
purpose text
);
-- ISS
INSERT INTO satellites VALUES (
25544, 'ISS (ZARYA)',
'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',
'ISS', 'Space Station'
);
-- Hubble Space Telescope
INSERT INTO satellites VALUES (
20580, 'HST',
'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',
'NASA', 'Telescope'
);
-- GPS IIR-M
INSERT INTO satellites VALUES (
28874, 'GPS BIIR-3 (PRN 29)',
'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',
'USSF', 'Navigation'
);
```
### Batch observation
Observe every satellite in the catalog from a single observer at a single time:
```sql
SELECT s.name,
round(topo_azimuth(obs)::numeric, 1) AS az,
round(topo_elevation(obs)::numeric, 1) AS el,
round(topo_range(obs)::numeric, 0) AS range_km
FROM satellites s,
observe_safe(s.tle,
'40.0N 105.3W 1655m'::observer,
'2024-01-01 12:00:00+00') obs
WHERE topo_elevation(obs) > 0
ORDER BY topo_elevation(obs) DESC;
```
`observe_safe()` returns NULL for decayed or invalid TLEs, so the query runs cleanly over the full catalog. The `WHERE` clause filters to satellites above the horizon. With 12,000 TLEs, this completes in about 17ms.
### Join with metadata
The power of doing this in SQL: you can join satellite positions with any other table. Suppose you have a frequency allocation table:
```sql
-- Hypothetical frequency table
CREATE TABLE sat_frequencies (
norad_id integer REFERENCES satellites(norad_id),
downlink_mhz float8,
mode text
);
-- Which satellites transmitting on 2m are visible right now?
SELECT s.name,
f.downlink_mhz,
f.mode,
round(topo_elevation(obs)::numeric, 1) AS el
FROM satellites s
JOIN sat_frequencies f USING (norad_id),
observe_safe(s.tle,
'40.0N 105.3W 1655m'::observer,
now()) obs
WHERE f.downlink_mhz BETWEEN 144.0 AND 148.0
AND topo_elevation(obs) > 10
ORDER BY topo_elevation(obs) DESC;
```
This is a query you cannot write with python-sgp4 alone. It combines orbital propagation with database operations in a single statement.
### Pass prediction
Predict passes for a single satellite over the next 24 hours:
```sql
SELECT pass_aos_time(p) AS rise,
round(pass_max_elevation(p)::numeric, 1) AS max_el,
pass_max_el_time(p) AS culmination,
pass_los_time(p) AS set,
round(pass_aos_azimuth(p)::numeric, 0) AS rise_az,
round(pass_los_azimuth(p)::numeric, 0) AS set_az,
pass_duration(p) AS duration
FROM satellites s,
predict_passes(s.tle,
'40.0N 105.3W 1655m'::observer,
now(),
now() + interval '24 hours',
10.0) p
WHERE s.norad_id = 25544;
```
The `10.0` parameter filters to passes with maximum elevation above 10 degrees. Lower the threshold to see more passes; raise it to find only the high ones worth tracking.
### Predict passes for many satellites
Use `LATERAL JOIN` to predict passes for every satellite in a subset:
```sql
SELECT s.name,
pass_aos_time(p) AS rise,
round(pass_max_elevation(p)::numeric, 1) AS max_el,
pass_duration(p) AS duration
FROM satellites s,
LATERAL predict_passes(s.tle,
'40.0N 105.3W 1655m'::observer,
now(),
now() + interval '24 hours',
20.0) p
WHERE s.purpose = 'Space Station'
ORDER BY pass_aos_time(p);
```
This finds all passes above 20 degrees for every space station in the catalog. The `LATERAL` keyword lets PostgreSQL call `predict_passes()` once per row of the outer query.
### Ground tracks
Trace the ISS ground track over one orbit (approximately 93 minutes):
```sql
SELECT t,
round(lat::numeric, 2) AS latitude,
round(lon::numeric, 2) AS longitude,
round(alt::numeric, 0) AS altitude_km
FROM satellites s,
ground_track(s.tle,
'2024-01-01 12:00:00+00',
'2024-01-01 13:33:00+00',
interval '1 minute')
WHERE s.norad_id = 25544;
```
The output is a set of (time, lat, lon, alt) rows ready to plot on a map or export to GeoJSON.
### Subsatellite point
The subsatellite point is the nadir location directly below the satellite:
```sql
SELECT geodetic_lat(subsatellite_point(s.tle, now())) AS lat,
geodetic_lon(subsatellite_point(s.tle, now())) AS lon,
geodetic_alt(subsatellite_point(s.tle, now())) AS alt_km
FROM satellites s
WHERE s.norad_id = 25544;
```
### Distance between satellites
Compute the Euclidean distance between any two TLEs at a given time:
```sql
SELECT round(tle_distance(a.tle, b.tle, '2024-01-01 12:00:00+00')::numeric, 0) AS dist_km
FROM satellites a, satellites b
WHERE a.norad_id = 25544 -- ISS
AND b.norad_id = 20580; -- Hubble
```
### Conjunction screening with GiST
The GiST index on the `tle` column enables fast spatial filtering by altitude band and inclination. This is the foundation for conjunction screening:
<Steps>
1. **Create the index:**
```sql
CREATE INDEX satellites_orbit_idx ON satellites USING gist (tle);
```
The index stores a 2-D key for each TLE: altitude band (perigee to apogee) and inclination range. Building the index over the full catalog takes about 200ms.
2. **Find satellites in overlapping orbital shells:**
The `&&` operator tests whether two TLEs occupy overlapping altitude bands AND inclination ranges. This is a necessary (not sufficient) condition for conjunction.
```sql
-- Find all satellites in the same orbital shell as the ISS
SELECT b.name,
round(tle_perigee(b.tle)::numeric, 0) AS perigee_km,
round(tle_apogee(b.tle)::numeric, 0) AS apogee_km,
round(tle_inclination(b.tle)::numeric, 1) AS inc_deg
FROM satellites a, satellites b
WHERE a.norad_id = 25544
AND a.norad_id != b.norad_id
AND a.tle && b.tle
ORDER BY tle_perigee(b.tle);
```
This query uses the GiST index to avoid scanning the full catalog. Only satellites whose altitude band overlaps the ISS and whose inclination is similar are returned.
3. **Nearest-neighbor by altitude separation:**
The `<->` operator returns the minimum altitude-band separation in km between two TLEs. Combined with GiST, it supports efficient K-nearest-neighbor queries:
```sql
-- Find the 10 satellites with the closest altitude band to the ISS
SELECT b.name,
round((a.tle <-> b.tle)::numeric, 0) AS alt_separation_km
FROM satellites a, satellites b
WHERE a.norad_id = 25544
AND a.norad_id != b.norad_id
ORDER BY a.tle <-> b.tle
LIMIT 10;
```
4. **Full conjunction check on candidates:**
The GiST filter narrows the catalog to a handful of candidates. Then verify with actual propagation:
```sql
-- Step 1: GiST narrows to candidates (fast)
-- Step 2: Compute actual distance at each time step (precise)
WITH candidates AS (
SELECT b.norad_id, b.name, b.tle
FROM satellites a, satellites b
WHERE a.norad_id = 25544
AND a.norad_id != b.norad_id
AND a.tle && b.tle
),
iss AS (
SELECT tle FROM satellites WHERE norad_id = 25544
)
SELECT c.name,
t AS check_time,
round(tle_distance(iss.tle, c.tle, t)::numeric, 1) AS dist_km
FROM candidates c, iss,
generate_series(
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
interval '5 minutes') t
WHERE tle_distance(iss.tle, c.tle, t) < 50.0
ORDER BY dist_km;
```
The GiST filter reduces a 12,000-object catalog to a few dozen candidates. The time-stepping check then finds the actual close approaches.
</Steps>
<Aside type="tip" title="GiST is a coarse filter">
The `&&` operator checks altitude band and inclination overlap, not instantaneous distance. Two satellites can share an orbital shell and never come close because their RAANs or phases differ. Always follow GiST filtering with full propagation for actual conjunction assessment.
</Aside>
### TLE metadata accessors
Every TLE exposes its orbital elements as accessor functions:
```sql
SELECT tle_norad_id(tle) AS norad_id,
tle_intl_desig(tle) AS cospar_id,
round(tle_inclination(tle)::numeric, 2) AS inc_deg,
round(tle_eccentricity(tle)::numeric, 6) AS ecc,
round(tle_perigee(tle)::numeric, 0) AS perigee_km,
round(tle_apogee(tle)::numeric, 0) AS apogee_km,
round(tle_period(tle)::numeric, 1) AS period_min,
round(tle_age(tle, now())::numeric, 1) AS age_days
FROM satellites
ORDER BY tle_perigee(tle);
```
The `tle_age()` function returns how many days old the TLE is relative to a given time. Fresh TLEs (age < 3 days) give the best propagation accuracy.

View File

@ -0,0 +1,71 @@
---
title: pg_orrery Documentation
description: Solar system computation for PostgreSQL
template: splash
hero:
tagline: "It's not rocket science. (It's celestial mechanics. But now it's just SQL.)"
image:
file: ../../assets/hero-elephant-orbit.svg
alt: PostgreSQL elephant orbiting a planet
actions:
- text: Get Started
link: /getting-started/what-is-pg-orrery/
icon: right-arrow
variant: primary
- text: What's Different
link: /workflow/sql-advantage/
icon: open-book
---
import { Card, CardGrid, LinkCard } from "@astrojs/starlight/components";
## What can pg_orrery do?
<CardGrid>
<Card title="Track anything in orbit" icon="rocket">
SGP4/SDP4 propagation over 12,000 TLEs in 17ms. GiST-indexed conjunction
screening. Pass prediction with AOS/TCA/LOS. Ground tracks, subsatellite
points, and topocentric observation — all as SQL functions.
</Card>
<Card title="Observe the solar system" icon="sun">
Eight planets via VSOP87 (built-in) or optional JPL DE440/441 (~0.1
milliarcsecond). The Sun, the Moon via ELP2000-82B, 19 planetary moons
across Jupiter, Saturn, Uranus, and Mars. Stars from J2000 catalog
coordinates. Comets and asteroids from Keplerian elements.
</Card>
<Card title="Predict radio bursts" icon="star">
Jupiter-Io decametric emission probability from Carr source regions.
Io orbital phase, Jupiter Central Meridian Longitude (System III), and
burst probability — batch-computed over any time range with generate_series.
</Card>
<Card title="Plan trajectories" icon="right-caret">
Lambert solver for interplanetary transfers between any two planets.
Pork chop plots as SQL CROSS JOINs — 22,500 transfer solutions in
8.3 seconds. Departure C3, arrival C3, time of flight, transfer SMA.
</Card>
</CardGrid>
## Explore the docs
<CardGrid>
<LinkCard
title="Quick Start"
description="Five SQL queries from 'Where is Jupiter?' to planning an Earth-Mars transfer"
href="/getting-started/quick-start/"
/>
<LinkCard
title="Guides"
description="Domain-specific walkthroughs for satellites, planets, moons, stars, comets, radio, and trajectories"
href="/guides/tracking-satellites/"
/>
<LinkCard
title="Workflow Translation"
description="Side-by-side comparisons: how you do it today vs. how pg_orrery changes the game"
href="/workflow/from-skyfield/"
/>
<LinkCard
title="Architecture"
description="Design principles, constant chain of custody, and the observation pipeline"
href="/architecture/design-principles/"
/>
</CardGrid>

View File

@ -0,0 +1,268 @@
---
title: Benchmarks
sidebar:
order: 1
---
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.
<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.
</Aside>
## Summary table
| Operation | Count | Time | Rate | Notes |
|-----------|-------|------|------|-------|
| TLE propagation (SGP4) | 12,000 | 17 ms | 706K/sec | Mixed LEO/MEO/GEO |
| 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 |
| Star observation | 500 | 0.7 ms | 714K/sec | Precession + az/el only |
| 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`.
## TLE propagation
The fundamental operation: given a TLE and a timestamp, compute the TEME position and velocity.
```sql
-- Benchmark: propagate 12,000 TLEs to a single epoch
EXPLAIN (ANALYZE, BUFFERS)
SELECT sgp4_propagate(tle, '2024-06-15 12:00:00+00'::timestamptz)
FROM satellite_catalog;
```
**12,000 TLEs in 17 ms --- 706,000 propagations per second.**
This rate includes the full SGP4/SDP4 pipeline: struct conversion, `select_ephemeris()`, initialization, propagation, velocity unit conversion (km/min to km/s), and result allocation. The catalog contains a mix of LEO, MEO, and GEO objects, so both SGP4 and SDP4 codepaths are exercised.
### What limits the rate
SGP4 propagation is compute-bound, dominated by trigonometric function evaluations in the short-period perturbation corrections. The `params` array (736 bytes) fits in L1 cache. The bottleneck is not memory access but `sin()` / `cos()` calls in the inner loop.
### Scaling with parallel workers
When PostgreSQL allocates parallel workers, throughput scales near-linearly because all functions are `PARALLEL SAFE` with zero shared state:
```sql
-- Force parallel execution (for benchmarking only)
SET max_parallel_workers_per_gather = 4;
SET parallel_tuple_cost = 0;
EXPLAIN (ANALYZE)
SELECT sgp4_propagate(tle, now())
FROM satellite_catalog;
```
With 4 workers on a 6-core machine, expect 2.5--3.5x throughput improvement. The sub-linear scaling is due to tuple redistribution overhead, not contention.
## Planet observation
The full observation pipeline: VSOP87 for the target, VSOP87 for Earth, geocentric ecliptic, obliquity rotation, precession, sidereal time, and az/el.
```sql
-- Benchmark: observe all 7 non-Earth planets at 125 times each
EXPLAIN (ANALYZE)
SELECT planet_observe(body_id, '40.0N 105.3W 1655m'::observer, t)
FROM generate_series(1, 8) AS body_id,
generate_series(
'2024-01-01'::timestamptz,
'2024-01-01'::timestamptz + interval '125 hours',
interval '1 hour'
) AS t
WHERE body_id != 3; -- skip Earth (observer is on Earth)
```
**875 observations in 57 ms --- 15,400 observations per second.**
VSOP87 is ~45x slower than SGP4 per call because it evaluates large trigonometric series (hundreds of terms per coordinate). The Earth position is computed twice per observation (once for the target's geocentric position, once for the observer's sidereal time), but the Earth VSOP87 call is cached internally per Julian date.
### Per-planet breakdown
The outer planets (Jupiter through Neptune) are slightly faster than the inner planets because their VSOP87 series have fewer significant terms at the truncation level pg_orrery uses.
## Galilean moon observation
L1.2 theory for the moon position, plus VSOP87 for Jupiter (parent planet) and Earth.
```sql
-- Benchmark: observe all 4 Galilean moons at 250 times each
EXPLAIN (ANALYZE)
SELECT galilean_observe(moon_id, '40.0N 105.3W 1655m'::observer, t)
FROM generate_series(1, 4) AS moon_id,
generate_series(
'2024-01-01'::timestamptz,
'2024-01-01'::timestamptz + interval '250 hours',
interval '1 hour'
) AS t;
```
**1,000 observations in 63 ms --- 15,900 per second.**
The per-call cost is slightly higher than a single planet observation because the pipeline includes the moon theory (L1.2) plus the parent planet VSOP87 call plus the standard observation pipeline.
## Saturn moon observation
TASS17 theory, plus VSOP87 for Saturn.
```sql
-- Benchmark: observe 8 Saturn moons at 100 times each
EXPLAIN (ANALYZE)
SELECT saturn_moon_observe(moon_id, '40.0N 105.3W 1655m'::observer, t)
FROM generate_series(1, 8) AS moon_id,
generate_series(
'2024-01-01'::timestamptz,
'2024-01-01'::timestamptz + interval '100 hours',
interval '1 hour'
) AS t;
```
**800 observations in 53 ms --- 15,100 per second.**
TASS17 is comparable in complexity to L1.2. The rate difference from Galilean moon observation is within measurement noise.
## Star observation
Stars use the simplest pipeline: catalog coordinates (RA/Dec J2000), precession to date, sidereal time, and az/el. No ephemeris computation.
```sql
-- Benchmark: observe 500 stars
EXPLAIN (ANALYZE)
SELECT star_observe(ra_j2000, dec_j2000, '40.0N 105.3W 1655m'::observer, now())
FROM star_catalog
LIMIT 500;
```
**500 observations in 0.7 ms --- 714,000 per second.**
This is nearly as fast as SGP4 propagation because the only computation is matrix multiplication (precession) and a trigonometric transform (az/el). No series evaluation, no iteration.
## Lambert transfer
A single Lambert solve: given two planet positions and a time of flight, find the transfer orbit.
```sql
-- Benchmark: 100 Lambert solves with varying TOF
EXPLAIN (ANALYZE)
SELECT lambert_transfer(3, 4, dep, dep + tof * interval '1 day')
FROM generate_series(1, 100) AS tof,
(SELECT '2028-10-01'::timestamptz AS dep) d;
```
**100 solves in 0.1 ms --- 800,000 per second.**
The Lambert solver itself (Izzo's Householder iteration) converges in 3--5 iterations for typical interplanetary transfers. The dominant cost per call is the two VSOP87 evaluations (departure and arrival planet positions), not the solver.
## Pork chop plot
The flagship benchmark: a full 150 x 150 grid of departure and arrival dates for an Earth-Mars transfer, each cell requiring two VSOP87 calls plus a Lambert solve.
```sql
-- Benchmark: 150x150 pork chop plot, Earth to Mars
EXPLAIN (ANALYZE)
SELECT dep_date, arr_date, c3_departure, c3_arrival, tof_days
FROM generate_series(
'2028-08-01'::timestamptz,
'2028-08-01'::timestamptz + interval '150 days',
interval '1 day'
) AS dep_date
CROSS JOIN generate_series(
'2029-02-01'::timestamptz,
'2029-02-01'::timestamptz + interval '150 days',
interval '1 day'
) AS arr_date
CROSS JOIN LATERAL lambert_transfer(3, 4, dep_date, arr_date) t
WHERE t IS NOT NULL;
```
**22,500 transfer solutions in 8.3 seconds --- 2,700 per second.**
Each cell requires:
- 2 VSOP87 evaluations (Earth and Mars at departure)
- 2 VSOP87 evaluations (Earth and Mars at arrival, for velocity computation)
- 1 Lambert solve
- 2 velocity difference computations (departure and arrival $C_3$)
The per-cell cost is dominated by the four VSOP87 calls. Cells where arrival precedes departure or the time of flight is too short for convergence return NULL and are filtered by the WHERE clause.
### Parallelization
This is where `PARALLEL SAFE` pays off most. A 150 x 150 pork chop plot with 4 parallel workers:
```sql
SET max_parallel_workers_per_gather = 4;
```
Expected speedup: 2.5--3x, bringing the total under 3 seconds for 22,500 solutions.
## Pass prediction
Pass prediction is harder to benchmark in isolation because it is a search algorithm, not a fixed-cost computation. The number of propagation calls depends on the orbit and search window.
```sql
-- Benchmark: ISS passes over 7 days, minimum 10 degrees
EXPLAIN (ANALYZE)
SELECT *
FROM predict_passes(
iss_tle,
'40.0N 105.3W 1655m'::observer,
'2024-06-15'::timestamptz,
'2024-06-22'::timestamptz,
10.0
);
```
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.
## Reproducing these benchmarks
<Tabs>
<TabItem label="Requirements">
- PostgreSQL 17 with pg_orrery installed
- A satellite catalog table with ~12,000 TLEs (available from CelesTrak)
- 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
</TabItem>
<TabItem label="Setup">
```sql
CREATE EXTENSION pg_orrery;
-- Load a TLE catalog
CREATE TABLE satellite_catalog (tle tle);
-- (COPY from CelesTrak bulk TLE file)
-- Verify catalog size
SELECT count(*) FROM satellite_catalog;
-- Expected: ~12,000 rows
-- Disable parallel workers for baseline measurement
SET max_parallel_workers_per_gather = 0;
```
</TabItem>
<TabItem label="Measurement">
```sql
-- Run each benchmark query three times
-- Discard the first run (cold start)
-- Report the median of runs 2 and 3
-- Example:
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT sgp4_propagate(tle, now())
FROM satellite_catalog;
```
Use `EXPLAIN (ANALYZE)` rather than client-side timing to exclude network latency and result serialization overhead. The `Execution Time` line in the EXPLAIN output is the number to report.
</TabItem>
</Tabs>
## What these numbers mean
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.

View File

@ -0,0 +1,170 @@
---
title: "Body ID Reference"
sidebar:
order: 9
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Complete mapping of integer body identifiers used across all pg_orrery functions. Each function family uses its own ID space; the tables below document which IDs are valid for which functions.
---
## Planet IDs
Used by `planet_heliocentric`, `planet_observe`, and `lambert_transfer` / `lambert_c3`.
| ID | Body | Valid for `planet_heliocentric` | Valid for `planet_observe` | Valid for `lambert_*` |
|----|------|:----:|:----:|:----:|
| 0 | Sun | Yes (returns origin) | No | No |
| 1 | Mercury | Yes | Yes | Yes |
| 2 | Venus | Yes | Yes | Yes |
| 3 | Earth | Yes | No | Yes |
| 4 | Mars | Yes | Yes | Yes |
| 5 | Jupiter | Yes | Yes | Yes |
| 6 | Saturn | Yes | Yes | Yes |
| 7 | Uranus | Yes | Yes | Yes |
| 8 | Neptune | Yes | Yes | Yes |
<Aside type="note">
Body ID 0 (Sun) in `planet_heliocentric` returns the origin (0, 0, 0) by definition. Use `sun_observe` for topocentric Sun observation. Body ID 3 (Earth) cannot be used with `planet_observe` because observing Earth from Earth is undefined; use `planet_heliocentric(3, t)` to get Earth's heliocentric position.
</Aside>
### Conventions
The planet ID numbering follows the VSOP87 convention:
- IDs 1-8 map to the eight planets in order from the Sun
- No ID is assigned to Pluto (VSOP87 does not include it)
- The Sun is included as ID 0 for completeness in heliocentric queries
```sql
-- Quick lookup: all planet IDs with names
SELECT body_id,
CASE body_id
WHEN 0 THEN 'Sun' WHEN 1 THEN 'Mercury'
WHEN 2 THEN 'Venus' WHEN 3 THEN 'Earth'
WHEN 4 THEN 'Mars' WHEN 5 THEN 'Jupiter'
WHEN 6 THEN 'Saturn' WHEN 7 THEN 'Uranus'
WHEN 8 THEN 'Neptune'
END AS name
FROM generate_series(0, 8) AS body_id;
```
---
## Galilean Moon IDs
Used by `galilean_observe`. Numbered in order of distance from Jupiter, following the Lieske L1.2 convention.
| ID | Moon | Discoverer | Orbital Period | Semi-major Axis |
|----|------|------------|----------------|-----------------|
| 0 | Io | Galileo (1610) | 1.769 days | 421,700 km |
| 1 | Europa | Galileo (1610) | 3.551 days | 671,034 km |
| 2 | Ganymede | Galileo (1610) | 7.155 days | 1,070,412 km |
| 3 | Callisto | Galileo (1610) | 16.689 days | 1,882,709 km |
```sql
-- All Galilean moon names and IDs
SELECT moon_id,
CASE moon_id
WHEN 0 THEN 'Io' WHEN 1 THEN 'Europa'
WHEN 2 THEN 'Ganymede' WHEN 3 THEN 'Callisto'
END AS name
FROM generate_series(0, 3) AS moon_id;
```
---
## Saturn Moon IDs
Used by `saturn_moon_observe`. Numbered in order of distance from Saturn, following the TASS17 convention.
| ID | Moon | Discoverer | Orbital Period | Semi-major Axis |
|----|------|------------|----------------|-----------------|
| 0 | Mimas | Herschel (1789) | 0.942 days | 185,539 km |
| 1 | Enceladus | Herschel (1789) | 1.370 days | 238,042 km |
| 2 | Tethys | Cassini (1684) | 1.888 days | 294,619 km |
| 3 | Dione | Cassini (1684) | 2.737 days | 377,396 km |
| 4 | Rhea | Cassini (1672) | 4.518 days | 527,108 km |
| 5 | Titan | Huygens (1655) | 15.945 days | 1,221,870 km |
| 6 | Iapetus | Cassini (1671) | 79.322 days | 3,560,820 km |
| 7 | Hyperion | Bond & Lassell (1848) | 21.277 days | 1,481,010 km |
<Aside type="note">
The moon IDs are ordered by distance from Saturn, not by discovery date or ID 7 (Hyperion) being after ID 6 (Iapetus) despite Hyperion orbiting closer. This follows the TASS17 convention where the inner six moons (Mimas through Titan) are modeled as a coupled system, and the outer two (Iapetus, Hyperion) are treated with additional perturbation terms.
</Aside>
```sql
SELECT moon_id,
CASE moon_id
WHEN 0 THEN 'Mimas' WHEN 1 THEN 'Enceladus'
WHEN 2 THEN 'Tethys' WHEN 3 THEN 'Dione'
WHEN 4 THEN 'Rhea' WHEN 5 THEN 'Titan'
WHEN 6 THEN 'Iapetus' WHEN 7 THEN 'Hyperion'
END AS name
FROM generate_series(0, 7) AS moon_id;
```
---
## Uranus Moon IDs
Used by `uranus_moon_observe`. Numbered in order of distance from Uranus, following the GUST86 convention.
| ID | Moon | Discoverer | Orbital Period | Semi-major Axis |
|----|------|------------|----------------|-----------------|
| 0 | Miranda | Kuiper (1948) | 1.413 days | 129,390 km |
| 1 | Ariel | Lassell (1851) | 2.520 days | 190,900 km |
| 2 | Umbriel | Lassell (1851) | 4.144 days | 266,000 km |
| 3 | Titania | Herschel (1787) | 8.706 days | 435,910 km |
| 4 | Oberon | Herschel (1787) | 13.463 days | 583,520 km |
```sql
SELECT moon_id,
CASE moon_id
WHEN 0 THEN 'Miranda' WHEN 1 THEN 'Ariel'
WHEN 2 THEN 'Umbriel' WHEN 3 THEN 'Titania'
WHEN 4 THEN 'Oberon'
END AS name
FROM generate_series(0, 4) AS moon_id;
```
---
## Mars Moon IDs
Used by `mars_moon_observe`. Numbered in order of distance from Mars, following the MarsSat convention.
| ID | Moon | Discoverer | Orbital Period | Semi-major Axis |
|----|------|------------|----------------|-----------------|
| 0 | Phobos | Hall (1877) | 0.319 days (7h 39m) | 9,376 km |
| 1 | Deimos | Hall (1877) | 1.263 days | 23,463 km |
<Aside type="note">
Phobos orbits closer to its parent planet than any other known moon in the solar system, completing more than three orbits per Martian day. Its orbital period (7h 39m) is shorter than Mars's rotation period (24h 37m), so it rises in the west and sets in the east as seen from the Martian surface.
</Aside>
```sql
SELECT moon_id,
CASE moon_id
WHEN 0 THEN 'Phobos'
WHEN 1 THEN 'Deimos'
END AS name
FROM generate_series(0, 1) AS moon_id;
```
---
## Summary Table
Total: **8 planets + 19 moons = 27 solar system bodies** computable from SQL.
| Function | ID Range | Count | Theory |
|----------|----------|-------|--------|
| `planet_heliocentric` | 0-8 | 9 | VSOP87 |
| `planet_observe` | 1-2, 4-8 | 7 | VSOP87 |
| `galilean_observe` | 0-3 | 4 | Lieske L1.2 |
| `saturn_moon_observe` | 0-7 | 8 | TASS17 |
| `uranus_moon_observe` | 0-4 | 5 | GUST86 |
| `mars_moon_observe` | 0-1 | 2 | MarsSat |
| `lambert_transfer` / `lambert_c3` | 1-8 | 8 | VSOP87 + Lambert |

View File

@ -0,0 +1,224 @@
---
title: "Constants & Accuracy"
sidebar:
order: 10
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Physical constants, astronomical constants, and accuracy bounds for every computational theory used in pg_orrery. VSOP87/ELP2000-82B constants are compiled from their original sources and embedded at compile time. Optional JPL DE440/441 ephemeris support (v0.3.0+) reads constants from an external binary file.
---
## Physical Constants
### WGS-72 (SGP4/SDP4 Only)
The SGP4/SDP4 propagator uses WGS-72 constants internally. This matches the reference frame in which TLEs are generated. Using WGS-84 with SGP4 would introduce systematic errors.
| Constant | Symbol | Value | Unit |
|----------|--------|-------|------|
| Gravitational parameter | mu | 398600.8 | km^3/s^2 |
| Equatorial radius | ae | 6378.135 | km |
| J2 | J2 | 0.001082616 | -- |
| J3 | J3 | -0.00000253881 | -- |
| J4 | J4 | -0.00000165597 | -- |
<Aside type="caution">
These WGS-72 values are **only** used inside the SGP4/SDP4 propagator. All coordinate output (geodetic, topocentric) uses WGS-84. Mixing WGS-72 and WGS-84 constants in the same computation is a common source of systematic error in satellite tracking software; pg_orrery handles this boundary correctly.
</Aside>
### WGS-84 (Coordinate Output)
All geodetic and topocentric coordinate conversions use WGS-84 constants.
| Constant | Symbol | Value | Unit |
|----------|--------|-------|------|
| Semi-major axis (equatorial radius) | a | 6378.137 | km |
| Flattening | f | 1/298.257223563 | -- |
| Semi-minor axis (polar radius) | b | 6356.752314245 | km |
| First eccentricity squared | e^2 | 0.00669437999014 | -- |
---
## Astronomical Constants
| Constant | Symbol | Value | Unit | Source |
|----------|--------|-------|------|--------|
| Astronomical Unit | AU | 149597870.7 | km | IAU 2012 |
| Obliquity of the ecliptic at J2000 | epsilon | 23.4392911 | degrees | IAU 1976 |
| Gaussian gravitational constant | k | 0.01720209895 | AU^(3/2) / (day * Msun^(1/2)) | IAU 1976 |
| Julian century | -- | 36525.0 | days | -- |
| J2000 epoch | -- | 2451545.0 | JD | 2000-01-01T12:00:00 TT |
| Speed of light | c | 299792.458 | km/s | IAU 2012 |
| Earth rotation rate | omega_e | 7.292115e-5 | rad/s | WGS-84 |
### Jupiter-Specific Constants
| Constant | Value | Unit | Source |
|----------|-------|------|--------|
| System III rotation period | 9h 55m 29.711s | -- | IAU 1965 |
| System III rotation rate | 870.536 | deg/day | Derived |
---
## Theory Accuracy Bounds
Each computational theory in pg_orrery has well-characterized accuracy limits. The bounds below are drawn from the original theory publications and validated against JPL ephemerides where possible.
### SGP4/SDP4 (Satellite Propagation)
| Orbit Class | Typical Error at Epoch | Error Growth Rate | Source |
|-------------|----------------------|-------------------|--------|
| LEO (< 2000 km) | < 1 km at epoch | 1-3 km/day | Vallado et al., 2006 |
| MEO (2000-35786 km) | < 5 km at epoch | 5-10 km/day | Vallado et al., 2006 |
| GEO (~35786 km) | < 10 km at epoch | 10-50 km/day | Vallado et al., 2006 |
| HEO (Molniya-type) | < 10 km at epoch | Highly variable | Vallado et al., 2006 |
<Aside type="note">
These errors assume the TLE is freshly generated. TLE age is the dominant error source. The `tle_age` accessor function reports how old a TLE is relative to the current time. For LEO conjunction screening, TLEs older than 3 days should be treated with caution.
</Aside>
**Valid epoch range:** TLEs are typically valid for +/- 7 days from epoch for LEO, +/- 14 days for GEO. Beyond this, errors grow rapidly and propagation may fail outright (returning a fatal error code).
**Deep space selection:** The SGP4/SDP4 algorithm switch is based on orbital period. Orbits with period >= 225 minutes (~3.75 hours, corresponding to an altitude of roughly 5,900 km for circular orbits) use the SDP4 deep-space model, which includes lunar and solar perturbation terms.
### VSOP87 (Planetary Positions)
Position accuracy relative to JPL DE405 ephemeris:
| Planet | Max Error (within +/- 2000 yr of J2000) | Max Error (within +/- 4000 yr of J2000) |
|--------|------------------------------------------|------------------------------------------|
| Mercury | 0.6" | 1" |
| Venus | 0.3" | 2" |
| Earth-Moon barycenter | 0.4" | 2" |
| Mars | 0.8" | 4" |
| Jupiter | 0.3" | 7" |
| Saturn | 0.4" | 10" |
| Uranus | 0.2" | 20" |
| Neptune | 0.3" | 40" |
<Aside type="tip">
For dates within a few centuries of J2000, VSOP87 is accurate to sub-arcsecond levels for all planets. For most observational purposes (telescope pointing, rise/set times, conjunction identification), this exceeds requirements.
</Aside>
**Valid epoch range:** The series coefficients are fitted over the interval J2000 +/- 4000 years. Outside this range, accuracy degrades unpredictably. For observational planning (present-day queries), accuracy is well within 1 arcsecond.
### JPL DE440/441 (Optional, v0.3.0+)
When configured via the `pg_orrery.ephemeris_path` GUC, DE ephemeris positions are available through the `_de()` function variants.
| Property | DE440 | DE441 |
|----------|-------|-------|
| Accuracy | ~0.1 milliarcsecond | ~0.1 milliarcsecond |
| Valid range | 1550 to 2650 | -13200 to +17191 |
| File size | ~115 MB | ~3.1 GB |
| Method | Numerical integration | Numerical integration |
Accuracy relative to VLBI observations:
| Body | Position Accuracy | Notes |
|------|-------------------|-------|
| Inner planets (Mercury-Mars) | < 0.3 milliarcsecond | Constrained by radar ranging |
| Jupiter, Saturn | < 1 milliarcsecond | Constrained by spacecraft tracking + CCD astrometry |
| Uranus, Neptune | < 10 milliarcsecond | Limited by ground-based astrometry |
| Moon | ~1 meter | Constrained by Lunar Laser Ranging |
<Aside type="note">
DE positions are in the ICRS equatorial frame. pg_orrery applies the equatorial-to-ecliptic rotation at the provider boundary before feeding positions into the observation pipeline. Both target and Earth positions always come from the same provider (Rule 7 of the constant chain of custody).
</Aside>
**DE440 vs DE441:** DE440 is recommended for present-day and near-future work. It covers 1550-2650 and produces identical results to DE441 within that range, but is ~27x smaller. DE441's extended range (-13200 to +17191) is needed only for paleoclimate studies, historical astronomy, or very long-term solar system dynamics.
### ELP2000-82B (Lunar Position)
| Quantity | Accuracy |
|----------|----------|
| Geocentric longitude | < 2" for dates within +/- 500 years of J2000 |
| Geocentric latitude | < 1" |
| Distance | < 0.5 km |
**Valid epoch range:** Nominally +/- 4000 years from J2000, though accuracy degrades beyond +/- 500 years. For present-day lunar observations, the error is well under 1 arcsecond and 1 km in distance.
### Lieske L1.2 (Galilean Moons)
| Quantity | Accuracy |
|----------|----------|
| Position relative to Jupiter | < 500 km (typical), < 1000 km (worst case) |
| Differential positions (moon-to-moon) | < 200 km |
**Valid epoch range:** Fitted to observations spanning 1891-2000. Accuracy degrades outside this range. For present-day observations and near-term predictions, the theory is reliable.
### TASS17 (Saturn Moons)
| Quantity | Accuracy |
|----------|----------|
| Position relative to Saturn | < 500 km (inner moons), < 2000 km (Hyperion) |
| Titan position | < 300 km |
**Notes:** Hyperion has the largest uncertainty due to its chaotic rotation and irregular orbital perturbations. Titan, as the most massive moon, is the best-determined.
**Valid epoch range:** Fitted to observations spanning 1886-1985. The theory uses secular terms that limit extrapolation.
### GUST86 (Uranus Moons)
| Quantity | Accuracy |
|----------|----------|
| Position relative to Uranus | < 1000 km (Titania, Oberon), < 2000 km (Miranda) |
**Notes:** The Uranian system was significantly improved by Voyager 2 encounter data (1986). Pre-Voyager observations constrain secular rates; Voyager data constrains short-period terms.
**Valid epoch range:** Most reliable within +/- 50 years of the Voyager encounter (1986). Present-day accuracy is good.
### MarsSat (Mars Moons)
| Quantity | Accuracy |
|----------|----------|
| Phobos position relative to Mars | < 10 km |
| Deimos position relative to Mars | < 20 km |
**Notes:** The Mars moon theories benefit from spacecraft tracking data (Viking, Mars Express). Phobos is better determined than Deimos due to more frequent close encounters with Mars orbiters.
### Kepler Propagation (Comets & Asteroids)
| Orbit Type | Limitation |
|------------|------------|
| Elliptic (e < 1) | Two-body only. No planetary perturbations. Error grows with distance from perihelion epoch. |
| Parabolic (e = 1) | Barker's equation. Exact for the two-body case. |
| Hyperbolic (e > 1) | Two-body only. Valid for interstellar objects near perihelion. |
<Aside type="caution">
Keplerian propagation ignores gravitational perturbations from planets, non-gravitational forces (outgassing, radiation pressure), and relativistic effects. For long-period comets far from perihelion, the two-body approximation is reasonable. For short-period comets with close planetary encounters (e.g., Jupiter-family comets), errors accumulate over time. Use fresh osculating elements.
</Aside>
### Lambert Solver (Transfer Orbits)
| Quantity | Notes |
|----------|-------|
| Transfer orbit accuracy | Exact for the two-body (patched conic) approximation |
| Planet position accuracy | Limited by VSOP87 (sub-arcsecond for present-day) |
| C3 accuracy | Departure C3 values are typically within 0.1 km^2/s^2 of JPL trajectory tools for well-posed transfers |
**Limitations:** The Lambert solver assumes patched conic trajectories (two-body between planets). It does not account for:
- Gravity assists
- Solar radiation pressure
- Finite thrust arcs
- N-body perturbations during the transfer
For preliminary mission design and pork chop plot generation, these limitations are standard and expected.
---
## Reference Publications
| Theory | Publication |
|--------|-------------|
| SGP4/SDP4 | Vallado, D.A., Crawford, P., Hujsak, R., Kelso, T.S. "Revisiting Spacetrack Report #3." AIAA 2006-6753, 2006. |
| VSOP87 | Bretagnon, P., Francou, G. "Planetary theories in rectangular and spherical variables. VSOP87 solutions." Astronomy and Astrophysics, 202, 309-315, 1988. |
| ELP2000-82B | Chapront-Touze, M., Chapront, J. "The lunar ephemeris ELP-2000." Astronomy and Astrophysics, 124, 50-62, 1983. |
| Lieske L1.2 | Lieske, J.H. "Galilean satellites of Jupiter." Astronomy and Astrophysics Supplement Series, 129, 205-217, 1998. |
| TASS17 | Vienne, A., Duriez, L. "TASS1.7: An ephemeris generator for the major satellites of Saturn." Astronomy and Astrophysics, 297, 588-605, 1995. |
| GUST86 | Laskar, J., Jacobson, R.A. "GUST86: An analytical ephemeris of the Uranian satellites." Astronomy and Astrophysics, 188, 212-224, 1987. |
| MarsSat | Jacobson, R.A. "The orbits and masses of the Martian satellites and the libration of Phobos." The Astronomical Journal, 139, 668-679, 2010. |
| Carr source regions | Carr, T.D., Desch, M.D., Alexander, J.K. "Phenomenology of magnetospheric radio emissions." In Physics of the Jovian Magnetosphere, Cambridge Univ. Press, 1983. |
| Lambert solver | Battin, R.H. "An Introduction to the Mathematics and Methods of Astrodynamics." AIAA Education Series, Revised Edition, 1999. |

View File

@ -0,0 +1,339 @@
---
title: "Functions: DE Ephemeris"
sidebar:
order: 8
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Optional high-precision function variants that use JPL DE440/441 ephemeris files when configured via the `pg_orrery.ephemeris_path` GUC. Each function mirrors an existing VSOP87/ELP2000-82B counterpart with identical parameters and return types.
All DE functions are `STABLE STRICT PARALLEL SAFE` (except `pg_orrery_ephemeris_info` which is `STABLE PARALLEL SAFE`, not STRICT). When DE is unavailable, they fall back to their VSOP87/ELP2000-82B equivalents.
See the [DE Ephemeris guide](/guides/de-ephemeris/) for setup and configuration.
---
## planet_heliocentric_de
Computes the heliocentric ecliptic J2000 position of a planet using DE positions when available, falling back to VSOP87.
### Signature
```sql
planet_heliocentric_de(body_id int4, t timestamptz) → heliocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `body_id` | `int4` | Planet identifier: 0 (Sun), 1-8 (Mercury through Neptune) |
| `t` | `timestamptz` | Evaluation time |
### Returns
A `heliocentric` position in AU (ecliptic J2000 frame). Identical return type to `planet_heliocentric()`.
### Example
```sql
-- Compare DE vs VSOP87 heliocentric distances
SELECT body_id,
round(helio_distance(planet_heliocentric(body_id, '2024-06-21 12:00:00+00'))::numeric, 10) AS vsop87,
round(helio_distance(planet_heliocentric_de(body_id, '2024-06-21 12:00:00+00'))::numeric, 10) AS de
FROM generate_series(1, 8) AS body_id;
```
---
## planet_observe_de
Computes the topocentric position of a planet using DE ephemeris for both the target and Earth positions.
### Signature
```sql
planet_observe_de(body_id int4, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `body_id` | `int4` | Planet identifier (1-8, excluding 3/Earth) |
| `obs` | `observer` | Observer location on Earth |
| `t` | `timestamptz` | Observation time |
<Aside type="caution">
Body IDs 0 (Sun) and 3 (Earth) are not valid. Use `sun_observe_de()` for the Sun. Observing Earth from Earth raises an error.
</Aside>
### Returns
A `topocentric` with azimuth, elevation, range (km), and range rate (km/s).
### Example
```sql
-- Mars position from Boulder using DE
SELECT round(topo_azimuth(t)::numeric, 4) AS az,
round(topo_elevation(t)::numeric, 4) AS el,
round(topo_range(t)::numeric, 0) AS range_km
FROM planet_observe_de(4, '40.0N 105.3W 1655m'::observer, now()) AS t;
```
---
## sun_observe_de
Computes the topocentric position of the Sun using DE positions.
### Signature
```sql
sun_observe_de(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
SELECT round(topo_azimuth(t)::numeric, 2) AS az,
round(topo_elevation(t)::numeric, 2) AS el
FROM sun_observe_de('40.0N 105.3W 1655m'::observer, now()) AS t;
```
---
## moon_observe_de
Computes the topocentric position of the Moon using DE positions. Falls back to ELP2000-82B when DE is unavailable.
### Signature
```sql
moon_observe_de(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
SELECT round(topo_azimuth(t)::numeric, 2) AS az,
round(topo_elevation(t)::numeric, 2) AS el,
round(topo_range(t)::numeric, 0) AS range_km
FROM moon_observe_de('40.0N 105.3W 1655m'::observer, now()) AS t;
```
---
## lambert_transfer_de
Computes a Lambert transfer orbit using DE-precision planet positions.
### Signature
```sql
lambert_transfer_de(
dep_body_id int4,
arr_body_id int4,
dep_time timestamptz,
arr_time timestamptz
) → RECORD(c3_departure float8, c3_arrival float8, v_inf_dep float8, v_inf_arr float8, tof_days float8)
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `dep_body_id` | `int4` | Departure planet (1-8) |
| `arr_body_id` | `int4` | Arrival planet (1-8, different from departure) |
| `dep_time` | `timestamptz` | Departure epoch |
| `arr_time` | `timestamptz` | Arrival epoch (must be after departure) |
### Returns
Transfer orbit parameters including departure and arrival C3 (km^2/s^2), v-infinity magnitudes, and time of flight.
### Example
```sql
-- Earth-Mars transfer window with DE positions
SELECT round(c3_departure::numeric, 2) AS c3_dep,
round(c3_arrival::numeric, 2) AS c3_arr,
round(tof_days::numeric, 1) AS tof
FROM lambert_transfer_de(3, 4, '2026-05-01 00:00:00+00', '2027-01-15 00:00:00+00');
```
---
## lambert_c3_de
Returns only the departure C3 (km^2/s^2) from a Lambert transfer computation using DE positions. A convenience wrapper around `lambert_transfer_de()`.
### Signature
```sql
lambert_c3_de(dep_body_id int4, arr_body_id int4, dep_time timestamptz, arr_time timestamptz) → float8
```
### Example
```sql
-- Pork chop grid with DE accuracy
SELECT dep::date AS departure,
arr::date AS arrival,
round(lambert_c3_de(3, 4, dep, arr)::numeric, 2) AS c3
FROM generate_series('2026-04-01'::timestamptz, '2026-08-01'::timestamptz, '14 days') dep,
generate_series('2026-12-01'::timestamptz, '2027-04-01'::timestamptz, '14 days') arr
WHERE arr > dep + interval '90 days';
```
---
## galilean_observe_de
Computes the topocentric position of a Galilean moon of Jupiter. Uses DE for Jupiter's heliocentric position and L1.2 theory for the moon's offset.
### Signature
```sql
galilean_observe_de(moon_id int4, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `moon_id` | `int4` | 0=Io, 1=Europa, 2=Ganymede, 3=Callisto |
| `obs` | `observer` | Observer location |
| `t` | `timestamptz` | Observation time |
### Example
```sql
-- All four Galilean moons with DE-precision Jupiter
SELECT moon_id,
CASE moon_id WHEN 0 THEN 'Io' WHEN 1 THEN 'Europa'
WHEN 2 THEN 'Ganymede' WHEN 3 THEN 'Callisto' END AS moon,
round(topo_elevation(galilean_observe_de(moon_id, '40.0N 105.3W 1655m'::observer, now()))::numeric, 2) AS el
FROM generate_series(0, 3) AS moon_id;
```
---
## saturn_moon_observe_de
Computes the topocentric position of a Saturn moon. Uses DE for Saturn's heliocentric position and TASS17 theory for the moon's offset.
### Signature
```sql
saturn_moon_observe_de(moon_id int4, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `moon_id` | `int4` | 0=Mimas, 1=Enceladus, 2=Tethys, 3=Dione, 4=Rhea, 5=Titan, 6=Iapetus, 7=Hyperion |
| `obs` | `observer` | Observer location |
| `t` | `timestamptz` | Observation time |
---
## uranus_moon_observe_de
Computes the topocentric position of a Uranus moon. Uses DE for Uranus's heliocentric position and GUST86 theory for the moon's offset.
### Signature
```sql
uranus_moon_observe_de(moon_id int4, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `moon_id` | `int4` | 0=Miranda, 1=Ariel, 2=Umbriel, 3=Titania, 4=Oberon |
| `obs` | `observer` | Observer location |
| `t` | `timestamptz` | Observation time |
---
## mars_moon_observe_de
Computes the topocentric position of a Mars moon. Uses DE for Mars's heliocentric position and MarsSat theory for the moon's offset.
### Signature
```sql
mars_moon_observe_de(moon_id int4, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `moon_id` | `int4` | 0=Phobos, 1=Deimos |
| `obs` | `observer` | Observer location |
| `t` | `timestamptz` | Observation time |
---
## pg_orrery_ephemeris_info
Returns diagnostic information about the current ephemeris provider.
### Signature
```sql
pg_orrery_ephemeris_info() → RECORD(provider text, file_path text, start_jd float8, end_jd float8, version int4, au_km float8)
```
<Aside type="note">
This function is `STABLE PARALLEL SAFE` but **not** `STRICT` --- it takes no arguments and always returns a row.
</Aside>
### Returns
| Column | Type | Description |
|--------|------|-------------|
| `provider` | `text` | `'VSOP87'` or `'JPL_DE'` |
| `file_path` | `text` | Path to the DE file (empty string if no DE) |
| `start_jd` | `float8` | First Julian Date covered by the DE file |
| `end_jd` | `float8` | Last Julian Date covered by the DE file |
| `version` | `int4` | DE version number (440, 441, etc.) |
| `au_km` | `float8` | Astronomical Unit in km from the DE header |
### Example
```sql
-- Check current provider
SELECT (pg_orrery_ephemeris_info()).provider;
-- Full diagnostic
SELECT * FROM pg_orrery_ephemeris_info();
```

View File

@ -0,0 +1,256 @@
---
title: "Functions: Moons"
sidebar:
order: 4
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Functions for observing the natural satellites of Jupiter, Saturn, Uranus, and Mars. Each moon family uses a dedicated analytical theory for computing satellite positions relative to the parent planet, which is then transformed to Earth-based topocentric coordinates.
---
## galilean_observe
Computes the topocentric position of a Galilean moon of Jupiter as seen from an Earth-based observer. Uses the Lieske L1.2 theory (Lieske, 1998).
### Signature
```sql
galilean_observe(moon_id int4, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `moon_id` | `int4` | Galilean moon identifier (see table below) |
| `obs` | `observer` | Observer location on Earth |
| `t` | `timestamptz` | Observation time |
#### Moon IDs
| ID | Moon | Orbital Period |
|----|------|----------------|
| 0 | Io | 1.769 days |
| 1 | Europa | 3.551 days |
| 2 | Ganymede | 7.155 days |
| 3 | Callisto | 16.689 days |
### Returns
A `topocentric` with azimuth, elevation, range (km), and range rate (km/s).
### Example
```sql
-- Current positions of all four Galilean moons
SELECT moon_id,
CASE moon_id
WHEN 0 THEN 'Io' WHEN 1 THEN 'Europa'
WHEN 2 THEN 'Ganymede' WHEN 3 THEN 'Callisto'
END AS moon,
round(topo_azimuth(t)::numeric, 4) AS az,
round(topo_elevation(t)::numeric, 4) AS el,
round(topo_range(t)::numeric, 0) AS range_km
FROM generate_series(0, 3) AS moon_id,
galilean_observe(moon_id, '40.0N 105.3W 1655m'::observer, now()) AS t;
```
```sql
-- Track Io's position over one orbital period (1.769 days)
SELECT t,
round(topo_azimuth(pos)::numeric, 4) AS az,
round(topo_elevation(pos)::numeric, 4) AS el
FROM generate_series(
now(), now() + interval '1.769 days', interval '15 minutes'
) AS t,
galilean_observe(0, '40.0N 105.3W 1655m'::observer, t) AS pos;
```
---
## saturn_moon_observe
Computes the topocentric position of a moon of Saturn as seen from an Earth-based observer. Uses the TASS17 theory (Vienne & Duriez, 1995) for the eight major Saturnian moons.
### Signature
```sql
saturn_moon_observe(moon_id int4, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `moon_id` | `int4` | Saturn moon identifier (see table below) |
| `obs` | `observer` | Observer location on Earth |
| `t` | `timestamptz` | Observation time |
#### Moon IDs
| ID | Moon | Orbital Period | Approx. Magnitude |
|----|------|----------------|-------------------|
| 0 | Mimas | 0.942 days | +12.9 |
| 1 | Enceladus | 1.370 days | +11.7 |
| 2 | Tethys | 1.888 days | +10.2 |
| 3 | Dione | 2.737 days | +10.4 |
| 4 | Rhea | 4.518 days | +9.7 |
| 5 | Titan | 15.945 days | +8.3 |
| 6 | Iapetus | 79.322 days | +10.2-11.9 |
| 7 | Hyperion | 21.277 days | +14.2 |
<Aside type="note">
Iapetus has a large brightness variation due to its two-toned surface — the leading hemisphere is much darker than the trailing hemisphere. The magnitude range reflects this dichotomy.
</Aside>
### Returns
A `topocentric` with azimuth, elevation, range (km), and range rate (km/s).
### Example
```sql
-- All eight Saturn moons' current positions
SELECT moon_id,
CASE moon_id
WHEN 0 THEN 'Mimas' WHEN 1 THEN 'Enceladus'
WHEN 2 THEN 'Tethys' WHEN 3 THEN 'Dione'
WHEN 4 THEN 'Rhea' WHEN 5 THEN 'Titan'
WHEN 6 THEN 'Iapetus' WHEN 7 THEN 'Hyperion'
END AS moon,
round(topo_azimuth(t)::numeric, 4) AS az,
round(topo_elevation(t)::numeric, 4) AS el,
round(topo_range(t)::numeric, 0) AS range_km
FROM generate_series(0, 7) AS moon_id,
saturn_moon_observe(moon_id, '40.0N 105.3W 1655m'::observer, now()) AS t;
```
```sql
-- Track Titan over one orbital period
SELECT t,
round(topo_azimuth(pos)::numeric, 4) AS az,
round(topo_elevation(pos)::numeric, 4) AS el
FROM generate_series(
now(), now() + interval '15.945 days', interval '1 hour'
) AS t,
saturn_moon_observe(5, '40.0N 105.3W 1655m'::observer, t) AS pos;
```
---
## uranus_moon_observe
Computes the topocentric position of a moon of Uranus as seen from an Earth-based observer. Uses the GUST86 theory (Laskar & Jacobson, 1987).
### Signature
```sql
uranus_moon_observe(moon_id int4, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `moon_id` | `int4` | Uranus moon identifier (see table below) |
| `obs` | `observer` | Observer location on Earth |
| `t` | `timestamptz` | Observation time |
#### Moon IDs
| ID | Moon | Orbital Period | Approx. Magnitude |
|----|------|----------------|-------------------|
| 0 | Miranda | 1.413 days | +16.5 |
| 1 | Ariel | 2.520 days | +14.2 |
| 2 | Umbriel | 4.144 days | +14.8 |
| 3 | Titania | 8.706 days | +13.9 |
| 4 | Oberon | 13.463 days | +14.1 |
<Aside type="caution">
The Uranian moons are faint. Miranda at magnitude +16.5 requires a large aperture telescope. Titania and Oberon are the most accessible at around magnitude +14.
</Aside>
### Returns
A `topocentric` with azimuth, elevation, range (km), and range rate (km/s).
### Example
```sql
-- All five Uranus moons' current positions
SELECT moon_id,
CASE moon_id
WHEN 0 THEN 'Miranda' WHEN 1 THEN 'Ariel'
WHEN 2 THEN 'Umbriel' WHEN 3 THEN 'Titania'
WHEN 4 THEN 'Oberon'
END AS moon,
round(topo_azimuth(t)::numeric, 4) AS az,
round(topo_elevation(t)::numeric, 4) AS el,
round(topo_range(t)::numeric, 0) AS range_km
FROM generate_series(0, 4) AS moon_id,
uranus_moon_observe(moon_id, '40.0N 105.3W 1655m'::observer, now()) AS t;
```
---
## mars_moon_observe
Computes the topocentric position of a moon of Mars as seen from an Earth-based observer. Uses the MarsSat analytical theory.
### Signature
```sql
mars_moon_observe(moon_id int4, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `moon_id` | `int4` | Mars moon identifier (see table below) |
| `obs` | `observer` | Observer location on Earth |
| `t` | `timestamptz` | Observation time |
#### Moon IDs
| ID | Moon | Orbital Period | Approx. Magnitude |
|----|------|----------------|-------------------|
| 0 | Phobos | 0.319 days (7.66 hours) | +11.4 |
| 1 | Deimos | 1.263 days | +12.5 |
<Aside type="note">
Phobos and Deimos are extremely close to Mars and are typically overwhelmed by Mars's glare. Observations are best attempted near Mars opposition when the planet is closest to Earth and the moons have maximum angular separation.
</Aside>
### Returns
A `topocentric` with azimuth, elevation, range (km), and range rate (km/s).
### Example
```sql
-- Phobos and Deimos positions from Boulder
SELECT moon_id,
CASE moon_id
WHEN 0 THEN 'Phobos'
WHEN 1 THEN 'Deimos'
END AS moon,
round(topo_azimuth(t)::numeric, 4) AS az,
round(topo_elevation(t)::numeric, 4) AS el,
round(topo_range(t)::numeric, 0) AS range_km
FROM generate_series(0, 1) AS moon_id,
mars_moon_observe(moon_id, '40.0N 105.3W 1655m'::observer, now()) AS t;
```
```sql
-- Track Phobos through one full orbit (~7.66 hours)
SELECT t,
round(topo_azimuth(pos)::numeric, 4) AS az,
round(topo_elevation(pos)::numeric, 4) AS el
FROM generate_series(
now(), now() + interval '7.66 hours', interval '5 minutes'
) AS t,
mars_moon_observe(0, '40.0N 105.3W 1655m'::observer, t) AS pos;
```

View File

@ -0,0 +1,193 @@
---
title: "Functions: Radio"
sidebar:
order: 6
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Functions for predicting Jupiter decametric radio emissions. Jupiter is the strongest radio source in the solar system after the Sun, producing bursts in the 10-40 MHz range driven by the interaction between Io and Jupiter's magnetosphere. These functions compute the geometric parameters needed to predict when bursts are likely.
---
## io_phase_angle
Computes the orbital phase angle of Io relative to Jupiter's superior conjunction as seen from Earth. The phase angle determines the position of Io in its orbit as projected against Jupiter's disk, which is one of two parameters needed for burst prediction.
### Signature
```sql
io_phase_angle(t timestamptz) → float8
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `t` | `timestamptz` | Evaluation time |
### Returns
Io's orbital phase angle in degrees, range [0, 360).
- **0** = superior conjunction (Io behind Jupiter, as seen from Earth)
- **90** = eastern elongation (Io east of Jupiter)
- **180** = inferior conjunction (Io between Earth and Jupiter)
- **270** = western elongation (Io west of Jupiter)
### Example
```sql
-- Current Io phase angle
SELECT round(io_phase_angle(now())::numeric, 1) AS io_phase;
```
```sql
-- Io phase over the next 24 hours at 30-minute intervals
SELECT t,
round(io_phase_angle(t)::numeric, 1) AS io_phase
FROM generate_series(now(), now() + interval '24 hours', interval '30 minutes') AS t;
```
---
## jupiter_cml
Computes Jupiter's Central Meridian Longitude (CML) in System III (1965.0) as seen from an Earth-based observer. System III is tied to Jupiter's magnetic field rotation (period = 9h 55m 29.711s) and is the standard reference for radio astronomy.
The result is corrected for light travel time between Jupiter and the observer.
### Signature
```sql
jupiter_cml(obs observer, t timestamptz) → float8
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `obs` | `observer` | Observer location on Earth |
| `t` | `timestamptz` | Observation time |
### Returns
Central Meridian Longitude in degrees, range [0, 360). This is the longitude of the Jovian meridian facing the observer at the given time, in System III coordinates.
<Aside type="note">
The CML depends on the observer's position on Earth because it is corrected for light travel time. The difference between two observers on opposite sides of Earth is small (order of 0.01 degrees) but is included for correctness.
</Aside>
### Example
```sql
-- Current Jupiter CML from Boulder
SELECT round(jupiter_cml('40.0N 105.3W 1655m'::observer, now())::numeric, 1) AS cml;
```
```sql
-- CML sweep over one Jupiter rotation (~9h 55m)
SELECT t,
round(jupiter_cml('40.0N 105.3W 1655m'::observer, t)::numeric, 1) AS cml
FROM generate_series(
now(),
now() + interval '9 hours 55 minutes',
interval '10 minutes'
) AS t;
```
---
## jupiter_burst_probability
Computes the probability of detecting a Jupiter decametric radio burst given the current Io phase angle and Jupiter CML. Based on the Carr, Desch & Alexander (1983) source region model.
The function evaluates whether the Io phase and CML fall within one of the known emission source regions and returns a probability between 0 and 1.
### Signature
```sql
jupiter_burst_probability(io_phase float8, cml float8) → float8
```
### Parameters
| Parameter | Type | Unit | Description |
|-----------|------|------|-------------|
| `io_phase` | `float8` | degrees | Io orbital phase angle (output of `io_phase_angle`) |
| `cml` | `float8` | degrees | Jupiter CML System III (output of `jupiter_cml`) |
### Returns
Burst probability as a value from 0.0 to 1.0.
### Source Regions
The Carr model identifies four primary Io-related source regions in the Io phase vs. CML parameter space:
| Source | Io Phase Range | CML Range | Description |
|--------|----------------|-----------|-------------|
| **Io-A** | 195-265 | 200-290 | Strongest Io-related source. Io near western elongation, CML in the 200-290 range. |
| **Io-B** | 75-105 | 95-195 | Second strongest. Io near eastern elongation, CML roughly opposite to Io-A. |
| **Io-C** | 195-265 | 290-10 | Weaker Io-related source. Same Io phase as Io-A but different CML range. |
| **Io-D** | 75-105 | 0-95 | Weakest of the four. Same Io phase as Io-B but CML shifted. |
<Aside type="tip">
Non-Io emissions also occur (sources A, B, C without Io dependency) but are weaker and less predictable. The probability returned by this function reflects the combined Io-dependent likelihood.
</Aside>
### Example
```sql
-- Current burst probability
SELECT round(
jupiter_burst_probability(
io_phase_angle(now()),
jupiter_cml('40.0N 105.3W 1655m'::observer, now())
)::numeric, 3
) AS burst_prob;
```
```sql
-- Find high-probability windows tonight
SELECT t,
round(io_phase_angle(t)::numeric, 1) AS io_phase,
round(jupiter_cml('40.0N 105.3W 1655m'::observer, t)::numeric, 1) AS cml,
round(jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
)::numeric, 3) AS probability
FROM generate_series(
'2024-06-15 02:00:00+00',
'2024-06-15 10:00:00+00',
interval '5 minutes'
) AS t
WHERE jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
) > 0.2
ORDER BY probability DESC;
```
```sql
-- Full radio observing plan: combine burst probability with Jupiter visibility
SELECT t,
round(topo_elevation(jup)::numeric, 1) AS jupiter_el,
round(io_phase_angle(t)::numeric, 1) AS io_phase,
round(jupiter_cml('40.0N 105.3W 1655m'::observer, t)::numeric, 1) AS cml,
round(jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
)::numeric, 3) AS burst_prob
FROM generate_series(
'2024-06-15 02:00:00+00',
'2024-06-15 10:00:00+00',
interval '10 minutes'
) AS t,
planet_observe(5, '40.0N 105.3W 1655m'::observer, t) AS jup
WHERE topo_elevation(jup) > 10
AND jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
) > 0.1;
```

View File

@ -0,0 +1,529 @@
---
title: "Functions: Satellite"
sidebar:
order: 2
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Functions for propagating TLEs, converting coordinate frames, computing ground tracks, and predicting satellite passes. These form the core satellite tracking pipeline in pg_orrery.
---
## sgp4_propagate
Propagates a TLE to a given time using the SGP4 (near-earth) or SDP4 (deep-space) algorithm. The algorithm is selected automatically based on orbital period: elements with a period >= 225 minutes use SDP4.
### Signature
```sql
sgp4_propagate(tle tle, t timestamptz) → eci_position
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `tle` | `tle` | Two-Line Element set to propagate |
| `t` | `timestamptz` | Target epoch for propagation |
### Returns
An `eci_position` in the TEME reference frame. Position in km, velocity in km/s.
### Errors
Raises an exception if SGP4/SDP4 returns a fatal error code (e.g., satellite decay, eccentricity out of range, mean motion near zero). Use `sgp4_propagate_safe` if you need NULL-on-error behavior.
<Aside type="caution">
SGP4 accuracy degrades as you propagate further from the TLE epoch. For LEO satellites, errors grow roughly 1-3 km per day. Keep TLEs fresh.
</Aside>
### 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 eci_x(pos) AS x_km,
eci_y(pos) AS y_km,
eci_z(pos) AS z_km,
eci_speed(pos) AS speed_kms
FROM iss, sgp4_propagate(tle, '2024-01-02 12:00:00+00') AS pos;
```
---
## sgp4_propagate_safe
Identical to `sgp4_propagate`, but returns NULL instead of raising an exception on propagation errors. This is the batch-safe variant for processing large TLE catalogs where some elements may be stale or invalid.
### Signature
```sql
sgp4_propagate_safe(tle tle, t timestamptz) → eci_position
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `tle` | `tle` | Two-Line Element set to propagate |
| `t` | `timestamptz` | Target epoch for propagation |
### Returns
An `eci_position`, or NULL if propagation fails.
### Example
```sql
-- Propagate an entire catalog, skipping failed elements
SELECT norad_id,
eci_x(pos) AS x_km,
eci_y(pos) AS y_km,
eci_z(pos) AS z_km
FROM satellite_catalog,
sgp4_propagate_safe(tle, now()) AS pos
WHERE pos IS NOT NULL;
```
---
## sgp4_propagate_series
Generates a time series of TEME ECI positions for a single TLE over a time range. Returns one row per time step. This is significantly faster than calling `sgp4_propagate` inside a `generate_series` because the SGP4 initializer runs once.
### Signature
```sql
sgp4_propagate_series(
tle tle,
start_time timestamptz,
end_time timestamptz,
step interval
) → TABLE(t timestamptz, x float8, y float8, z float8, vx float8, vy float8, vz float8)
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `tle` | `tle` | Two-Line Element set |
| `start_time` | `timestamptz` | Start of the time range |
| `end_time` | `timestamptz` | End of the time range (inclusive if aligned to step) |
| `step` | `interval` | Time between samples |
### Returns
A set of rows with timestamp and TEME position/velocity components. Position in km, velocity in km/s.
### 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 t, x, y, z, vx, vy, vz
FROM iss,
sgp4_propagate_series(tle,
'2024-01-02 00:00:00+00',
'2024-01-02 01:00:00+00',
interval '1 minute');
```
---
## tle_distance
Computes the Euclidean distance between two satellites at a given time. Both TLEs are propagated to the target time and the 3D distance between their TEME positions is returned.
### Signature
```sql
tle_distance(a tle, b tle, t timestamptz) → float8
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `a` | `tle` | First satellite |
| `b` | `tle` | Second satellite |
| `t` | `timestamptz` | Evaluation time |
### Returns
Distance in kilometers. Raises an exception if either TLE fails to propagate.
### Example
```sql
-- Distance between two satellites at a specific time
SELECT tle_distance(sat_a.tle, sat_b.tle, '2024-06-15 12:00:00+00') AS dist_km
FROM satellite_catalog sat_a, satellite_catalog sat_b
WHERE sat_a.norad_id = 25544 -- ISS
AND sat_b.norad_id = 48274; -- CSS (Tianhe)
```
---
## eci_to_geodetic
Converts a TEME ECI position to WGS-84 geodetic coordinates. The timestamp is required to compute the Earth's rotation angle (Greenwich Apparent Sidereal Time).
### Signature
```sql
eci_to_geodetic(pos eci_position, t timestamptz) → geodetic
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `pos` | `eci_position` | TEME ECI position |
| `t` | `timestamptz` | Time of the position (for sidereal time computation) |
### Returns
A `geodetic` with WGS-84 latitude, longitude, and altitude.
### 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 geo_lat(g) AS lat,
geo_lon(g) AS lon,
geo_alt(g) AS alt_km
FROM iss,
eci_to_geodetic(sgp4_propagate(tle, now()), now()) AS g;
```
---
## eci_to_topocentric
Converts a TEME ECI position to topocentric (observer-relative) coordinates. Computes azimuth, elevation, slant range, and range rate.
### Signature
```sql
eci_to_topocentric(pos eci_position, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `pos` | `eci_position` | TEME ECI position and velocity |
| `obs` | `observer` | Observer location |
| `t` | `timestamptz` | Time of the position (for sidereal time computation) |
### Returns
A `topocentric` with azimuth, elevation, range, and range rate.
### 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 topo_azimuth(tc) AS az,
topo_elevation(tc) AS el,
topo_range(tc) AS range_km,
topo_range_rate(tc) AS range_rate_kms
FROM iss,
eci_to_topocentric(
sgp4_propagate(tle, now()),
'40.0N 105.3W 1655m'::observer,
now()
) AS tc;
```
---
## subsatellite_point
Returns the nadir (directly below the satellite) point on the WGS-84 ellipsoid for a given TLE at a given time. This is a convenience function equivalent to propagating and then converting to geodetic, but with altitude set to the satellite altitude.
### Signature
```sql
subsatellite_point(tle tle, t timestamptz) → geodetic
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `tle` | `tle` | Satellite TLE |
| `t` | `timestamptz` | Evaluation time |
### Returns
A `geodetic` with the latitude, longitude, and altitude of the satellite above the WGS-84 ellipsoid.
### 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 geo_lat(sp) AS nadir_lat,
geo_lon(sp) AS nadir_lon,
geo_alt(sp) AS altitude_km
FROM iss, subsatellite_point(tle, now()) AS sp;
```
---
## ground_track
Generates a time series of subsatellite points (nadir ground track) for a satellite over a time range. Each row contains the timestamp, latitude, longitude, and altitude.
### Signature
```sql
ground_track(
tle tle,
start_time timestamptz,
end_time timestamptz,
step interval
) → TABLE(t timestamptz, lat float8, lon float8, alt float8)
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `tle` | `tle` | Satellite TLE |
| `start_time` | `timestamptz` | Start of the time range |
| `end_time` | `timestamptz` | End of the time range |
| `step` | `interval` | Time between samples |
### Returns
A set of rows with timestamp, latitude (degrees), longitude (degrees), and altitude (km).
### Example
```sql
-- ISS ground track for one orbit (~92 minutes) at 30-second resolution
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 t, lat, lon, alt
FROM iss, ground_track(tle, now(), now() + interval '92 minutes', interval '30 seconds');
```
---
## observe
Propagates a TLE and computes topocentric look angles in a single call. Equivalent to `eci_to_topocentric(sgp4_propagate(tle, t), obs, t)`, but avoids the intermediate allocation.
### Signature
```sql
observe(tle tle, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `tle` | `tle` | Satellite TLE |
| `obs` | `observer` | Observer location |
| `t` | `timestamptz` | Observation time |
### Returns
A `topocentric` with azimuth, elevation, range, and range rate.
### 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 topo_azimuth(o) AS az,
topo_elevation(o) AS el,
topo_range(o) AS range_km
FROM iss, observe(tle, '40.0N 105.3W 1655m'::observer, now()) AS o;
```
---
## observe_safe
Identical to `observe`, but returns NULL instead of raising an exception on propagation errors. Use this when processing large TLE catalogs in batch.
### Signature
```sql
observe_safe(tle tle, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `tle` | `tle` | Satellite TLE |
| `obs` | `observer` | Observer location |
| `t` | `timestamptz` | Observation time |
### Returns
A `topocentric`, or NULL if propagation fails.
### Example
```sql
-- Find all satellites above the horizon right now, skipping stale TLEs
SELECT norad_id,
topo_azimuth(o) AS az,
topo_elevation(o) AS 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
ORDER BY topo_elevation(o) DESC;
```
---
## next_pass
Finds the next satellite pass over an observer location. Searches forward from the given start time up to 7 days.
### Signature
```sql
next_pass(tle tle, obs observer, start timestamptz) → pass_event
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `tle` | `tle` | Satellite TLE |
| `obs` | `observer` | Observer location |
| `start` | `timestamptz` | Time to begin searching from |
### Returns
A `pass_event` with AOS, maximum elevation, LOS, azimuths, and duration. Returns NULL if no pass is found within 7 days (possible for equatorial observers looking for high-inclination satellites, or vice versa).
<Aside type="tip">
For finding multiple passes, use `predict_passes` instead. `next_pass` is optimized for the single-pass case (e.g., "when is the ISS visible next?").
</Aside>
### 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 pass_aos_time(p) AS rise,
pass_max_elevation(p) AS max_el,
pass_los_time(p) AS set,
pass_duration(p) AS duration
FROM iss, next_pass(tle, '40.0N 105.3W 1655m'::observer, now()) AS p;
```
---
## predict_passes
Finds all satellite passes over an observer within a time window, optionally filtered by minimum elevation. Returns a set of `pass_event` records.
### Signature
```sql
predict_passes(
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.
### Example
```sql
-- All ISS passes above 20 degrees in the next 3 days
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_aos_azimuth(p) AS rise_az,
pass_los_azimuth(p) AS set_az,
pass_duration(p) AS dur
FROM iss,
predict_passes(tle, '40.0N 105.3W 1655m'::observer,
now(), now() + interval '3 days', 20.0) AS p;
```
---
## pass_visible
Returns true if at least one satellite pass occurs over the observer during the given time window.
### Signature
```sql
pass_visible(tle tle, obs observer, start_time timestamptz, end_time timestamptz) → boolean
```
### Parameters
| Parameter | Type | 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 |
### Returns
`true` if any pass (elevation > 0) occurs in the window; `false` otherwise. This is faster than `predict_passes` when you only need a yes/no answer because it stops searching after the first pass is found.
### Example
```sql
-- Which satellites from the catalog pass over Boulder tonight?
SELECT norad_id, name
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');
```

View File

@ -0,0 +1,230 @@
---
title: "Functions: Solar System"
sidebar:
order: 3
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Functions for computing planetary positions, observing the Sun, Moon, and planets from an Earth-based observer. Planetary positions use the VSOP87 theory (Bretagnon & Francou, 1988). Lunar position uses ELP2000-82B (Chapront-Touze & Chapront, 1983). All functions are `IMMUTABLE STRICT PARALLEL SAFE`.
For higher precision, v0.3.0 adds optional `_de()` variants that use JPL DE440/441 ephemeris files. See [Functions: DE Ephemeris](/reference/functions-de/).
---
## planet_heliocentric
Computes the heliocentric ecliptic J2000 position of a solar system body using VSOP87 series C (heliocentric, ecliptic, rectangular). Returns position in Astronomical Units.
### Signature
```sql
planet_heliocentric(body_id int4, t timestamptz) → heliocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `body_id` | `int4` | Planet identifier (see table below) |
| `t` | `timestamptz` | Evaluation time |
#### Body IDs
| ID | Body |
|----|------|
| 0 | Sun (returns origin: 0, 0, 0) |
| 1 | Mercury |
| 2 | Venus |
| 3 | Earth |
| 4 | Mars |
| 5 | Jupiter |
| 6 | Saturn |
| 7 | Uranus |
| 8 | Neptune |
### Returns
A `heliocentric` position in AU (ecliptic J2000 frame). For `body_id = 0` (Sun), all components are zero.
<Aside type="note">
VSOP87 is a truncated Fourier series. Accuracy varies by planet and epoch. For the inner planets within +/- 4000 years of J2000, positional accuracy is sub-arcsecond. For the outer planets, accuracy is a few arcseconds. See [Constants & Accuracy](/reference/constants-accuracy/) for detailed bounds.
</Aside>
### Example
```sql
-- Distance of each planet from the Sun
SELECT body_id,
CASE body_id
WHEN 1 THEN 'Mercury' WHEN 2 THEN 'Venus'
WHEN 3 THEN 'Earth' WHEN 4 THEN 'Mars'
WHEN 5 THEN 'Jupiter' WHEN 6 THEN 'Saturn'
WHEN 7 THEN 'Uranus' WHEN 8 THEN 'Neptune'
END AS planet,
round(helio_distance(planet_heliocentric(body_id, now()))::numeric, 6) AS dist_au
FROM generate_series(1, 8) AS body_id;
```
```sql
-- Earth's position over one year at weekly intervals
SELECT t,
helio_x(h) AS x_au,
helio_y(h) AS y_au,
helio_z(h) AS z_au
FROM generate_series(
'2024-01-01'::timestamptz,
'2025-01-01'::timestamptz,
interval '7 days'
) AS t,
planet_heliocentric(3, t) AS h;
```
---
## planet_observe
Computes the topocentric position of a planet as seen from an Earth-based observer. Internally computes the heliocentric positions of both Earth and the target planet, applies geometric transformation to geocentric, then converts to topocentric coordinates.
### Signature
```sql
planet_observe(body_id int4, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `body_id` | `int4` | Planet identifier (1-8, same as `planet_heliocentric` excluding 0 and 3) |
| `obs` | `observer` | Observer location on Earth |
| `t` | `timestamptz` | Observation time |
<Aside type="caution">
`body_id` 0 (Sun) and 3 (Earth) are not valid for `planet_observe`. Use `sun_observe` for the Sun. Observing Earth from Earth is not defined.
</Aside>
### Returns
A `topocentric` with azimuth, elevation, range (km), and range rate (km/s).
### Example
```sql
-- Where is Mars tonight from Greenwich?
SELECT topo_azimuth(t) AS az_deg,
topo_elevation(t) AS el_deg,
topo_range(t) / 149597870.7 AS dist_au
FROM planet_observe(4, '51.4769N 0.0005W 11m'::observer, now()) AS t;
```
```sql
-- All planets' current positions from Boulder
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(topo_azimuth(t)::numeric, 2) AS az,
round(topo_elevation(t)::numeric, 2) AS el
FROM unnest(ARRAY[1,2,4,5,6,7,8]) AS body_id,
planet_observe(body_id, '40.0N 105.3W 1655m'::observer, now()) AS t
ORDER BY topo_elevation(t) DESC;
```
---
## sun_observe
Computes the topocentric position of the Sun from an Earth-based observer.
### Signature
```sql
sun_observe(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 right now
SELECT topo_azimuth(t) AS az,
topo_elevation(t) AS el,
topo_range(t) / 149597870.7 AS dist_au
FROM sun_observe('40.0N 105.3W 1655m'::observer, now()) AS t;
```
```sql
-- Find today's solar noon (maximum elevation)
SELECT t,
round(topo_elevation(s)::numeric, 2) AS el
FROM generate_series(
now()::date::timestamptz,
now()::date::timestamptz + interval '24 hours',
interval '1 minute'
) AS t,
sun_observe('40.0N 105.3W 1655m'::observer, t) AS s
ORDER BY topo_elevation(s) DESC
LIMIT 1;
```
---
## moon_observe
Computes the topocentric position of the Moon from an Earth-based observer. Uses the ELP2000-82B lunar theory (Chapront-Touze & Chapront, 1983).
### Signature
```sql
moon_observe(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). The Moon's range is typically 356,500 to 406,700 km.
### Example
```sql
-- Current Moon position and distance
SELECT topo_azimuth(t) AS az,
topo_elevation(t) AS el,
topo_range(t) AS range_km
FROM moon_observe('40.0N 105.3W 1655m'::observer, now()) AS t;
```
```sql
-- Moon's path across the sky tonight at 5-minute intervals
SELECT t,
round(topo_azimuth(m)::numeric, 1) AS az,
round(topo_elevation(m)::numeric, 1) AS el,
round(topo_range(m)::numeric, 0) AS range_km
FROM generate_series(
'2024-06-15 02:00:00+00',
'2024-06-15 10:00:00+00',
interval '5 minutes'
) AS t,
moon_observe('40.0N 105.3W 1655m'::observer, t) AS m
WHERE topo_elevation(m) > 0;
```

View File

@ -0,0 +1,257 @@
---
title: "Functions: Stars & Comets"
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.
---
## star_observe
Converts a J2000 equatorial position (right ascension and declination) to topocentric coordinates for an Earth-based observer. Applies sidereal time rotation and horizon transformation. Stars are treated as being at infinite distance, so `topo_range` is always 0.
### Signature
```sql
star_observe(ra_hours float8, dec_deg float8, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Unit | Description |
|-----------|------|------|-------------|
| `ra_hours` | `float8` | hours | Right Ascension in J2000 equatorial frame (0-24) |
| `dec_deg` | `float8` | degrees | Declination in J2000 equatorial frame (-90 to +90) |
| `obs` | `observer` | | Observer location on Earth |
| `t` | `timestamptz` | | Observation time |
### Returns
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.
</Aside>
### Example
```sql
-- Where is Sirius (RA 6h 45m 8.9s, Dec -16d 42m 58s)?
SELECT topo_azimuth(t) AS az,
topo_elevation(t) AS el
FROM star_observe(6.7525, -16.7161, '40.0N 105.3W 1655m'::observer, now()) AS t;
```
```sql
-- Which bright stars are above the horizon right now?
-- (Assumes a star_catalog table with ra_hours, dec_deg, magnitude columns)
SELECT name, magnitude,
round(topo_azimuth(t)::numeric, 1) AS az,
round(topo_elevation(t)::numeric, 1) AS el
FROM star_catalog,
star_observe(ra_hours, dec_deg, '40.0N 105.3W 1655m'::observer, now()) AS t
WHERE magnitude < 2.0
AND topo_elevation(t) > 0
ORDER BY magnitude;
```
---
## star_observe_safe
Identical to `star_observe`, but returns NULL instead of raising an exception on invalid inputs (e.g., RA outside 0-24, Dec outside -90 to +90).
### Signature
```sql
star_observe_safe(ra_hours float8, dec_deg float8, obs observer, t timestamptz) → topocentric
```
### Parameters
| Parameter | Type | Unit | Description |
|-----------|------|------|-------------|
| `ra_hours` | `float8` | hours | Right Ascension (0-24) |
| `dec_deg` | `float8` | degrees | Declination (-90 to +90) |
| `obs` | `observer` | | Observer location |
| `t` | `timestamptz` | | Observation time |
### Returns
A `topocentric`, or NULL if the input coordinates are invalid.
### Example
```sql
-- Batch-process a catalog, skipping any rows with bad coordinates
SELECT catalog_id, name,
topo_azimuth(t) AS az,
topo_elevation(t) AS el
FROM star_catalog,
star_observe_safe(ra_hours, dec_deg, '40.0N 105.3W 1655m'::observer, now()) AS t
WHERE t IS NOT NULL
AND topo_elevation(t) > 10;
```
---
## kepler_propagate
Propagates an object on a Keplerian orbit (two-body problem) to a given time. Returns the heliocentric ecliptic J2000 position in AU. Handles elliptic (e < 1), parabolic (e = 1), and hyperbolic (e > 1) orbits.
### Signature
```sql
kepler_propagate(
q_au float8,
ecc float8,
inc_deg float8,
arg_peri_deg float8,
long_node_deg float8,
perihelion_jd float8,
t timestamptz
) → heliocentric
```
### Parameters
| Parameter | Type | Unit | Description |
|-----------|------|------|-------------|
| `q_au` | `float8` | AU | Perihelion distance |
| `ecc` | `float8` | | Eccentricity. 0 < e < 1 for elliptic, e = 1 for parabolic, e > 1 for hyperbolic |
| `inc_deg` | `float8` | degrees | Orbital inclination |
| `arg_peri_deg` | `float8` | degrees | Argument of perihelion |
| `long_node_deg` | `float8` | degrees | Longitude of ascending node |
| `perihelion_jd` | `float8` | JD | Time of perihelion passage as Julian Date |
| `t` | `timestamptz` | | Evaluation time |
### Returns
A `heliocentric` position in AU (ecliptic J2000 frame).
<Aside type="tip">
Orbital elements for comets and asteroids are available from the [IAU Minor Planet Center](https://www.minorplanetcenter.net/iau/Ephemerides/Comets/Soft06Cmt.txt) and [JPL Small-Body Database](https://ssd.jpl.nasa.gov/tools/sbdb_query.html). The parameter names match the MPC format.
</Aside>
### Example
```sql
-- Propagate Comet Halley (1P/Halley)
-- q=0.586 AU, e=0.967, i=162.3, w=111.3, node=58.4, T=2446467.4 (1986 Feb 9)
SELECT helio_x(h) AS x_au,
helio_y(h) AS y_au,
helio_z(h) AS z_au,
helio_distance(h) AS r_au
FROM kepler_propagate(
0.586, -- perihelion distance
0.967, -- eccentricity
162.3, -- inclination
111.3, -- argument of perihelion
58.4, -- longitude of ascending node
2446467.4, -- perihelion Julian Date
now()
) AS h;
```
```sql
-- Track a near-parabolic comet over 6 months
SELECT t,
helio_distance(h) AS r_au
FROM generate_series(
'2024-01-01'::timestamptz,
'2024-07-01'::timestamptz,
interval '1 day'
) AS t,
kepler_propagate(1.01, 0.9995, 45.0, 130.0, 210.0, 2460400.5, t) AS h;
```
---
## comet_observe
Computes the topocentric position of a comet or asteroid as seen from an Earth-based observer. This function combines Keplerian propagation with the Earth's heliocentric position to produce observer-relative coordinates.
### Signature
```sql
comet_observe(
q_au float8,
ecc float8,
inc_deg float8,
arg_peri_deg float8,
long_node_deg float8,
perihelion_jd float8,
earth_x float8,
earth_y float8,
earth_z float8,
obs observer,
t timestamptz
) → topocentric
```
### Parameters
| Parameter | Type | Unit | Description |
|-----------|------|------|-------------|
| `q_au` | `float8` | AU | Perihelion distance |
| `ecc` | `float8` | | Eccentricity |
| `inc_deg` | `float8` | degrees | Orbital inclination |
| `arg_peri_deg` | `float8` | degrees | Argument of perihelion |
| `long_node_deg` | `float8` | degrees | Longitude of ascending node |
| `perihelion_jd` | `float8` | JD | Time of perihelion passage as Julian Date |
| `earth_x` | `float8` | AU | Earth's heliocentric X (ecliptic J2000) |
| `earth_y` | `float8` | AU | Earth's heliocentric Y (ecliptic J2000) |
| `earth_z` | `float8` | AU | Earth's heliocentric Z (ecliptic J2000) |
| `obs` | `observer` | | Observer location on Earth |
| `t` | `timestamptz` | | Observation time |
<Aside type="note">
The Earth position parameters (`earth_x`, `earth_y`, `earth_z`) should be obtained from `planet_heliocentric(3, t)`. They are passed explicitly rather than computed internally so that when observing multiple comets at the same time, you compute Earth's position once.
</Aside>
### Returns
A `topocentric` with azimuth, elevation, range (km), and range rate (km/s).
### Example
```sql
-- Observe Comet Halley from Boulder
WITH earth AS (
SELECT planet_heliocentric(3, now()) AS h
)
SELECT topo_azimuth(c) AS az,
topo_elevation(c) AS el,
topo_range(c) / 149597870.7 AS dist_au
FROM earth,
comet_observe(
0.586, 0.967, 162.3, 111.3, 58.4, 2446467.4,
helio_x(earth.h), helio_y(earth.h), helio_z(earth.h),
'40.0N 105.3W 1655m'::observer,
now()
) AS c;
```
```sql
-- Batch-observe all comets from a catalog
WITH earth AS (
SELECT planet_heliocentric(3, now()) AS h
)
SELECT name,
round(topo_azimuth(c)::numeric, 2) AS az,
round(topo_elevation(c)::numeric, 2) AS el,
round((topo_range(c) / 149597870.7)::numeric, 4) AS dist_au
FROM comet_catalog, earth,
comet_observe(
q_au, ecc, inc_deg, arg_peri_deg, long_node_deg, perihelion_jd,
helio_x(earth.h), helio_y(earth.h), helio_z(earth.h),
'40.0N 105.3W 1655m'::observer,
now()
) AS c
WHERE topo_elevation(c) > 0
ORDER BY topo_range(c);
```

View File

@ -0,0 +1,152 @@
---
title: "Functions: Transfers"
sidebar:
order: 7
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Functions for computing interplanetary transfer orbits using the Lambert problem solver. Given a departure body, arrival body, departure time, and arrival time, the solver finds the transfer orbit that connects the two positions.
---
## lambert_transfer
Solves the Lambert problem for a transfer between two planets and returns the full solution record. The solver computes heliocentric positions of both bodies at the departure and arrival times using VSOP87, then finds the conic section connecting them.
### Signature
```sql
lambert_transfer(
dep_body int4,
arr_body int4,
dep_time timestamptz,
arr_time timestamptz
) → RECORD(
c3_departure float8,
c3_arrival float8,
v_inf_departure float8,
v_inf_arrival float8,
tof_days float8,
transfer_sma float8
)
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `dep_body` | `int4` | Departure planet body ID (1-8). See [Body ID Reference](/reference/body-ids/). |
| `arr_body` | `int4` | Arrival planet body ID (1-8) |
| `dep_time` | `timestamptz` | Departure epoch |
| `arr_time` | `timestamptz` | Arrival epoch. Must be after `dep_time`. |
### Returns
A record with the following fields:
| Field | Type | Unit | Description |
|-------|------|------|-------------|
| `c3_departure` | `float8` | km^2/s^2 | Departure characteristic energy. The square of the hyperbolic excess velocity at the departure planet. A smaller C3 requires less launch energy. |
| `c3_arrival` | `float8` | km^2/s^2 | Arrival characteristic energy. The energy that must be shed for orbit insertion at the arrival planet. |
| `v_inf_departure` | `float8` | km/s | Hyperbolic excess velocity at departure (= sqrt(C3)). |
| `v_inf_arrival` | `float8` | km/s | Hyperbolic excess velocity at arrival. |
| `tof_days` | `float8` | days | Time of flight from departure to arrival. |
| `transfer_sma` | `float8` | AU | Semi-major axis of the transfer orbit. Negative for hyperbolic transfers. |
<Aside type="caution">
The solver finds the short-way (Type I) transfer. For departure and arrival times that would require a long-way (Type II, > 180 degree transfer angle) solution, the solver may return NULL or produce a degenerate result. The departure time must be strictly before the arrival time.
</Aside>
### Example
```sql
-- Earth to Mars transfer for a 2028 opportunity
SELECT round(c3_departure::numeric, 2) AS c3_depart,
round(c3_arrival::numeric, 2) AS c3_arrive,
round(v_inf_departure::numeric, 3) AS v_inf_dep,
round(v_inf_arrival::numeric, 3) AS v_inf_arr,
round(tof_days::numeric, 1) AS flight_days,
round(transfer_sma::numeric, 4) AS sma_au
FROM lambert_transfer(3, 4,
'2028-10-01'::timestamptz,
'2029-06-15'::timestamptz);
```
```sql
-- Pork chop plot: scan departure and arrival dates for Earth-Mars
-- 150 departure dates x 150 arrival dates = 22,500 solutions
SELECT dep, arr,
round(c3_departure::numeric, 2) AS c3
FROM generate_series('2028-08-01'::timestamptz, '2029-01-28'::timestamptz, interval '1 day') AS dep
CROSS JOIN generate_series('2029-03-01'::timestamptz, '2029-07-28'::timestamptz, interval '1 day') AS arr,
LATERAL lambert_transfer(3, 4, dep, arr) AS lt
WHERE c3_departure IS NOT NULL
AND c3_departure < 50
ORDER BY c3_departure;
```
```sql
-- Compare transfer windows for all outer planets from Earth
SELECT arr_body,
CASE arr_body
WHEN 5 THEN 'Jupiter' WHEN 6 THEN 'Saturn'
WHEN 7 THEN 'Uranus' WHEN 8 THEN 'Neptune'
END AS target,
round(c3_departure::numeric, 2) AS c3,
round(tof_days::numeric, 0) AS flight_days
FROM unnest(ARRAY[5,6,7,8]) AS arr_body,
lambert_transfer(3, arr_body,
'2030-01-01'::timestamptz,
'2030-01-01'::timestamptz + interval '2 years') AS lt;
```
---
## lambert_c3
A convenience function that solves the Lambert problem and returns only the departure C3 value. Returns NULL on solver failure (e.g., degenerate geometry, transfer angle near 0 or 180 degrees). This is the function to use for generating pork chop plots where only departure energy matters.
### Signature
```sql
lambert_c3(dep_body int4, arr_body int4, dep_time timestamptz, arr_time timestamptz) → float8
```
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `dep_body` | `int4` | Departure planet body ID (1-8) |
| `arr_body` | `int4` | Arrival planet body ID (1-8) |
| `dep_time` | `timestamptz` | Departure epoch |
| `arr_time` | `timestamptz` | Arrival epoch |
### Returns
Departure C3 in km^2/s^2, or NULL if the solver fails.
### Example
```sql
-- Quick C3 check for a specific transfer
SELECT round(lambert_c3(3, 4,
'2028-10-15'::timestamptz,
'2029-05-01'::timestamptz
)::numeric, 2) AS c3_km2s2;
```
```sql
-- Dense pork chop plot using the scalar function
-- Faster than lambert_transfer when you only need C3
SELECT dep, arr,
round(lambert_c3(3, 4, dep, arr)::numeric, 2) AS c3
FROM generate_series('2028-08-01'::timestamptz, '2029-01-28'::timestamptz, interval '1 day') AS dep
CROSS JOIN generate_series('2029-03-01'::timestamptz, '2029-07-28'::timestamptz, interval '1 day') AS arr
WHERE lambert_c3(3, 4, dep, arr) IS NOT NULL
AND lambert_c3(3, 4, dep, arr) < 30;
```
<Aside type="tip">
For pork chop plots, filtering with `WHERE c3 < threshold` eliminates the unphysical fringes at short and long transfer times. A typical threshold for Earth-Mars is 15-25 km^2/s^2; for Earth-Jupiter, 80-120 km^2/s^2.
</Aside>

View File

@ -0,0 +1,190 @@
---
title: "Operators & GiST Index"
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.
---
## Operators
### && (Overlap)
Tests whether two TLEs have overlapping orbital envelopes. The envelopes are defined by the altitude band (perigee to apogee) and inclination range. Overlap is a necessary but not sufficient condition for a conjunction: two satellites whose altitude bands and inclination ranges do not overlap can never come close to each other.
#### Signature
```sql
tle && tle → boolean
```
#### Description
Returns `true` if both of the following conditions hold:
1. The altitude bands overlap (one satellite's perigee is below the other's apogee, and vice versa)
2. The inclination ranges are compatible (the orbits could geometrically intersect)
Returns `false` if the orbits are guaranteed to never intersect based on these geometric bounds.
<Aside type="note">
This operator is conservative: it may return `true` for satellite pairs that never actually approach each other (false positive), but it will never return `false` for a pair that does (no false negatives). Use `tle_distance` for precise distance computation on the pairs that pass this filter.
</Aside>
#### Example
```sql
-- Find all satellites whose orbits could potentially intersect with 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 norad_id, name
FROM satellite_catalog, iss
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.
#### Signature
```sql
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 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).
#### Example
```sql
-- Altitude band separation 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
),
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
FROM iss, geo;
```
```sql
-- Order catalog by altitude 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
FROM satellite_catalog, target
ORDER BY satellite_catalog.tle <-> target.tle
LIMIT 20;
```
---
## GiST Operator Class: tle_ops
The `tle_ops` operator class enables GiST indexing on `tle` columns. With this index in place, the `&&` (overlap) and `<->` (distance) operators use index scans instead of sequential scans, making conjunction screening over large catalogs practical.
### Creating the Index
```sql
CREATE INDEX idx_tle_gist ON satellite_catalog USING gist (tle tle_ops);
```
### What Gets Indexed
The GiST index stores a bounding representation of each TLE's orbital envelope:
- **Altitude band:** perigee altitude to apogee altitude (km, above WGS-72)
- **Inclination range:** orbital inclination (degrees)
These are extracted from the TLE's mean motion and eccentricity at index creation time. The index does not store time-varying quantities; it represents the geometric envelope of the orbit as defined by the current osculating elements.
### Index-Accelerated Queries
<Tabs>
<TabItem label="Overlap scan">
```sql
-- Find all catalog objects that could intersect with the ISS orbit
-- Uses the GiST index to avoid a full catalog scan
WITH iss AS (
SELECT tle FROM satellite_catalog WHERE norad_id = 25544
)
SELECT c.norad_id, c.name
FROM satellite_catalog c, iss
WHERE c.tle && iss.tle
AND c.norad_id != 25544;
```
</TabItem>
<TabItem label="kNN by altitude">
```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
LIMIT 10;
```
</TabItem>
<TabItem label="Two-stage screening">
```sql
-- Full conjunction screening pipeline:
-- Stage 1: GiST index filters by orbital envelope overlap
-- Stage 2: Precise distance computation on surviving pairs
WITH iss AS (
SELECT tle FROM satellite_catalog WHERE norad_id = 25544
),
candidates AS (
SELECT c.norad_id, c.name, c.tle
FROM satellite_catalog c, iss
WHERE c.tle && iss.tle
AND c.norad_id != 25544
)
SELECT norad_id, name,
round(tle_distance(candidates.tle, iss.tle, now())::numeric, 1) AS dist_km
FROM candidates, iss
WHERE tle_distance(candidates.tle, iss.tle, now()) < 100
ORDER BY dist_km;
```
</TabItem>
</Tabs>
### 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.
<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.
</Aside>
### Index Maintenance
The GiST index is maintained automatically by PostgreSQL on `INSERT`, `UPDATE`, and `DELETE`. When TLEs are refreshed (e.g., daily catalog updates), the index is updated in place. No manual `REINDEX` is needed under normal operation.
If you perform a bulk catalog replacement (truncate + reload), run `REINDEX` after loading:
```sql
TRUNCATE satellite_catalog;
COPY satellite_catalog FROM '/path/to/catalog.csv' WITH (FORMAT csv);
REINDEX INDEX idx_tle_gist;
```

View File

@ -0,0 +1,269 @@
---
title: Types
sidebar:
order: 1
---
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.
## tle
**Size:** 112 bytes
A Two-Line Element set, the standard orbital element format maintained by NORAD and distributed by CelesTrak and Space-Track. Internally stores all SGP4/SDP4-relevant fields in parsed, double-precision form.
### Input Format
Standard two-line TLE text. Both lines are concatenated into a single-quoted string with a newline separator:
```sql
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;
```
<Aside type="caution">
The TLE parser validates checksums on both lines. Malformed or truncated lines will raise a parse error. Trailing whitespace is tolerated; leading whitespace is not.
</Aside>
### Constructor
| Function | Signature | Description |
|----------|-----------|-------------|
| `tle_from_lines` | `tle_from_lines(line1 text, line2 text) → tle` | Constructs a TLE from two separate line strings. Useful when lines are stored in separate columns. |
```sql
SELECT tle_from_lines(
'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'
);
```
### Accessor Functions
| Function | Return Type | Description |
|----------|-------------|-------------|
| `tle_norad_id(tle)` | `int4` | NORAD catalog number |
| `tle_intl_desig(tle)` | `text` | International designator (e.g. `98067A`) |
| `tle_epoch(tle)` | `timestamptz` | Epoch as a PostgreSQL timestamp |
| `tle_inclination(tle)` | `float8` | Inclination in degrees |
| `tle_raan(tle)` | `float8` | Right Ascension of Ascending Node in degrees |
| `tle_eccentricity(tle)` | `float8` | Eccentricity (dimensionless) |
| `tle_arg_perigee(tle)` | `float8` | Argument of perigee in degrees |
| `tle_mean_anomaly(tle)` | `float8` | Mean anomaly in degrees |
| `tle_mean_motion(tle)` | `float8` | Mean motion in revolutions/day |
| `tle_bstar(tle)` | `float8` | B* drag coefficient (1/earth-radii) |
| `tle_period(tle)` | `float8` | Orbital period in minutes |
| `tle_perigee(tle)` | `float8` | Perigee altitude in km (above WGS-72 ellipsoid) |
| `tle_apogee(tle)` | `float8` | Apogee altitude in km (above WGS-72 ellipsoid) |
| `tle_age(tle)` | `interval` | Age of the TLE relative to `now()` |
```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 tle_norad_id(tle) AS norad_id,
tle_inclination(tle) AS inc_deg,
tle_eccentricity(tle) AS ecc,
tle_period(tle) AS period_min,
tle_perigee(tle) AS perigee_km,
tle_apogee(tle) AS apogee_km,
tle_epoch(tle) AS epoch,
tle_age(tle) AS age
FROM iss;
```
---
## eci_position
**Size:** 48 bytes
Earth-Centered Inertial position and velocity in the True Equator Mean Equinox (TEME) reference frame. Position components are in kilometers; velocity components are in km/s. This is the native output frame of the SGP4/SDP4 propagator.
### Accessor Functions
| Function | Return Type | Unit | Description |
|----------|-------------|------|-------------|
| `eci_x(eci_position)` | `float8` | km | X position (TEME) |
| `eci_y(eci_position)` | `float8` | km | Y position (TEME) |
| `eci_z(eci_position)` | `float8` | km | Z position (TEME) |
| `eci_vx(eci_position)` | `float8` | km/s | X velocity (TEME) |
| `eci_vy(eci_position)` | `float8` | km/s | Y velocity (TEME) |
| `eci_vz(eci_position)` | `float8` | km/s | Z velocity (TEME) |
| `eci_speed(eci_position)` | `float8` | km/s | Magnitude of velocity vector |
| `eci_altitude(eci_position)` | `float8` | km | Geocentric altitude (distance from Earth center minus WGS-84 equatorial radius) |
```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 eci_x(pos) AS x_km,
eci_y(pos) AS y_km,
eci_z(pos) AS z_km,
eci_speed(pos) AS speed_kms,
eci_altitude(pos) AS alt_km
FROM iss, sgp4_propagate(tle, now()) AS pos;
```
---
## geodetic
**Size:** 24 bytes
WGS-84 geodetic coordinates: latitude, longitude, and altitude above the reference ellipsoid.
### Accessor Functions
| Function | Return Type | Unit | Description |
|----------|-------------|------|-------------|
| `geo_lat(geodetic)` | `float8` | degrees | Geodetic latitude (-90 to +90, north positive) |
| `geo_lon(geodetic)` | `float8` | degrees | Geodetic longitude (-180 to +180, east positive) |
| `geo_alt(geodetic)` | `float8` | km | Altitude above WGS-84 ellipsoid |
```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 geo_lat(g) AS lat,
geo_lon(g) AS lon,
geo_alt(g) AS alt_km
FROM iss, subsatellite_point(tle, now()) AS g;
```
---
## topocentric
**Size:** 32 bytes
Observer-relative coordinates: azimuth, elevation, slant range, and range rate. This is the output of all `*_observe` functions.
### Accessor Functions
| Function | Return Type | Unit | Description |
|----------|-------------|------|-------------|
| `topo_azimuth(topocentric)` | `float8` | degrees | Azimuth measured clockwise from true north (0-360) |
| `topo_elevation(topocentric)` | `float8` | degrees | Elevation above the local horizon (-90 to +90) |
| `topo_range(topocentric)` | `float8` | km | Slant range from observer to target |
| `topo_range_rate(topocentric)` | `float8` | km/s | Rate of change of range. Positive = receding from observer. |
<Aside type="note">
For `star_observe`, `topo_range` is always 0 because stars are treated as being at infinite distance. For solar system bodies, the range is the true geometric distance at the observation time.
</Aside>
```sql
-- Where is Saturn from Boulder right now?
SELECT topo_azimuth(t) AS az,
topo_elevation(t) AS el,
topo_range(t) AS range_km
FROM planet_observe(6, '40.0N 105.3W 1655m'::observer, now()) AS t;
```
---
## observer
**Size:** 24 bytes
An observer's geodetic location on Earth. Used as input to all topocentric observation functions.
### Input Format
A compact string encoding latitude, longitude, and optional altitude:
```
'40.0N 105.3W 1655m'
```
- Latitude: decimal degrees followed by `N` or `S`
- Longitude: decimal degrees followed by `E` or `W`
- Altitude: meters followed by `m` (optional, defaults to 0)
```sql
SELECT '40.0N 105.3W 1655m'::observer; -- Boulder, CO
SELECT '51.4769N 0.0005W 11m'::observer; -- Greenwich Observatory
SELECT '35.6762N 139.6503E 40m'::observer; -- Tokyo
SELECT '33.9S 18.5E 0m'::observer; -- Cape Town
```
### Constructor
| Function | Signature | Description |
|----------|-----------|-------------|
| `observer_from_geodetic` | `observer_from_geodetic(lat float8, lon float8, alt_m float8 DEFAULT 0) → observer` | Construct from numeric lat/lon (degrees) and altitude (meters). Latitude: north positive. Longitude: east positive. |
```sql
-- These are equivalent:
SELECT '40.0N 105.3W 1655m'::observer;
SELECT observer_from_geodetic(40.0, -105.3, 1655);
```
---
## pass_event
**Size:** 48 bytes
A satellite pass over an observer location, with AOS (Acquisition of Signal), maximum elevation, and LOS (Loss of Signal) timestamps plus geometry.
### Accessor Functions
| Function | Return Type | Unit | Description |
|----------|-------------|------|-------------|
| `pass_aos_time(pass_event)` | `timestamptz` | | Time the satellite rises above the horizon (or minimum elevation threshold) |
| `pass_max_el_time(pass_event)` | `timestamptz` | | Time of maximum elevation (closest approach) |
| `pass_los_time(pass_event)` | `timestamptz` | | Time the satellite sets below the horizon (or minimum elevation threshold) |
| `pass_max_elevation(pass_event)` | `float8` | degrees | Peak elevation during the pass |
| `pass_aos_azimuth(pass_event)` | `float8` | degrees | Azimuth at AOS |
| `pass_los_azimuth(pass_event)` | `float8` | degrees | Azimuth at LOS |
| `pass_duration(pass_event)` | `interval` | | Duration from AOS to LOS |
```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 pass_aos_time(p) AS rise,
pass_max_el_time(p) AS culmination,
pass_max_elevation(p) AS max_el,
pass_los_time(p) AS set,
pass_aos_azimuth(p) AS rise_az,
pass_los_azimuth(p) AS set_az,
pass_duration(p) AS dur
FROM iss, predict_passes(tle, '40.0N 105.3W 1655m'::observer,
now(), now() + interval '24 hours', 10.0) AS p;
```
---
## heliocentric
**Size:** 24 bytes
Heliocentric position in the ecliptic plane of J2000.0, measured in Astronomical Units (AU). This is the output of `planet_heliocentric` and `kepler_propagate`.
### Accessor Functions
| Function | Return Type | Unit | Description |
|----------|-------------|------|-------------|
| `helio_x(heliocentric)` | `float8` | AU | X position (ecliptic J2000) |
| `helio_y(heliocentric)` | `float8` | AU | Y position (ecliptic J2000) |
| `helio_z(heliocentric)` | `float8` | AU | Z position (ecliptic J2000) |
| `helio_distance(heliocentric)` | `float8` | AU | Distance from the Sun (vector magnitude) |
```sql
-- Heliocentric positions of all eight planets
SELECT body_id,
helio_x(h) AS x_au,
helio_y(h) AS y_au,
helio_z(h) AS z_au,
helio_distance(h) AS dist_au
FROM generate_series(1, 8) AS body_id,
planet_heliocentric(body_id, now()) AS h;
```

View File

@ -0,0 +1,281 @@
---
title: From GMAT to SQL
sidebar:
order: 3
description: Comparing GMAT's mission design workflow with pg_orrery's SQL approach for Lambert transfer analysis.
---
import { Tabs, TabItem, Aside, Steps } from "@astrojs/starlight/components";
GMAT (General Mission Analysis Tool) is NASA's open-source mission design software. It handles everything from preliminary trajectory design to full mission planning with low-thrust propulsion, gravity assists, and multi-body optimization. It's powerful, free, and built for professional mission designers.
pg_orrery is not a mission design tool. It solves one specific problem from GMAT's domain — Lambert transfers between planets — and makes it available as a SQL function. This page is about that narrow overlap, and why doing Lambert analysis in SQL is sometimes a better fit than launching a full mission design environment.
## The Lambert problem
Given two positions in space and a time of flight, find the orbit that connects them. This is the starting point for interplanetary trajectory design: "If I leave Earth on this date and arrive at Mars on that date, what does the transfer orbit look like?"
The solution gives you departure C3 (the energy needed to escape Earth's gravity), arrival C3 (the energy you arrive with at the target), and the transfer orbit parameters. A pork chop plot is a grid of these solutions across a range of departure and arrival dates — the visual tool mission designers use to identify launch windows.
## Setting up a Lambert transfer
<Tabs>
<TabItem label="GMAT">
GMAT uses a scripting language with a GUI. A minimal Lambert-style analysis
(technically a "targeting" problem in GMAT) requires understanding several
concepts specific to the tool.
```
% GMAT script for Earth-Mars transfer targeting
% This is simplified — real GMAT scripts are longer
Create Spacecraft sc;
sc.DateFormat = UTCGregorian;
sc.Epoch = '01 Oct 2028 00:00:00.000';
sc.CoordinateSystem = EarthMJ2000Eq;
sc.DisplayStateType = Cartesian;
Create ForceModel DeepSpace;
DeepSpace.CentralBody = Sun;
DeepSpace.PointMasses = {Sun, Earth, Mars};
Create Propagator DeepSpaceProp;
DeepSpaceProp.FM = DeepSpace;
DeepSpaceProp.Type = PrinceDormand78;
DeepSpaceProp.InitialStepSize = 86400;
Create ImpulsiveBurn TOI;
TOI.CoordinateSystem = EarthMJ2000Eq;
Create ImpulsiveBurn MOI;
MOI.CoordinateSystem = MarsMJ2000Eq;
Create DifferentialCorrector DC;
Create OrbitView SolarSystemView;
SolarSystemView.Add = {sc, Earth, Mars, Sun};
BeginMissionSequence;
Target DC;
Vary DC(TOI.Element1 = 3.0, {Perturbation = 0.01, ...});
Vary DC(TOI.Element2 = 0.0, {Perturbation = 0.01, ...});
Vary DC(TOI.Element3 = 0.5, {Perturbation = 0.01, ...});
Maneuver TOI(sc);
Propagate DeepSpaceProp(sc) {sc.Mars.Periapsis};
Achieve DC(sc.Mars.RMAG = 3500, {Tolerance = 0.1});
Achieve DC(sc.ElapsedDays = 258, {Tolerance = 0.1});
EndTarget;
```
This is the compressed version. The GMAT tutorial for interplanetary transfers
runs about 50 pages. You configure:
- Spacecraft objects with initial state vectors
- Force models with point masses and perturbations
- Propagators with numerical integration settings
- Impulsive burn objects
- A differential corrector (the targeting engine)
- Visualization objects
Then you write a targeting sequence that varies the burn parameters
until the spacecraft reaches the desired conditions at arrival.
For a **single transfer**, this gives you a precise, multi-body solution
with accurate planetary perturbations. That's genuinely valuable for
mission design.
For a **survey** across hundreds of departure/arrival date combinations
to find the optimal launch window? You'd need to script a loop over
the targeting sequence, handle convergence failures, and manage output
parsing.
</TabItem>
<TabItem label="pg_orrery (SQL)">
```sql
-- Single Earth-Mars transfer
SELECT round(c3_departure::numeric, 2) AS c3_depart_km2s2,
round(c3_arrival::numeric, 2) AS c3_arrive_km2s2,
round(v_inf_departure::numeric, 3) AS v_inf_depart_kms,
round(v_inf_arrival::numeric, 3) AS v_inf_arrive_kms,
round(tof_days::numeric, 1) AS flight_days,
round(transfer_sma::numeric, 4) AS sma_au
FROM lambert_transfer(
3, 4, -- Earth to Mars
'2028-10-01'::timestamptz, -- departure
'2029-06-15'::timestamptz -- arrival
);
```
Five lines. The function handles:
- Computing Earth and Mars heliocentric positions at the given dates (VSOP87)
- Solving the Lambert boundary value problem (Izzo algorithm)
- Returning C3, v-infinity, time of flight, and transfer SMA
</TabItem>
</Tabs>
## Pork chop plots
This is where the SQL approach really differentiates itself. A pork chop plot surveys transfer energy across a grid of departure and arrival dates. In mission design, this is how you find launch windows.
<Tabs>
<TabItem label="GMAT">
GMAT doesn't have a built-in pork chop plot generator. You would:
1. Write a GMAT script with parameterized departure/arrival epochs
2. Create an outer loop (in GMAT script or an external driver — Python, MATLAB)
that iterates over your date grid
3. For each combination, run the targeting sequence
4. Handle convergence failures (some date combinations don't produce
valid solutions with the chosen initial guess)
5. Collect output from GMAT's report files
6. Parse and aggregate into a matrix
7. Plot externally (GMAT's plotting is limited for contour-type visualization)
This is doable — mission design teams do it — but it's a multi-hour workflow
for setup and execution, and convergence issues require manual attention.
Alternatively, most teams use a dedicated Lambert solver in Python or MATLAB
(not GMAT) for pork chop plots, and only bring GMAT in for detailed
trajectory refinement once the launch window is identified.
</TabItem>
<TabItem label="pg_orrery (SQL)">
```sql
-- Full pork chop plot: Earth to Mars, 2028-2029 window
-- 150 departure dates x 150 arrival dates = 22,500 transfer solutions
SELECT dep::date AS departure,
arr::date AS arrival,
round(c3_departure::numeric, 2) AS c3_km2s2,
round(tof_days::numeric, 0) AS tof
FROM generate_series(
'2028-06-01'::timestamptz,
'2029-01-01'::timestamptz,
interval '1.4 days'
) AS dep,
generate_series(
'2029-02-01'::timestamptz,
'2029-10-01'::timestamptz,
interval '1.6 days'
) AS arr,
LATERAL lambert_transfer(3, 4, dep, arr) AS xfer
WHERE tof_days > 90
AND c3_departure < 50; -- Filter unreasonable solutions
```
22,500 Lambert solves. About 8.3 seconds on commodity hardware. Export to CSV:
```sql
COPY (
SELECT dep::date, arr::date,
round(c3_departure::numeric, 2) AS c3
FROM generate_series(
'2028-06-01'::timestamptz, '2029-01-01'::timestamptz,
interval '1.4 days') AS dep,
generate_series(
'2029-02-01'::timestamptz, '2029-10-01'::timestamptz,
interval '1.6 days') AS arr,
LATERAL lambert_transfer(3, 4, dep, arr) AS xfer
WHERE tof_days > 90
) TO '/tmp/porkchop.csv' WITH CSV HEADER;
```
Then plot with gnuplot, matplotlib, or any contour plotting tool.
</TabItem>
</Tabs>
## Multi-planet survey
Which planets have favorable transfer windows from Earth in a given year? This kind of broad survey is natural in SQL but tedious in GMAT.
```sql
-- Survey all inner/outer planet transfers from Earth, 2028-2030
-- Which targets have the lowest departure C3 in each year?
SELECT target_id,
CASE target_id
WHEN 1 THEN 'Mercury' WHEN 2 THEN 'Venus'
WHEN 4 THEN 'Mars' WHEN 5 THEN 'Jupiter'
WHEN 6 THEN 'Saturn'
END AS target,
dep::date AS best_departure,
arr::date AS best_arrival,
round(min_c3::numeric, 2) AS min_c3_km2s2,
round(tof::numeric, 0) AS flight_days
FROM (
SELECT target_id, dep, arr,
c3_departure AS min_c3,
tof_days AS tof,
ROW_NUMBER() OVER (
PARTITION BY target_id
ORDER BY c3_departure
) AS rn
FROM generate_series(1, 6) AS target_id,
generate_series(
'2028-01-01'::timestamptz,
'2030-12-01'::timestamptz,
interval '10 days'
) AS dep,
generate_series(
'2028-06-01'::timestamptz,
'2033-01-01'::timestamptz,
interval '10 days'
) AS arr,
LATERAL lambert_transfer(3, target_id, dep, arr) AS xfer
WHERE target_id != 3 -- Not Earth-to-Earth
AND tof_days BETWEEN 60 AND 1500
AND c3_departure < 100
) sub
WHERE rn = 1
ORDER BY min_c3;
```
This scans a wide grid and finds the single best (lowest C3) departure/arrival combination for each target planet. It's a brute-force approach — not how a mission designer would work, but useful for exploration and screening.
## Where GMAT wins
<Aside type="note" title="GMAT is a different class of tool">
pg_orrery's Lambert solver is a screening tool. GMAT is a mission design environment. The comparison is between a calculator and a workshop.
</Aside>
**Multi-body dynamics.** GMAT propagates trajectories with full N-body gravitational perturbations — Sun, planets, moons, solar radiation pressure, atmospheric drag. pg_orrery's Lambert solver is strictly two-body (Sun + spacecraft).
**Low-thrust trajectories.** Electric propulsion missions (ion engines, Hall thrusters) require continuous thrust modeling. GMAT handles this; pg_orrery does not.
**Gravity assists.** Flyby trajectories — using a planet's gravity to change direction and speed — are central to many interplanetary missions. GMAT models these with full patched-conic or N-body dynamics. pg_orrery solves point-to-point transfers only.
**Mission sequence optimization.** GMAT's differential corrector and optimizer can target complex mission constraints: orbital insertion parameters, flyby altitudes, fuel budgets. pg_orrery returns raw transfer parameters without optimization.
**Attitude modeling.** GMAT tracks spacecraft orientation — important for solar panel pointing, antenna alignment, and sensor geometry. pg_orrery has no concept of spacecraft attitude.
**Visualization.** GMAT includes 3D trajectory visualization. pg_orrery returns numbers.
## Where pg_orrery wins
**Speed of iteration.** Changing a date range or adding a constraint is editing a SQL query. No restarting a GUI, no waiting for script compilation, no managing convergence parameters.
**Batch computation.** 22,500 Lambert solves in 8.3 seconds, parallelized across cores automatically. GMAT's targeting loop would take orders of magnitude longer for the same grid.
**Integration with data.** If your satellite catalog, contact schedules, or mission database lives in PostgreSQL, Lambert results join directly with those tables. No file export/import cycle.
**Accessibility.** GMAT has a steep learning curve — the tutorial for a basic interplanetary transfer is a 50-page document. pg_orrery's Lambert solver requires knowing one SQL function and its six output columns.
## The intended workflow
pg_orrery's Lambert solver fits into a specific phase of mission planning:
<Steps>
1. **Screen with pg_orrery.** Generate a pork chop plot across a broad date range. Identify the departure/arrival windows with favorable C3 values. This takes minutes, not hours.
2. **Refine with GMAT.** Take the promising date ranges from step 1 and set up detailed trajectory design in GMAT. Add perturbations, model the departure and arrival spirals, check flyby opportunities.
3. **Iterate.** If the refined trajectory shifts the window, go back to pg_orrery to explore the neighborhood in SQL. Faster iteration between broad surveys and detailed analysis.
</Steps>
<Aside type="tip" title="Quick C3 comparison">
For a fast sanity check without building a full transfer analysis, use `lambert_c3()`:
```sql
-- Just the departure C3, nothing else
SELECT round(lambert_c3(3, 4,
'2028-10-01'::timestamptz,
'2029-06-15'::timestamptz)::numeric, 2) AS c3_km2s2;
```
Useful for scripting quick "is this window even worth investigating?" checks.
</Aside>

View File

@ -0,0 +1,341 @@
---
title: From JPL Horizons to SQL
sidebar:
order: 2
description: Side-by-side comparison of JPL Horizons API workflows and equivalent pg_orrery SQL queries.
---
import { Tabs, TabItem, Aside, Steps } from "@astrojs/starlight/components";
JPL Horizons is the gold standard for solar system ephemeris data. Run by the Solar System Dynamics group at the Jet Propulsion Laboratory, it serves precise positions for every known body — planets, moons, asteroids, comets, spacecraft. You can access it through a web interface, telnet, email, or REST API.
pg_orrery does not replace Horizons. What it does is move the 95% of queries that don't need sub-milliarcsecond precision from a remote API into your local database — with no rate limits, no network latency, and results that join directly with your other tables.
## Planet ephemeris query
The most common Horizons request: "Where is Mars from my location at this time?"
<Tabs>
<TabItem label="Horizons (API)">
```python
import requests
params = {
'format': 'json',
'COMMAND': '499', # Mars
'OBJ_DATA': 'NO',
'MAKE_EPHEM': 'YES',
'EPHEM_TYPE': 'OBSERVER',
'CENTER': 'coord@399',
'COORD_TYPE': 'GEODETIC',
'SITE_COORD': '-105.3,40.0,1.655', # lon, lat, alt(km)
'START_TIME': '2025-06-15 00:00',
'STOP_TIME': '2025-06-15 00:01',
'STEP_SIZE': '1',
'QUANTITIES': '1,4,20', # Astrometric RA/Dec, Az/El, Range
}
response = requests.get(
'https://ssd.jpl.nasa.gov/api/horizons.api',
params=params
)
data = response.json()
# Parse the text block in data['result']
# Horizons returns fixed-width text, not structured JSON
print(data['result'])
```
The API returns a text block with column headers embedded in the response body.
Parsing it requires knowing the column positions or using a library like
`astroquery.jplhorizons`. The response format varies depending on which
quantities you request.
**Rate limits:** JPL asks for no more than ~200 heavy queries per hour from a
single IP. Automated batch jobs that generate thousands of queries risk being
throttled or blocked.
</TabItem>
<TabItem label="pg_orrery (SQL)">
```sql
SELECT topo_azimuth(t) AS az,
topo_elevation(t) AS el,
topo_range(t) AS range_km
FROM planet_observe(4, '40.0N 105.3W 1655m'::observer,
'2025-06-15 00:00:00+00'::timestamptz) t;
```
Same computation. No network call, no parsing, no rate limits.
The result is a typed `topocentric` value — access individual components
with `topo_azimuth()`, `topo_elevation()`, `topo_range()`, `topo_range_rate()`.
</TabItem>
</Tabs>
## Batch queries over time ranges
This is where the workflow difference becomes dramatic. Generating a 24-hour elevation profile at 10-minute resolution means 144 data points.
<Tabs>
<TabItem label="Horizons (API)">
```python
import requests
# Option A: Single request with STEP_SIZE
params = {
'format': 'json',
'COMMAND': '599', # Jupiter
'MAKE_EPHEM': 'YES',
'EPHEM_TYPE': 'OBSERVER',
'CENTER': 'coord@399',
'COORD_TYPE': 'GEODETIC',
'SITE_COORD': '-105.3,40.0,1.655',
'START_TIME': '2025-06-15 00:00',
'STOP_TIME': '2025-06-16 00:00',
'STEP_SIZE': '10m', # 10-minute intervals
'QUANTITIES': '4', # Az/El only
}
response = requests.get(
'https://ssd.jpl.nasa.gov/api/horizons.api',
params=params
)
# Parse 144 lines of fixed-width text
# Extract azimuth and elevation from each line
lines = response.json()['result'].split('\n')
# ... parsing logic ...
```
For a single body and time range, Horizons handles this in one request.
But what if you want this for all 8 planets? That's 8 API calls. For
5 observers? That's 40. For a full year at 1-hour resolution?
You're managing thousands of requests, rate limiting, error handling,
and stitching results together.
</TabItem>
<TabItem label="pg_orrery (SQL)">
```sql
-- Jupiter elevation over 24 hours, 10-minute steps
SELECT t,
topo_azimuth(obs) AS az,
topo_elevation(obs) AS el
FROM generate_series(
'2025-06-15 00:00:00+00'::timestamptz,
'2025-06-16 00:00:00+00'::timestamptz,
interval '10 minutes'
) AS t,
LATERAL planet_observe(5, '40.0N 105.3W 1655m'::observer, t) AS obs;
```
```sql
-- All 8 planets, 5 observers, full year, 1-hour resolution
-- = 8 * 5 * 8760 = 350,400 observations
SELECT body_id, obs_name, t,
topo_elevation(planet_observe(body_id, location, t)) AS el
FROM generate_series(1, 8) AS body_id,
(VALUES
('Boulder', '40.0N 105.3W 1655m'::observer),
('London', '51.5N 0.1W 11m'::observer),
('Tokyo', '35.7N 139.7E 40m'::observer),
('Sydney', '33.9S 151.2E 58m'::observer),
('Nairobi', '1.3S 36.8E 1795m'::observer)
) AS observers(obs_name, location),
generate_series(
'2025-01-01'::timestamptz,
'2025-12-31'::timestamptz,
interval '1 hour'
) AS t;
```
350,400 observations. One query. No rate limits. Results land in a table you
can index, aggregate, and join.
</TabItem>
</Tabs>
## Moon positions
Horizons excels at moons — it has ephemerides for every known natural satellite. pg_orrery covers the 19 most-observed moons.
<Tabs>
<TabItem label="Horizons (API)">
```python
import requests
# Galilean moons: Io=501, Europa=502, Ganymede=503, Callisto=504
moons = {'Io': '501', 'Europa': '502', 'Ganymede': '503', 'Callisto': '504'}
for name, code in moons.items():
params = {
'format': 'json',
'COMMAND': code,
'MAKE_EPHEM': 'YES',
'EPHEM_TYPE': 'OBSERVER',
'CENTER': 'coord@399',
'COORD_TYPE': 'GEODETIC',
'SITE_COORD': '-105.3,40.0,1.655',
'START_TIME': '2025-06-15 03:00',
'STOP_TIME': '2025-06-15 03:01',
'STEP_SIZE': '1',
'QUANTITIES': '1,4,20',
}
response = requests.get(
'https://ssd.jpl.nasa.gov/api/horizons.api',
params=params
)
# Parse each response separately...
```
Four separate API calls. To track all four moons over a night of observation
at 5-minute intervals (say, 8 hours = 96 steps), that's 4 requests or
careful batching.
</TabItem>
<TabItem label="pg_orrery (SQL)">
```sql
-- All four Galilean moons, right now
SELECT moon_id,
CASE moon_id
WHEN 0 THEN 'Io' WHEN 1 THEN 'Europa'
WHEN 2 THEN 'Ganymede' WHEN 3 THEN 'Callisto'
END AS name,
topo_azimuth(galilean_observe(moon_id, obs, now())) AS az,
topo_elevation(galilean_observe(moon_id, obs, now())) AS el,
topo_range(galilean_observe(moon_id, obs, now())) AS range_km
FROM generate_series(0, 3) AS moon_id,
(VALUES ('40.0N 105.3W 1655m'::observer)) AS o(obs);
```
```sql
-- Track all four moons over an 8-hour observation session
SELECT t,
moon_id,
topo_elevation(galilean_observe(moon_id, obs, t)) AS el
FROM generate_series(0, 3) AS moon_id,
generate_series(
'2025-06-15 02:00:00+00'::timestamptz,
'2025-06-15 10:00:00+00'::timestamptz,
interval '5 minutes'
) AS t,
(VALUES ('40.0N 105.3W 1655m'::observer)) AS o(obs);
```
384 observations (4 moons times 96 timestamps). One query.
</TabItem>
</Tabs>
## Lambert transfer survey
This is where the difference is most striking. Horizons doesn't compute transfer orbits directly — you'd use its ephemeris data as input to your own Lambert solver. pg_orrery does both in one step.
<Tabs>
<TabItem label="Horizons + Python solver">
```python
from astroquery.jplhorizons import Horizons
from poliastro.iod import izzo
from astropy import units as u
import numpy as np
# Step 1: Get Earth and Mars positions from Horizons
# for each departure/arrival date pair
dep_dates = pd.date_range('2028-08-01', '2028-12-01', freq='5D')
arr_dates = pd.date_range('2029-04-01', '2029-09-01', freq='5D')
results = []
for dep in dep_dates:
# Query Earth heliocentric state at departure
earth = Horizons(id='399', location='@sun', epochs=dep.jd)
earth_vec = earth.vectors() # API call
for arr in arr_dates:
# Query Mars heliocentric state at arrival
mars = Horizons(id='499', location='@sun', epochs=arr.jd)
mars_vec = mars.vectors() # API call
# Solve Lambert problem
r1 = [earth_vec['x'][0], earth_vec['y'][0], earth_vec['z'][0]] * u.AU
r2 = [mars_vec['x'][0], mars_vec['y'][0], mars_vec['z'][0]] * u.AU
tof = (arr - dep).days * u.day
try:
(v1, v2), = izzo.lambert(Sun.k, r1, r2, tof)
c3 = (np.linalg.norm(v1.value) ** 2)
results.append({'dep': dep, 'arr': arr, 'c3': c3})
except:
pass
# For a 25x31 grid, that's 775 departure queries + 775 arrival queries
# to Horizons, plus 775 Lambert solves in Python
```
The Horizons queries alone — even with careful batching — take minutes
and risk rate limiting. The Lambert solve is the easy part.
</TabItem>
<TabItem label="pg_orrery (SQL)">
```sql
-- Full pork chop plot: 25 departure dates x 31 arrival dates = 775 transfers
SELECT dep::date AS departure,
arr::date AS arrival,
round(c3_departure::numeric, 2) AS c3_km2s2,
round(tof_days::numeric, 0) AS flight_days
FROM generate_series(
'2028-08-01'::timestamptz,
'2028-12-01'::timestamptz,
interval '5 days'
) AS dep,
generate_series(
'2029-04-01'::timestamptz,
'2029-09-01'::timestamptz,
interval '5 days'
) AS arr,
LATERAL lambert_transfer(3, 4, dep, arr) AS xfer
WHERE tof_days > 90; -- Filter unrealistic short transfers
```
pg_orrery computes the planet positions AND solves Lambert internally.
No external API calls. The 775 transfer solutions run in under a second.
Scale it up to a 150x150 grid (22,500 solutions) and it finishes in
about 8.3 seconds.
</TabItem>
</Tabs>
## Where Horizons wins
<Aside type="note" title="Horizons remains essential for some use cases">
JPL Horizons uses DE441 and applies the full suite of apparent-position corrections. For certain applications, it's the right source — but the accuracy gap has narrowed significantly.
</Aside>
**Apparent-position corrections.** Horizons applies light-time iteration, stellar aberration, and gravitational deflection of light. pg_orrery computes geometric positions. The difference is up to ~20 arcseconds for planets — irrelevant for observation planning, but significant for precision astrometry of apparent coordinates.
**Positional accuracy.** With the built-in VSOP87 pipeline, pg_orrery is accurate to ~1 arcsecond. With [optional DE440/441 support](/guides/de-ephemeris/) (v0.3.0+), pg_orrery reads the same JPL ephemeris data that powers Horizons — matching it at ~0.1 milliarcsecond for geometric positions. The remaining gap is in the apparent-position corrections above, not in the underlying ephemeris.
**Physical properties.** Horizons can return visual magnitude, angular diameter, phase angle, illuminated fraction, and surface brightness. pg_orrery returns geometric position and range.
**Topographic corrections.** Horizons accounts for Earth's oblateness and topographic features at the observer's location using precise geodetic models. pg_orrery uses a WGS-84 ellipsoid.
**Body catalog.** Horizons knows about every numbered asteroid, every known comet, and spacecraft past and present. pg_orrery covers the 8 planets, the Sun, the Moon, 19 planetary moons, and whatever comets/asteroids you define with Keplerian elements.
## Where pg_orrery wins
**No network dependency.** pg_orrery runs locally, in your database process. No DNS resolution, no TLS handshake, no API parsing. Useful in air-gapped environments, on aircraft, or when Horizons is down for maintenance.
**No rate limits.** Horizons is generous but not unlimited. Automated pipelines that generate thousands of queries — pork chop plot surveys, Monte Carlo trajectory analysis, multi-body scheduling — can hit throttling. pg_orrery has no external limits; you're bounded only by your own hardware.
**Batch everything locally.** The Lambert transfer example above illustrates this best. What takes hundreds of API calls and minutes of wall-clock time in the Horizons workflow is a single query that runs in seconds.
**Results in your database.** Horizons returns text that you parse and then insert. pg_orrery results are already rows in PostgreSQL — ready to JOIN, index, aggregate, or export.
**Reproducibility.** A pg_orrery query is deterministic. Given the same inputs, it produces the same output on any PostgreSQL instance with the extension installed. No dependency on the current state of a remote API or the version of its ephemeris files.
## A practical workflow
For many projects, the right approach uses both.
<Steps>
1. **Start with VSOP87 (the default).** No configuration needed. For observation planning, rise/set times, and "what's up tonight?" queries, ~1 arcsecond accuracy is more than sufficient.
2. **Enable DE for precision work.** If you need sub-arcsecond planet positions — dish pointing, occultation timing, precision Lambert transfers — [configure a DE file](/guides/de-ephemeris/) and use the `_de()` function variants. You get Horizons-quality positions locally.
3. **Use pg_orrery for surveys.** Any time you need positions for many bodies, many timestamps, or many observers — parameter sweeps, scheduling optimization, catalog screening — run it locally. No rate limits, no network dependency.
4. **Use pg_orrery for integration.** When orbital data needs to join with other database tables — observation logs, equipment schedules, frequency allocations — computing inside PostgreSQL eliminates the ETL step.
5. **Use Horizons for exotic bodies.** If you need positions for Pluto, numbered asteroids with precise osculating elements, or decommissioned spacecraft, Horizons is the only option.
</Steps>

View File

@ -0,0 +1,275 @@
---
title: From Radio Jupiter Pro to SQL
sidebar:
order: 4
description: Replacing the Windows-only Radio Jupiter Pro desktop app with pg_orrery SQL queries for Jupiter decametric burst prediction.
---
import { Tabs, TabItem, Aside, Steps } from "@astrojs/starlight/components";
Radio Jupiter Pro is a Windows desktop application used by the Radio JOVE community — roughly 500 to 1000 amateur radio astronomers worldwide who monitor Jupiter's decametric radio emissions. It predicts when Jupiter-Io interactions will produce radio bursts detectable from a given location, based on the Io orbital phase angle and Jupiter's Central Meridian Longitude (CML, System III).
The application works. It has served the community well for years. But it has limitations that SQL can address: it's Windows-only, it doesn't export data for automated scheduling, and batch analysis over long time ranges requires manual date entry.
## How Jupiter radio burst prediction works
Jupiter emits powerful radio bursts at frequencies between roughly 15 and 38 MHz. The strongest emissions correlate with the orbital position of Io relative to Jupiter and with Jupiter's rotation (the CML). The Carr et al. (1983) model maps source regions — labeled A, B, C, and D — onto an Io phase vs. CML diagram. When the current Io phase and CML fall within a source region, the probability of detecting a burst is elevated.
Both Radio Jupiter Pro and pg_orrery use this same underlying model.
## Checking burst probability right now
<Tabs>
<TabItem label="Radio Jupiter Pro">
1. Launch the application (Windows only, or via Wine on Linux/Mac)
2. Set your geographic coordinates in the preferences
3. Set the date and time to the current moment
4. Read the Io phase, CML, and source region prediction from the display
5. Check whether a source region is active in the CML/Io-phase chart
There is no programmatic access. The result is on screen — you read it
and decide whether to turn on your receiver.
</TabItem>
<TabItem label="pg_orrery (SQL)">
```sql
SELECT round(io_phase_angle(now())::numeric, 1) AS io_phase,
round(jupiter_cml('40.0N 105.3W 1655m'::observer, now())::numeric, 1) AS cml,
round(jupiter_burst_probability(
io_phase_angle(now()),
jupiter_cml('40.0N 105.3W 1655m'::observer, now())
)::numeric, 3) AS burst_prob;
```
One row: Io phase angle (degrees), CML (degrees), burst probability (0 to 1).
The probability comes from the same Carr source region model.
</TabItem>
</Tabs>
## Best burst windows tonight
This is where manual tools hit their limit. "When should I turn on my receiver tonight?" requires scanning hours of time at reasonable intervals.
<Tabs>
<TabItem label="Radio Jupiter Pro">
In Radio Jupiter Pro, you would:
1. Set the start time to sunset
2. Advance time manually (or use the animation feature) in small steps
3. Watch the CML/Io-phase indicator to see when it enters a source region
4. Note the time and source region on paper or in a spreadsheet
5. Repeat until sunrise
For a single evening, this takes 5 to 10 minutes of clicking. For
planning a week of observations, multiply accordingly.
</TabItem>
<TabItem label="pg_orrery (SQL)">
```sql
-- Best burst windows tonight (6 PM to 6 AM local, 10-minute steps)
-- Only show times when Jupiter is above the horizon AND burst probability > 0.2
SELECT t AT TIME ZONE 'America/Denver' AS local_time,
round(io_phase_angle(t)::numeric, 1) AS io_phase,
round(jupiter_cml('40.0N 105.3W 1655m'::observer, t)::numeric, 1) AS cml,
round(jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
)::numeric, 3) AS prob
FROM generate_series(
'2025-06-15 00:00:00+00'::timestamptz,
'2025-06-15 12:00:00+00'::timestamptz,
interval '10 minutes'
) AS t
WHERE topo_elevation(planet_observe(5, '40.0N 105.3W 1655m'::observer, t)) > 5
AND jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
) > 0.2
ORDER BY prob DESC;
```
This filters for:
- Jupiter above 5 degrees elevation (actually observable)
- Burst probability above 20%
Sorted by probability so the best windows are at the top. The entire scan
takes milliseconds.
</TabItem>
</Tabs>
## 30-day burst calendar
Planning a month of observations. Which nights have the best windows?
```sql
-- 30-day burst calendar: peak probability each night
-- Checks every 15 minutes between 00:00 and 12:00 UTC
WITH nightly_scan AS (
SELECT t::date AS night,
t,
jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
) AS prob,
topo_elevation(planet_observe(5, '40.0N 105.3W 1655m'::observer, t)) AS jup_el
FROM generate_series(
now(),
now() + interval '30 days',
interval '15 minutes'
) AS t
WHERE topo_elevation(planet_observe(5, '40.0N 105.3W 1655m'::observer, t)) > 5
)
SELECT night,
round(max(prob)::numeric, 3) AS peak_prob,
(array_agg(t ORDER BY prob DESC))[1] AS best_time_utc,
round(max(jup_el)::numeric, 1) AS max_jupiter_el
FROM nightly_scan
WHERE prob > 0.1
GROUP BY night
ORDER BY night;
```
One query scans 30 nights and returns the peak burst probability for each, along with the specific time and Jupiter's elevation at that moment. In Radio Jupiter Pro, this would require manually advancing through 30 separate nights.
## Correlate with observation logs
If you maintain a database of past observations, pg_orrery lets you answer questions like "did I actually detect bursts when the model predicted them?"
```sql
-- Compare burst predictions with actual observation results
-- Assumes an observation_log table with timestamps and detection flags
SELECT o.obs_time,
o.detected,
o.snr_db,
round(jupiter_burst_probability(
io_phase_angle(o.obs_time),
jupiter_cml('40.0N 105.3W 1655m'::observer, o.obs_time)
)::numeric, 3) AS predicted_prob,
round(io_phase_angle(o.obs_time)::numeric, 1) AS io_phase,
round(jupiter_cml('40.0N 105.3W 1655m'::observer, o.obs_time)::numeric, 1) AS cml
FROM observation_log o
WHERE o.receiver = 'radio_jove_20m'
AND o.obs_time > now() - interval '1 year'
ORDER BY o.obs_time;
```
```sql
-- Detection rate by probability bucket
SELECT prob_bucket,
count(*) AS total_obs,
count(*) FILTER (WHERE detected) AS detections,
round(count(*) FILTER (WHERE detected)::numeric / count(*)::numeric, 2) AS det_rate
FROM (
SELECT o.detected,
CASE
WHEN jupiter_burst_probability(
io_phase_angle(o.obs_time),
jupiter_cml('40.0N 105.3W 1655m'::observer, o.obs_time)
) < 0.1 THEN '< 10%'
WHEN jupiter_burst_probability(
io_phase_angle(o.obs_time),
jupiter_cml('40.0N 105.3W 1655m'::observer, o.obs_time)
) < 0.3 THEN '10-30%'
WHEN jupiter_burst_probability(
io_phase_angle(o.obs_time),
jupiter_cml('40.0N 105.3W 1655m'::observer, o.obs_time)
) < 0.5 THEN '30-50%'
ELSE '> 50%'
END AS prob_bucket
FROM observation_log o
WHERE o.receiver = 'radio_jove_20m'
AND o.obs_time > now() - interval '1 year'
) sub
GROUP BY prob_bucket
ORDER BY prob_bucket;
```
This is the kind of analysis that's impossible with Radio Jupiter Pro — it has no concept of historical data or past observations. The program shows you the current prediction and that's it.
## Automated scheduling
For operators who want to automate their receivers, pg_orrery can drive a scheduling system.
```sql
-- Generate tonight's observation schedule
-- Schedule 30-minute blocks centered on high-probability windows
CREATE MATERIALIZED VIEW tonight_schedule AS
WITH windows AS (
SELECT t,
jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
) AS prob,
topo_elevation(planet_observe(5, '40.0N 105.3W 1655m'::observer, t)) AS el
FROM generate_series(
date_trunc('day', now()) + interval '1 hour',
date_trunc('day', now()) + interval '13 hours',
interval '5 minutes'
) AS t
),
high_prob AS (
SELECT t AS window_center,
prob,
el
FROM windows
WHERE prob > 0.3
AND el > 10
)
SELECT window_center - interval '15 minutes' AS rec_start,
window_center + interval '15 minutes' AS rec_stop,
round(prob::numeric, 3) AS probability,
round(el::numeric, 1) AS jupiter_el
FROM high_prob
ORDER BY window_center;
```
Export with `COPY` to feed into a receiver control script, a cron job, or any scheduling system. Refresh nightly with `REFRESH MATERIALIZED VIEW tonight_schedule`.
## Io phase and CML time series
For operators building their own CML/Io-phase diagrams (the standard visualization in Jupiter radio astronomy):
```sql
-- CML vs Io phase over 24 hours, 5-minute resolution
-- Export for plotting a CML/Io-phase track
COPY (
SELECT t,
round(io_phase_angle(t)::numeric, 2) AS io_phase,
round(jupiter_cml('40.0N 105.3W 1655m'::observer, t)::numeric, 2) AS cml,
round(jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
)::numeric, 3) AS prob
FROM generate_series(
'2025-06-15 00:00:00+00'::timestamptz,
'2025-06-16 00:00:00+00'::timestamptz,
interval '5 minutes'
) AS t
) TO '/tmp/cml_io_phase.csv' WITH CSV HEADER;
```
Feed the CSV into gnuplot, matplotlib, or any plotting tool to generate the CML/Io-phase diagram. Overlay the Carr source regions and you have the same visualization Radio Jupiter Pro provides — but with data you can customize, share, and version-control.
## Where Radio Jupiter Pro wins
<Aside type="note" title="Radio Jupiter Pro has unique strengths">
For casual, visual use, a dedicated GUI application has advantages.
</Aside>
**Visual CML/Io-phase chart.** Radio Jupiter Pro displays the source regions graphically with the current position highlighted. You can see at a glance which source is active and how close the geometry is to a boundary. pg_orrery returns numbers — you build your own visualization.
**Audio prediction.** Radio Jupiter Pro includes models for the expected spectral characteristics of different source regions. pg_orrery provides geometry and probability only.
**Integrated display.** Radio Jupiter Pro shows Jupiter's position, the current CML, Io phase, predicted source, and receiver recommendations all in one window. With pg_orrery, you compose the information yourself from separate function calls.
**Zero setup.** Install the application, enter your coordinates, and it works. pg_orrery requires PostgreSQL, the extension, and SQL knowledge.
## Where pg_orrery wins
**Platform independence.** Radio Jupiter Pro is Windows-only. pg_orrery runs on any platform that supports PostgreSQL — Linux, macOS, Windows, containers, cloud instances.
**Batch analysis.** Scanning 30 days, 90 days, or a full Jovian apparition at arbitrary resolution is a single `generate_series` query. No manual date advancement.
**Data integration.** Correlating predictions with observation logs, equipment status, weather data, or any other database table is a JOIN. Radio Jupiter Pro has no data export or import capability.
**Automated scheduling.** pg_orrery results feed directly into scripts, cron jobs, or any scheduling system through standard SQL exports. Radio Jupiter Pro requires a human to read the screen.
**Reproducibility.** A SQL query is a complete specification. Share it with another JOVE operator and they get the same results for their location by changing the observer coordinates.

View File

@ -0,0 +1,312 @@
---
title: From Skyfield to SQL
sidebar:
order: 1
description: Side-by-side comparison of Skyfield Python workflows and equivalent pg_orrery SQL queries.
---
import { Tabs, TabItem, Aside, Steps } from "@astrojs/starlight/components";
Skyfield is an excellent Python library for positional astronomy — well-documented, well-tested, and built on the same JPL ephemeris data used by spacecraft navigation teams. If you already use Skyfield, you'll recognize the computations pg_orrery performs. The difference is where they happen.
This page shows the same tasks done both ways. Not to argue one is better than the other — they make different trade-offs — but to help you decide which fits your workflow.
## Observing a planet
The most common starting point: where is Jupiter from my location, right now?
<Tabs>
<TabItem label="Skyfield (Python)">
```python
from skyfield.api import load, Topos
ts = load.timescale() # downloads finals2000A.all
eph = load('de421.bsp') # downloads 17MB BSP file
observer = Topos('40.0 N', '105.3 W', elevation_m=1655)
t = ts.now()
astrometric = (eph['earth'] + observer).at(t).observe(eph['jupiter barycenter'])
alt, az, distance = astrometric.apparent().altaz()
print(f"Az: {az.degrees:.2f} El: {alt.degrees:.2f} Dist: {distance.au:.4f} AU")
```
Before this runs, Skyfield downloads two files:
- `de421.bsp` (17 MB) — JPL planetary ephemeris
- `finals2000A.all` (3.5 MB) — Earth orientation parameters
These files expire. `finals2000A.all` needs refreshing every few months. The BSP file
itself is stable, but managing file paths across environments (local dev, CI, production)
adds friction.
</TabItem>
<TabItem label="pg_orrery (SQL)">
```sql
SELECT topo_azimuth(t) AS az,
topo_elevation(t) AS el,
topo_range(t) / 149597870.7 AS distance_au
FROM planet_observe(5, '40.0N 105.3W 1655m'::observer, now()) t;
```
No files to download. No timescale object. The VSOP87 coefficients are compiled
into the extension — they ship with `CREATE EXTENSION pg_orrery` and never expire.
</TabItem>
</Tabs>
## Batch satellite observation
Observe many satellites at the same timestamp. This is where the architectural difference starts to matter.
<Tabs>
<TabItem label="Skyfield (Python)">
```python
from skyfield.api import load, EarthSatellite, Topos
ts = load.timescale()
observer = Topos('40.0 N', '105.3 W', elevation_m=1655)
t = ts.now()
# Load TLE file
with open('catalog.tle') as f:
lines = f.readlines()
results = []
for i in range(0, len(lines), 3):
name = lines[i].strip()
sat = EarthSatellite(lines[i+1], lines[i+2], name, ts)
topo = (sat - observer).at(t)
alt, az, dist = topo.altaz()
if alt.degrees > 0:
results.append({
'name': name,
'az': az.degrees,
'el': alt.degrees,
'range_km': dist.km
})
# Now you have results in a Python list.
# To correlate with other data, you need to:
# 1. Load that data from your database
# 2. Match by satellite name or NORAD ID
# 3. Merge in Python (pandas, dict lookup, etc.)
```
The results live in Python memory. If you need to correlate with operator contact
schedules, frequency assignments, or historical observation logs that live in
PostgreSQL, you have to bridge two systems.
</TabItem>
<TabItem label="pg_orrery (SQL)">
```sql
-- TLEs already in your database? Just JOIN.
SELECT s.norad_id,
s.name,
topo_azimuth(observe(s.tle, obs.location, now())) AS az,
topo_elevation(observe(s.tle, obs.location, now())) AS el,
topo_range(observe(s.tle, obs.location, now())) AS range_km
FROM satellites s,
(VALUES ('40.0N 105.3W 1655m'::observer)) AS obs(location)
WHERE topo_elevation(observe(s.tle, obs.location, now())) > 0;
```
```sql
-- Correlate with frequency assignments in the same query
SELECT s.norad_id,
s.name,
f.downlink_mhz,
topo_elevation(observe(s.tle, obs.location, now())) AS el
FROM satellites s
JOIN freq_assignments f ON f.norad_id = s.norad_id,
(VALUES ('40.0N 105.3W 1655m'::observer)) AS obs(location)
WHERE topo_elevation(observe(s.tle, obs.location, now())) > 10
ORDER BY el DESC;
```
The computation and the correlation happen in the same process. No data
transfer between Python and PostgreSQL. The query planner can parallelize
across cores when scanning large catalogs.
</TabItem>
</Tabs>
## Time series generation
Generate positions over a time range — for plotting an elevation profile, building a ground track, or analyzing visibility windows.
<Tabs>
<TabItem label="Skyfield (Python)">
```python
from skyfield.api import load, Topos
import numpy as np
ts = load.timescale()
eph = load('de421.bsp')
observer = Topos('40.0 N', '105.3 W', elevation_m=1655)
# Generate 144 timestamps over 24 hours
t0 = ts.now()
t1 = ts.tt_jd(t0.tt + 1.0)
times = ts.linspace(t0, t1, 144)
earth_obs = eph['earth'] + observer
jupiter = eph['jupiter barycenter']
elevations = []
for t in times:
alt, az, dist = earth_obs.at(t).observe(jupiter).apparent().altaz()
elevations.append(alt.degrees)
# Plot with matplotlib
import matplotlib.pyplot as plt
plt.plot(range(len(elevations)), elevations)
plt.ylabel('Elevation (degrees)')
plt.show()
```
The loop is explicit. For 144 points this is fast, but the pattern doesn't
parallelize automatically. For larger sweeps (thousands of satellites, days
of 1-minute resolution), you manage the iteration yourself.
</TabItem>
<TabItem label="pg_orrery (SQL)">
```sql
SELECT t AS time,
topo_azimuth(obs) AS az,
topo_elevation(obs) AS el
FROM generate_series(
now(),
now() + interval '24 hours',
interval '10 minutes'
) AS t,
LATERAL planet_observe(5, '40.0N 105.3W 1655m'::observer, t) AS obs;
```
`generate_series` replaces the Python loop. To change the resolution from
10 minutes to 1 minute, change one parameter. The same pattern works for
any observable — planets, satellites, moons, stars.
Export for plotting:
```sql
COPY (
SELECT t,
topo_elevation(planet_observe(5, '40.0N 105.3W 1655m'::observer, t)) AS el
FROM generate_series(now(), now() + interval '24 hours', interval '10 minutes') t
) TO '/tmp/jupiter_elevation.csv' WITH CSV HEADER;
```
Then plot with whatever tool you prefer — gnuplot, matplotlib, Observable,
a spreadsheet. pg_orrery produces data; visualization is a separate concern.
</TabItem>
</Tabs>
## Pass prediction
Predict when a satellite will be visible from a location. This is where Skyfield and pg_orrery take genuinely different approaches.
<Tabs>
<TabItem label="Skyfield (Python)">
```python
from skyfield.api import load, EarthSatellite, Topos
ts = load.timescale()
observer = Topos('40.0 N', '105.3 W', elevation_m=1655)
line1 = '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025'
line2 = '2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'
sat = EarthSatellite(line1, line2, 'ISS', ts)
t0 = ts.now()
t1 = ts.tt_jd(t0.tt + 1.0)
# find_events returns (times, event_types)
# event_type: 0=rise, 1=culminate, 2=set
times, events = sat.find_events(observer, t0, t1, altitude_degrees=10.0)
for ti, event in zip(times, events):
name = ('rise', 'culminate', 'set')[event]
print(f"{ti.utc_iso()} — {name}")
```
Skyfield's `find_events` uses root-finding to locate the exact moments when
elevation crosses the threshold. This gives sub-second precision for AOS and
LOS times.
</TabItem>
<TabItem label="pg_orrery (SQL)">
```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 pass_aos(p) AS rise_time,
pass_tca(p) AS max_el_time,
pass_max_el(p) AS max_elevation,
pass_los(p) AS set_time,
pass_aos_az(p) AS rise_azimuth,
pass_los_az(p) AS set_azimuth
FROM iss,
predict_passes(tle, '40.0N 105.3W 1655m'::observer,
now(), now() + interval '24 hours', 10.0) p;
```
`predict_passes` returns structured `pass_event` records. Batch prediction
across many satellites is a JOIN:
```sql
SELECT s.name,
pass_aos(p) AS rise,
pass_max_el(p) AS max_el,
pass_los(p) AS set
FROM satellites s,
LATERAL predict_passes(s.tle, '40.0N 105.3W 1655m'::observer,
now(), now() + interval '24 hours', 10.0) p
WHERE s.constellation = 'Iridium'
ORDER BY pass_aos(p);
```
Every Iridium pass in the next 24 hours, filtered by constellation, sorted
chronologically. Adding `JOIN schedules` or `JOIN ground_contacts` keeps the
correlation inside the database.
</TabItem>
</Tabs>
## Where Skyfield wins
<Aside type="note" title="Skyfield has real advantages">
pg_orrery does not replace Skyfield for all use cases. Be clear about where the trade-offs fall.
</Aside>
**Apparent-position corrections.** Skyfield uses the full IAU 2000A nutation model, polar motion corrections, delta-T from IERS data, and iterates for light-time and stellar aberration. pg_orrery v0.3.0 can [optionally use DE441](/guides/de-ephemeris/) for the same underlying geometric accuracy (~0.1 milliarcsecond), but Skyfield still applies corrections that pg_orrery does not — corrections that matter for precision apparent-coordinate work like occultation timing or sub-arcsecond astrometry.
**Rise/set finding.** `find_events()` uses numerical root-finding to pinpoint the exact moment a body crosses an elevation threshold. pg_orrery's `predict_passes` uses a step-and-refine approach that's fast for batches but less precise for individual events.
**Aberration and light-time.** Skyfield iterates to correct for light travel time and applies stellar aberration. pg_orrery uses geometric positions without light-time iteration — the difference is under 20 arcseconds for planets and irrelevant for satellite tracking, but it matters for some applications.
**Visualization integration.** Skyfield works directly with matplotlib, numpy, and the rest of the Python scientific stack. pg_orrery produces rows and columns — you export to CSV or JSON and then plot separately.
**Extensibility.** Skyfield handles arbitrary BSP kernels — Pluto, spacecraft, asteroids with precise ephemerides. pg_orrery's body catalog is fixed at compile time.
## Where pg_orrery wins
**No file management.** No BSP kernels, no timescale data files, no expiring Earth orientation parameters. The computation ships with the extension.
**Batch operations at database speed.** Observing 12,000 satellites in 17ms. 22,500 Lambert solves in 8.3 seconds. These aren't optimized benchmarks — they're `SELECT` statements running on commodity hardware.
**Data correlation.** The computation happens where your data lives. JOIN orbital results with contact schedules, frequency assignments, observation logs, or any other table. No ETL pipeline between Python and PostgreSQL.
**Automatic parallelism.** PostgreSQL's query planner distributes PARALLEL SAFE functions across available cores. You don't manage threads or multiprocessing pools.
**Reproducibility.** A SQL query is a complete, self-contained specification of a computation. No virtual environment, no package versions, no file paths. The same query produces the same result on any PostgreSQL instance with pg_orrery installed.
## Migrating gradually
You don't have to choose one or the other. A practical migration path:
<Steps>
1. **Keep Skyfield for apparent-position work.** Anything requiring aberration corrections, polar motion, nutation at IAU 2000A level, or custom BSP kernels stays in Python. For raw geometric position accuracy, pg_orrery with [DE enabled](/guides/de-ephemeris/) matches Skyfield.
2. **Move batch observation to SQL.** If you're computing positions for hundreds of objects to filter or correlate with database records, pg_orrery eliminates the Python-to-PostgreSQL round trip.
3. **Move scheduling to SQL.** Pass prediction and visibility windows over time ranges are natural `generate_series` + `predict_passes` queries.
4. **Move reporting to SQL.** "What was above 20 degrees from each of our 5 observers last night?" is a single query with a CROSS JOIN, not a Python loop over observers and timestamps.
5. **Enable DE when accuracy matters.** If you find VSOP87's ~1 arcsecond isn't enough for a specific use case, [configure a DE file](/guides/de-ephemeris/) and switch to `_de()` function variants. Same SQL patterns, same parameters — just add `_de` to the function name.
</Steps>

View File

@ -0,0 +1,502 @@
---
title: The SQL Advantage
sidebar:
order: 5
description: SQL patterns that make pg_orrery uniquely powerful — generate_series, CROSS JOIN, PARALLEL SAFE, materialized views, GiST indexes, and more.
---
import { Tabs, TabItem, Aside } from "@astrojs/starlight/components";
The previous pages compared pg_orrery with specific tools — Skyfield, JPL Horizons, GMAT, Radio Jupiter Pro. This page steps back and looks at the thing they all have in common: none of them are SQL.
That sounds obvious, but the implications are deeper than "you can write queries instead of scripts." SQL brings a set of patterns — compositional, declarative, and optimized by decades of database engine development — that change what's practical to compute.
Each pattern below includes a concrete pg_orrery example.
## generate_series: time series without loops
The most common pattern in orbital computation is "evaluate this function at regular time intervals." In Python, that's a for-loop. In SQL, it's `generate_series`.
```sql
-- Mars elevation every 15 minutes for a week
SELECT t,
topo_elevation(planet_observe(4, '40.0N 105.3W 1655m'::observer, t)) AS el
FROM generate_series(
now(),
now() + interval '7 days',
interval '15 minutes'
) AS t;
```
672 data points. One statement. Change the interval to `'1 minute'` and you get 10,080 points — same statement, same structure. The query planner decides how to execute it; you describe what you want.
This replaces the boilerplate that dominates astronomical computation scripts: initializing time arrays, iterating, collecting results into lists, converting units. Here, the time array is the `generate_series` call, and the computation is inline.
### Irregular time grids
`generate_series` handles regular intervals. For irregular grids — say, the timestamps from an observation log — use a subquery or VALUES list:
```sql
-- Compute Jupiter position at each recorded observation time
SELECT o.obs_id,
o.obs_time,
topo_azimuth(planet_observe(5, o.location, o.obs_time)) AS az,
topo_elevation(planet_observe(5, o.location, o.obs_time)) AS el
FROM observations o
WHERE o.target = 'Jupiter'
AND o.obs_time > now() - interval '30 days';
```
The function evaluates at whatever timestamps exist in your data. No pre-generating a time grid and interpolating.
## CROSS JOIN: parameter sweeps
When you need to evaluate a function across every combination of multiple parameters, SQL's CROSS JOIN (or implicit comma-join) generates the Cartesian product.
### Pork chop plot
The canonical example: departure date x arrival date for Lambert transfers.
```sql
SELECT dep::date AS departure,
arr::date AS arrival,
round(c3_departure::numeric, 2) AS c3_km2s2
FROM generate_series(
'2028-08-01'::timestamptz, '2029-01-01'::timestamptz,
interval '2 days') AS dep,
generate_series(
'2029-03-01'::timestamptz, '2029-10-01'::timestamptz,
interval '2 days') AS arr,
LATERAL lambert_transfer(3, 4, dep, arr) AS xfer
WHERE tof_days > 90;
```
Two `generate_series` calls, one `LATERAL` function. The database generates every combination, evaluates the Lambert solver for each, and returns the results. No nested loops, no progress bars, no managing iteration state.
### Multi-observer visibility
Which observer has the best view of each planet right now?
```sql
SELECT body_id,
obs_name,
round(topo_elevation(
planet_observe(body_id, location, now())
)::numeric, 1) AS el
FROM generate_series(1, 8) AS body_id,
(VALUES
('Boulder', '40.0N 105.3W 1655m'::observer),
('Mauna Kea', '19.8N 155.5W 4205m'::observer),
('Paranal', '24.6S 70.4W 2635m'::observer),
('Tenerife', '28.3N 16.5W 2390m'::observer)
) AS obs(obs_name, location)
WHERE topo_elevation(planet_observe(body_id, location, now())) > 0
ORDER BY body_id, el DESC;
```
8 planets times 4 observers = 32 evaluations. Filtered to only above-horizon results. Sorted so the best observer for each planet appears first.
## JOIN: correlate with anything
This is the pattern that no standalone computation tool can replicate. When orbital data lives in the same database as your other data, correlation is a JOIN — not an export-import-match pipeline.
### Satellite visibility with frequency data
```sql
-- Which satellites are visible AND transmitting on frequencies
-- our receiver can handle?
SELECT s.norad_id,
s.name,
f.downlink_mhz,
f.mode,
round(topo_elevation(
observe(s.tle, '40.0N 105.3W 1655m'::observer, now())
)::numeric, 1) AS el,
round(topo_range(
observe(s.tle, '40.0N 105.3W 1655m'::observer, now())
)::numeric, 0) AS range_km
FROM satellites s
JOIN freq_assignments f ON f.norad_id = s.norad_id
WHERE f.downlink_mhz BETWEEN 144.0 AND 146.0 -- 2m band
AND topo_elevation(observe(s.tle, '40.0N 105.3W 1655m'::observer, now())) > 10
ORDER BY el DESC;
```
The satellite catalog, frequency database, and orbit propagation all participate in the same query. No intermediate files, no API calls, no data format conversion.
### Pass prediction with contact scheduling
```sql
-- Predict passes for our constellation and check against existing schedule
SELECT s.name,
pass_aos(p) AS rise,
pass_max_el(p) AS max_el,
pass_los(p) AS set,
CASE WHEN cs.id IS NOT NULL THEN 'SCHEDULED'
ELSE 'AVAILABLE'
END AS status
FROM satellites s,
LATERAL predict_passes(
s.tle, '40.0N 105.3W 1655m'::observer,
now(), now() + interval '24 hours', 15.0
) p
LEFT JOIN contact_schedule cs
ON cs.norad_id = s.norad_id
AND cs.start_time < pass_los(p)
AND cs.end_time > pass_aos(p)
WHERE s.constellation = 'ORBCOMM'
ORDER BY pass_aos(p);
```
Every predicted pass, annotated with whether it overlaps an existing scheduled contact. The LEFT JOIN means unscheduled windows show up as 'AVAILABLE' — these are the gaps you can fill.
### Burst prediction with weather
```sql
-- Jupiter burst windows, filtered by weather forecast
SELECT t AT TIME ZONE 'America/Denver' AS local_time,
round(jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
)::numeric, 3) AS burst_prob,
w.cloud_cover_pct,
w.precipitation_mm
FROM generate_series(
now(), now() + interval '12 hours', interval '15 minutes'
) AS t
LEFT JOIN weather_forecast w
ON w.station = 'KBDU'
AND w.forecast_time = date_trunc('hour', t)
WHERE topo_elevation(planet_observe(5, '40.0N 105.3W 1655m'::observer, t)) > 10
AND jupiter_burst_probability(
io_phase_angle(t),
jupiter_cml('40.0N 105.3W 1655m'::observer, t)
) > 0.2
ORDER BY t;
```
Radio observation is affected by weather differently than optical — rain matters less than ionospheric conditions — but the pattern is the same. Whatever data you have that affects observing decisions, JOIN it in.
## PARALLEL SAFE: automatic multi-core
All pg_orrery computation functions are declared `PARALLEL SAFE`. This means PostgreSQL's query planner can distribute work across multiple CPU cores without any explicit threading or multiprocessing code.
<Aside type="tip" title="When parallelism kicks in">
PostgreSQL enables parallel query execution when the estimated cost exceeds `parallel_tuple_cost * min_parallel_table_scan_size`. For pg_orrery, this typically happens when scanning tables with hundreds or thousands of rows, or when `generate_series` produces large result sets. You can check the execution plan with `EXPLAIN ANALYZE`.
</Aside>
```sql
-- Observe all 12,000+ satellites in a catalog
-- PostgreSQL will parallelize this across available cores
EXPLAIN ANALYZE
SELECT s.norad_id,
topo_elevation(observe(s.tle, '40.0N 105.3W 1655m'::observer, now())) AS el
FROM satellites s;
```
The execution plan will show `Parallel Seq Scan` or `Gather` nodes when the planner decides parallelism is worthwhile. You don't request it, configure worker pools, or manage thread safety. The database handles it.
For explicit control when testing:
```sql
-- Force parallel execution with 4 workers
SET max_parallel_workers_per_gather = 4;
SET parallel_tuple_cost = 0;
SET parallel_setup_cost = 0;
SELECT s.norad_id,
topo_elevation(observe(s.tle, '40.0N 105.3W 1655m'::observer, now())) AS el
FROM satellites s
WHERE topo_elevation(observe(s.tle, '40.0N 105.3W 1655m'::observer, now())) > 0;
```
## CREATE MATERIALIZED VIEW: cache expensive computations
Some computations are expensive to run repeatedly — a 30-day burst calendar, a full catalog observation, a pork chop plot survey. Materialized views store the result and let you query it like a table.
```sql
-- Cache tonight's satellite visibility for all observers
CREATE MATERIALIZED VIEW tonight_visibility AS
SELECT s.norad_id,
s.name,
obs.obs_name,
pass_aos(p) AS rise,
pass_max_el(p) AS max_el,
pass_los(p) AS set
FROM satellites s,
(VALUES
('Boulder', '40.0N 105.3W 1655m'::observer),
('London', '51.5N 0.1W 11m'::observer),
('Tokyo', '35.7N 139.7E 40m'::observer)
) AS obs(obs_name, location),
LATERAL predict_passes(
s.tle, obs.location,
now(), now() + interval '24 hours', 10.0
) p;
-- Query it instantly
SELECT * FROM tonight_visibility
WHERE obs_name = 'Boulder'
AND max_el > 45
ORDER BY rise;
-- Refresh when TLEs update
REFRESH MATERIALIZED VIEW tonight_visibility;
```
The initial computation might take seconds for a large catalog. Subsequent queries against the materialized view are instant — it's just reading a table. Refresh it when the underlying data changes (new TLEs, new day).
### Concurrent refresh
For production systems where you don't want to block readers during refresh:
```sql
CREATE UNIQUE INDEX ON tonight_visibility (norad_id, obs_name, rise);
REFRESH MATERIALIZED VIEW CONCURRENTLY tonight_visibility;
```
The `CONCURRENTLY` option requires a unique index but allows queries to continue reading the old data while the refresh runs.
## COPY TO: export to anything
pg_orrery produces structured data. Getting it out of PostgreSQL and into other tools is a `COPY` statement.
<Tabs>
<TabItem label="CSV">
```sql
COPY (
SELECT t, topo_elevation(planet_observe(5, '40.0N 105.3W 1655m'::observer, t)) AS el
FROM generate_series(now(), now() + interval '24 hours', interval '10 minutes') t
) TO '/tmp/jupiter_elevation.csv' WITH CSV HEADER;
```
</TabItem>
<TabItem label="JSON">
```sql
COPY (
SELECT json_build_object(
'time', t,
'az', topo_azimuth(planet_observe(5, obs, t)),
'el', topo_elevation(planet_observe(5, obs, t)),
'range_au', topo_range(planet_observe(5, obs, t)) / 149597870.7
)
FROM generate_series(now(), now() + interval '24 hours', interval '1 hour') t,
(VALUES ('40.0N 105.3W 1655m'::observer)) AS o(obs)
) TO '/tmp/jupiter.jsonl';
```
</TabItem>
<TabItem label="psql to stdout">
```bash
psql -c "
COPY (
SELECT dep::date, arr::date, round(c3_departure::numeric, 2) AS c3
FROM generate_series('2028-08-01'::timestamptz, '2029-01-01'::timestamptz, '5 days') dep,
generate_series('2029-03-01'::timestamptz, '2029-10-01'::timestamptz, '5 days') arr,
LATERAL lambert_transfer(3, 4, dep, arr) xfer
WHERE tof_days > 90
) TO STDOUT WITH CSV HEADER
" > porkchop.csv
```
</TabItem>
</Tabs>
CSV feeds into gnuplot, matplotlib, Excel, R, Observable, or any visualization tool. JSON feeds into web applications, APIs, or document databases. The computation stays in PostgreSQL; rendering happens wherever you prefer.
## GiST INDEX: spatial queries on orbital elements
pg_orrery's TLE type supports GiST indexing. This enables spatial-style queries over orbital elements — finding satellites that share similar orbits or screening for conjunction risks.
```sql
-- Create a GiST index on the satellite catalog
CREATE INDEX idx_satellites_tle ON satellites USING gist (tle);
-- Find satellites with orbital overlap (similar altitude, inclination, RAAN)
SELECT a.name AS sat_a,
b.name AS sat_b,
a.tle <-> b.tle AS altitude_separation_km
FROM satellites a, satellites b
WHERE a.norad_id < b.norad_id -- Avoid duplicate pairs
AND a.tle && b.tle -- Orbital overlap (GiST accelerated)
AND a.tle <-> b.tle < 50 -- Within 50 km altitude
ORDER BY a.tle <-> b.tle
LIMIT 100;
```
The `&&` operator tests for orbital overlap — whether two TLEs describe orbits in the same region of space. The `<->` operator returns altitude separation in kilometers. Both are accelerated by the GiST index, meaning the database can prune the search space before evaluating expensive propagation.
For a catalog of 12,000 satellites, a full cross-product would be 72 million pairs. The GiST index reduces this to the pairs that are actually in the same orbital regime.
### Conjunction screening
```sql
-- Catalog-wide conjunction screening for the next 24 hours
-- GiST index pre-filters to nearby orbital regimes
SELECT a.norad_id AS sat_a,
b.norad_id AS sat_b,
a.tle <-> b.tle AS alt_sep_km
FROM satellites a, satellites b
WHERE a.norad_id < b.norad_id
AND a.tle && b.tle
AND a.tle <-> b.tle < 10
ORDER BY a.tle <-> b.tle;
```
This is a screening filter, not a precision conjunction analysis. It identifies pairs worth investigating further — the ones where orbital elements suggest close approaches. Detailed conjunction assessment would then propagate those specific pairs at high time resolution.
## Provider switching: accuracy when you need it
pg_orrery v0.3.0 has two ephemeris providers — the built-in VSOP87 pipeline (~1 arcsecond) and optional [JPL DE440/441](/guides/de-ephemeris/) (~0.1 milliarcsecond). The SQL interface makes switching between them a one-character change.
```sql
-- VSOP87 (built-in, IMMUTABLE, no setup)
SELECT topo_elevation(planet_observe(5, '40.0N 105.3W 1655m'::observer, now()));
-- DE441 (opt-in, STABLE, sub-milliarcsecond)
SELECT topo_elevation(planet_observe_de(5, '40.0N 105.3W 1655m'::observer, now()));
```
Same parameters, same return type, same SQL patterns. Add `_de` and you get Horizons-quality positions. Remove it and you get zero-dependency speed.
The distinction matters at the SQL level because of **volatility**. VSOP87 functions are `IMMUTABLE` — their output depends only on their arguments. PostgreSQL can constant-fold them during planning, use them in expression indexes, and cache results aggressively. DE functions are `STABLE` — they depend on an external file, so the planner evaluates them once per row per statement but can't index on them.
```sql
-- This works: expression index on IMMUTABLE VSOP87 function
CREATE INDEX ON almanac (date)
WHERE topo_elevation(planet_observe(5, location, date)) > 0;
-- For DE queries, use a materialized view instead
CREATE MATERIALIZED VIEW almanac_de AS
SELECT date, topo_elevation(planet_observe_de(5, location, date)) AS el
FROM almanac WHERE topo_elevation(planet_observe_de(5, location, date)) > 0;
```
Use VSOP87 for indexes and fast screening. Use DE for final-answer queries where accuracy matters. Both compose with every other SQL pattern on this page.
## Window functions: tracking changes over time
SQL window functions let you compute values relative to neighboring rows — previous values, running averages, ranks within groups — without self-joins or subqueries.
### Range rate changes
```sql
-- Track ISS range and range rate, flagging closest approach
WITH iss_track AS (
SELECT t,
topo_range(observe(iss.tle, '40.0N 105.3W 1655m'::observer, t)) AS range_km,
topo_range_rate(observe(iss.tle, '40.0N 105.3W 1655m'::observer, t)) AS range_rate,
topo_elevation(observe(iss.tle, '40.0N 105.3W 1655m'::observer, t)) AS el
FROM (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 iss(tle),
generate_series(now(), now() + interval '2 hours', interval '30 seconds') AS t
WHERE topo_elevation(observe(iss.tle, '40.0N 105.3W 1655m'::observer, t)) > 0
)
SELECT t,
round(range_km::numeric, 1) AS range_km,
round(range_rate::numeric, 3) AS range_rate_kms,
round(el::numeric, 1) AS elevation,
CASE
WHEN range_rate < 0 AND lead(range_rate) OVER (ORDER BY t) >= 0
THEN 'CLOSEST APPROACH'
ELSE ''
END AS event
FROM iss_track
ORDER BY t;
```
The `lead()` window function looks at the next row's range rate. When range rate crosses from negative to positive, the satellite has passed closest approach. No separate analysis step — it's computed inline.
### Daily peak elevation
```sql
-- Which planet reaches the highest elevation each night this month?
WITH hourly AS (
SELECT body_id,
t::date AS night,
topo_elevation(planet_observe(body_id, '40.0N 105.3W 1655m'::observer, t)) AS el
FROM generate_series(1, 8) AS body_id,
generate_series(now(), now() + interval '30 days', interval '1 hour') AS t
)
SELECT night,
body_id,
CASE body_id
WHEN 1 THEN 'Mercury' WHEN 2 THEN 'Venus' WHEN 3 THEN 'Earth'
WHEN 4 THEN 'Mars' WHEN 5 THEN 'Jupiter' WHEN 6 THEN 'Saturn'
WHEN 7 THEN 'Uranus' WHEN 8 THEN 'Neptune'
END AS planet,
round(max_el::numeric, 1) AS peak_el
FROM (
SELECT *,
max(el) OVER (PARTITION BY body_id, night) AS max_el,
ROW_NUMBER() OVER (PARTITION BY night ORDER BY max(el) OVER (PARTITION BY body_id, night) DESC) AS rn
FROM hourly
WHERE el > 0
) sub
WHERE rn = 1
GROUP BY night, body_id, max_el
ORDER BY night;
```
For each night, this finds which planet reaches the highest elevation — useful for deciding what to observe. The window function handles the ranking within each night without a correlated subquery.
## Composition: building complex queries from simple parts
The real power of SQL is that these patterns compose. A single query can use `generate_series` for time steps, `CROSS JOIN` for parameter sweeps, `JOIN` for data correlation, window functions for change detection, and `COPY TO` for export — all in one statement.
```sql
-- Complete observation planning query:
-- For each of 3 observers, for each visible planet tonight,
-- find the 2-hour window with the highest average elevation,
-- export to CSV
COPY (
WITH planet_track AS (
SELECT obs_name, body_id, t,
topo_elevation(planet_observe(body_id, location, t)) AS el
FROM (VALUES
('Boulder', '40.0N 105.3W 1655m'::observer),
('Mauna Kea','19.8N 155.5W 4205m'::observer),
('Paranal', '24.6S 70.4W 2635m'::observer)
) AS obs(obs_name, location),
generate_series(1, 8) AS body_id,
generate_series(
date_trunc('day', now()) + interval '1 hour',
date_trunc('day', now()) + interval '13 hours',
interval '15 minutes'
) AS t
WHERE topo_elevation(planet_observe(body_id, location, t)) > 10
),
windowed AS (
SELECT obs_name, body_id, t,
el,
avg(el) OVER (
PARTITION BY obs_name, body_id
ORDER BY t
ROWS BETWEEN 4 PRECEDING AND 4 FOLLOWING
) AS rolling_avg_el
FROM planet_track
)
SELECT DISTINCT ON (obs_name, body_id)
obs_name,
body_id,
CASE body_id
WHEN 1 THEN 'Mercury' WHEN 2 THEN 'Venus'
WHEN 3 THEN 'Earth' WHEN 4 THEN 'Mars'
WHEN 5 THEN 'Jupiter' WHEN 6 THEN 'Saturn'
WHEN 7 THEN 'Uranus' WHEN 8 THEN 'Neptune'
END AS planet,
t AS best_window_center,
round(rolling_avg_el::numeric, 1) AS avg_el_in_window
FROM windowed
ORDER BY obs_name, body_id, rolling_avg_el DESC
) TO '/tmp/observation_plan.csv' WITH CSV HEADER;
```
This query:
1. Generates time steps across the night (`generate_series`)
2. Evaluates all 8 planets from 3 observers (`CROSS JOIN`)
3. Filters to above-horizon results (`WHERE`)
4. Computes a rolling 2-hour average elevation (`window function`)
5. Selects the best window for each observer/planet pair (`DISTINCT ON`)
6. Exports to CSV (`COPY TO`)
In a traditional workflow, each of these steps would be a separate script, a separate data file, and a separate tool. In SQL, they compose into a single declarative statement that the database engine optimizes and parallelizes.
That's the advantage. Not that SQL is a better programming language — it isn't. But for the specific pattern of "evaluate a function over structured parameter spaces and correlate the results with existing data," SQL is exactly the right tool. pg_orrery puts 68 functions inside that tool — from 17ms satellite batch propagation to sub-milliarcsecond DE441 planet positions — and every one of them composes with every SQL pattern on this page.

183
docs/src/og-renderer.tsx Normal file
View File

@ -0,0 +1,183 @@
import React from "react";
import type { RenderFunctionInput } from "astro-opengraph-images";
export async function pgOrreryOgImage({
title,
description,
}: RenderFunctionInput): Promise<React.ReactNode> {
return Promise.resolve(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#0a0e17",
fontFamily: "Inter, system-ui, sans-serif",
position: "relative",
overflow: "hidden",
}}
>
{/* Top accent bar */}
<div
style={{
height: "4px",
width: "100%",
background: "linear-gradient(to right, #f59e0b, #fbbf24, #f59e0b)",
}}
/>
{/* Decorative orbital rings (top-right) */}
<svg
width="320"
height="320"
viewBox="0 0 320 320"
style={{
position: "absolute",
top: "-60px",
right: "-40px",
opacity: 0.08,
}}
>
<ellipse
cx="160"
cy="160"
rx="140"
ry="60"
stroke="#f59e0b"
stroke-width="2"
fill="none"
transform="rotate(-20 160 160)"
/>
<ellipse
cx="160"
cy="160"
rx="120"
ry="45"
stroke="#fbbf24"
stroke-width="1.5"
fill="none"
transform="rotate(35 160 160)"
/>
<ellipse
cx="160"
cy="160"
rx="90"
ry="35"
stroke="#f59e0b"
stroke-width="1"
fill="none"
transform="rotate(-5 160 160)"
/>
<circle cx="160" cy="160" r="8" fill="#f59e0b" />
</svg>
{/* Content area */}
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
flexGrow: 1,
padding: "48px 64px",
}}
>
{/* Title + description */}
<div
style={{
display: "flex",
flexDirection: "column",
marginTop: "32px",
}}
>
<div
style={{
fontSize: 56,
fontWeight: 700,
color: "#e2e8f0",
lineHeight: 1.15,
maxWidth: "900px",
}}
>
{title}
</div>
{description && (
<div
style={{
fontSize: 28,
color: "#8896a8",
marginTop: "20px",
lineHeight: 1.4,
maxWidth: "800px",
}}
>
{description}
</div>
)}
</div>
{/* Footer */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
}}
>
{/* Amber dot */}
<div
style={{
width: "12px",
height: "12px",
borderRadius: "50%",
backgroundColor: "#f59e0b",
}}
/>
<div
style={{
fontSize: 24,
fontWeight: 700,
color: "#e2e8f0",
}}
>
pg_orrery
</div>
<div
style={{
fontSize: 20,
color: "#556677",
marginLeft: "4px",
}}
>
Celestial mechanics for PostgreSQL
</div>
</div>
<div
style={{
fontSize: 18,
color: "#556677",
}}
>
pg-orrery.warehack.ing
</div>
</div>
</div>
{/* Bottom accent bar */}
<div
style={{
height: "4px",
width: "100%",
background: "linear-gradient(to right, #f59e0b, #fbbf24, #f59e0b)",
}}
/>
</div>,
);
}

129
docs/src/styles/custom.css Normal file
View File

@ -0,0 +1,129 @@
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
@import "@fontsource/inter/700.css";
@import "@fontsource/jetbrains-mono/400.css";
@import "@fontsource/jetbrains-mono/500.css";
/* pg_orrery palette — deep space observation theme */
:root {
--sl-font: "Inter", system-ui, -apple-system, sans-serif;
--sl-font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
--sl-color-accent-low: #451a03;
--sl-color-accent: #f59e0b;
--sl-color-accent-high: #fef3c7;
--sl-color-white: #e2e8f0;
--sl-color-gray-1: #cbd5e1;
--sl-color-gray-2: #8896a8;
--sl-color-gray-3: #556677;
--sl-color-gray-4: #2a3f54;
--sl-color-gray-5: #1e2d3d;
--sl-color-gray-6: #111827;
--sl-color-gray-7: #0a0e17;
--sl-color-bg-nav: var(--sl-color-gray-6);
--sl-color-bg-sidebar: var(--sl-color-gray-7);
--sl-color-hairline-light: var(--sl-color-gray-5);
--sl-color-hairline-shade: var(--sl-color-gray-4);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--sl-color-gray-7);
}
::-webkit-scrollbar-thumb {
background: var(--sl-color-gray-5);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--sl-color-gray-4);
}
/* Selection color with amber accent */
::selection {
background-color: #92400e;
color: #e2e8f0;
}
/* SQL code blocks get amber left-border accent */
pre:has(> code.language-sql) {
border-left: 3px solid var(--sl-color-accent);
}
/* Code blocks — raised surface */
pre {
background-color: #111827 !important;
border: 1px solid #1e2d3d;
}
/* Sidebar section labels */
.sl-sidebar-group summary {
font-weight: 600;
letter-spacing: 0.02em;
}
/* Hero title gradient */
.hero .hero-html h1 {
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 50%, #d97706 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Tables */
table {
border-collapse: collapse;
width: 100%;
}
th {
text-align: left;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--sl-color-gray-2);
border-bottom: 1px solid var(--sl-color-gray-5);
}
td {
border-bottom: 1px solid var(--sl-color-gray-5);
font-size: 0.875rem;
}
tr:hover td {
background-color: #1a2332;
}
/* Aside tweaks */
.starlight-aside--note {
border-color: var(--sl-color-accent);
}
/* Focus visible */
:focus-visible {
outline: 2px solid var(--sl-color-accent);
outline-offset: 2px;
}
/* Workflow comparison blocks */
.workflow-compare {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 768px) {
.workflow-compare {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,48 @@
/*
* KaTeX fixes for Starlight
*
* Starlight sets `svg { height: auto }` globally which breaks KaTeX's
* internal SVG elements (fraction bars, radicals, delimiters). These
* elements rely on explicit height values from KaTeX's layout engine.
*/
.katex-html svg {
height: inherit;
}
.katex-html .vlist svg {
height: inherit;
}
/* Wide equations need horizontal scroll */
.katex-display {
overflow-x: auto;
overflow-y: hidden;
padding: 0.5rem 0;
}
/* Ensure KaTeX text is visible against dark background */
.katex {
color: var(--sl-color-white, #e2e8f0);
}
/* Display-mode equations get breathing room */
.katex-display > .katex {
max-width: 100%;
}
/* Fix KaTeX newline spacing in aligned environments */
.katex .base {
margin-top: 2px;
margin-bottom: 2px;
}
/* Ensure fraction lines are visible */
.katex .frac-line {
border-color: currentColor;
}
/* Inline math shouldn't break across lines */
.katex-inline {
white-space: nowrap;
}

3
docs/tsconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}

@ -1 +0,0 @@
Subproject commit ff7b98957dfa2979700a482bde9de9542807293e

View File

@ -1,4 +0,0 @@
comment = 'Orbital mechanics types and functions for PostgreSQL'
default_version = '0.1.0'
module_pathname = '$libdir/pg_orbit'
relocatable = true

4
pg_orrery.control Normal file
View File

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

View File

@ -0,0 +1,188 @@
-- pg_orrery 0.1.0 -> 0.2.0 migration
--
-- Phase 1: Stars, comets, and Keplerian propagation.
-- Adds heliocentric type, star observation, and two-body propagation.
-- ============================================================
-- 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.';
-- ============================================================
-- Phase 2: 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.';
-- ============================================================
-- Phase 3: 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.';
-- ============================================================
-- Phase 3: 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.';
-- ============================================================
-- Phase 4: 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.';

View File

@ -1,4 +1,4 @@
-- pg_orbit -- Orbital mechanics types and functions for PostgreSQL -- pg_orrery -- Orbital mechanics types and functions for PostgreSQL
-- --
-- Types: tle, eci_position, geodetic, topocentric, observer, pass_event -- Types: tle, eci_position, geodetic, topocentric, observer, pass_event
-- Provides SGP4/SDP4 propagation, coordinate transforms, pass prediction, -- Provides SGP4/SDP4 propagation, coordinate transforms, pass prediction,

View File

@ -0,0 +1,86 @@
-- pg_orrery 0.2.0 -> 0.3.0 migration
--
-- Adds optional JPL DE440/441 ephemeris functions.
-- Existing VSOP87/ELP2000-82B functions are unchanged (still IMMUTABLE).
-- New _de() functions are STABLE (depend on external DE binary file).
-- When DE is unavailable, _de() functions fall back to VSOP87 silently.
-- ============================================================
-- Phase 5: DE ephemeris functions (optional high-precision)
-- ============================================================
-- Planet observation with DE ephemeris
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.';
-- Lambert transfer with DE positions
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.';
-- Planetary moon observation with DE parent positions
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.';

704
sql/pg_orrery--0.2.0.sql Normal file
View File

@ -0,0 +1,704 @@
-- 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 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.';
-- ============================================================
-- 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);
-- pg_orrery 0.1.0 -> 0.2.0 migration
--
-- Phase 1: Stars, comets, and Keplerian propagation.
-- Adds heliocentric type, star observation, and two-body propagation.
-- ============================================================
-- 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.';
-- ============================================================
-- Phase 2: 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.';
-- ============================================================
-- Phase 3: 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.';
-- ============================================================
-- Phase 3: 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.';
-- ============================================================
-- Phase 4: 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.';

View File

@ -0,0 +1,64 @@
-- pg_orrery 0.3.0 -> 0.4.0 migration
--
-- Adds observation-to-TLE fitting via batch weighted least-squares
-- differential correction (Vallado & Crawford 2008, AIAA 2008-6770).
-- Uses equinoctial elements internally for singularity-free optimization.
-- LAPACK dgelss_() for SVD solve.
-- ============================================================
-- Phase 6: Orbit determination (TLE fitting from observations)
-- ============================================================
-- Fit TLE from ECI position/velocity ephemeris
CREATE FUNCTION tle_from_eci(
positions eci_position[],
times timestamptz[],
seed tle DEFAULT NULL,
fit_bstar boolean DEFAULT false,
max_iter int4 DEFAULT 15,
OUT fitted_tle tle,
OUT iterations int4,
OUT rms_final float8,
OUT rms_initial float8,
OUT status text
) RETURNS RECORD
AS 'MODULE_PATHNAME' LANGUAGE C STABLE PARALLEL SAFE;
COMMENT ON FUNCTION tle_from_eci(eci_position[], timestamptz[], tle, boolean, int4) IS
'Fit a TLE from ECI position/velocity observations via differential correction. Returns fitted TLE, iteration count, RMS residuals, and convergence status. Requires >= 6 observations.';
-- Fit TLE from topocentric observations (az/el/range)
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,
OUT fitted_tle tle,
OUT iterations int4,
OUT rms_final float8,
OUT rms_initial float8,
OUT status text
) RETURNS RECORD
AS 'MODULE_PATHNAME' LANGUAGE C STABLE PARALLEL SAFE;
COMMENT ON FUNCTION tle_from_topocentric(topocentric[], timestamptz[], observer, tle, boolean, int4) IS
'Fit a TLE from topocentric (az/el/range) observations via differential correction. Requires seed TLE and >= 6 observations.';
-- 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.';

790
sql/pg_orrery--0.3.0.sql Normal file
View File

@ -0,0 +1,790 @@
-- 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 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.';
-- ============================================================
-- 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);
-- pg_orrery 0.1.0 -> 0.2.0 migration
--
-- Phase 1: Stars, comets, and Keplerian propagation.
-- Adds heliocentric type, star observation, and two-body propagation.
-- ============================================================
-- 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.';
-- ============================================================
-- Phase 2: 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.';
-- ============================================================
-- Phase 3: 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.';
-- ============================================================
-- Phase 3: 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.';
-- ============================================================
-- Phase 4: 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.';
-- pg_orrery 0.2.0 -> 0.3.0 migration
--
-- Adds optional JPL DE440/441 ephemeris functions.
-- Existing VSOP87/ELP2000-82B functions are unchanged (still IMMUTABLE).
-- New _de() functions are STABLE (depend on external DE binary file).
-- When DE is unavailable, _de() functions fall back to VSOP87 silently.
-- ============================================================
-- Phase 5: DE ephemeris functions (optional high-precision)
-- ============================================================
-- Planet observation with DE ephemeris
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.';
-- Lambert transfer with DE positions
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.';
-- Planetary moon observation with DE parent positions
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.';

View File

@ -0,0 +1,62 @@
-- pg_orrery 0.4.0 -> 0.5.0 migration
--
-- Adds multi-observer support, IOD bootstrap (seed-free fitting),
-- and covariance output for uncertainty estimation.
--
-- Covariance changes the return type of tle_from_eci and
-- tle_from_topocentric (5 → 8 OUT params), which requires
-- DROP + re-CREATE.
-- ============================================================
-- Drop old 5-column OD functions
-- ============================================================
DROP FUNCTION IF EXISTS tle_from_eci(eci_position[], timestamptz[], tle, boolean, int4);
DROP FUNCTION IF EXISTS tle_from_topocentric(topocentric[], timestamptz[], observer, tle, boolean, int4);
-- ============================================================
-- Re-create with 8-column output (adds covariance)
-- ============================================================
CREATE FUNCTION tle_from_eci(
positions eci_position[], times timestamptz[],
seed tle DEFAULT NULL, fit_bstar boolean DEFAULT false,
max_iter int4 DEFAULT 15,
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) IS
'Fit a TLE from ECI position/velocity observations via differential correction. Returns fitted TLE, iteration count, RMS residuals, convergence status, condition number, and formal covariance matrix.';
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,
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) IS
'Fit a TLE from topocentric (az/el/range) observations via differential correction. Returns fitted TLE, RMS residuals, convergence status, condition number, and formal covariance matrix.';
-- ============================================================
-- Multi-observer topocentric fitting (new overload)
-- ============================================================
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,
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) IS
'Fit a TLE from topocentric observations collected by multiple ground stations. observer_ids[i] indexes into observers[]. Returns convergence status, condition number, and formal covariance matrix.';

854
sql/pg_orrery--0.4.0.sql Normal file
View File

@ -0,0 +1,854 @@
-- 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 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.';
-- ============================================================
-- 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);
-- pg_orrery 0.1.0 -> 0.2.0 migration
--
-- Phase 1: Stars, comets, and Keplerian propagation.
-- Adds heliocentric type, star observation, and two-body propagation.
-- ============================================================
-- 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.';
-- ============================================================
-- Phase 2: 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.';
-- ============================================================
-- Phase 3: 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.';
-- ============================================================
-- Phase 3: 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.';
-- ============================================================
-- Phase 4: 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.';
-- pg_orrery 0.2.0 -> 0.3.0 migration
--
-- Adds optional JPL DE440/441 ephemeris functions.
-- Existing VSOP87/ELP2000-82B functions are unchanged (still IMMUTABLE).
-- New _de() functions are STABLE (depend on external DE binary file).
-- When DE is unavailable, _de() functions fall back to VSOP87 silently.
-- ============================================================
-- Phase 5: DE ephemeris functions (optional high-precision)
-- ============================================================
-- Planet observation with DE ephemeris
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.';
-- Lambert transfer with DE positions
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.';
-- Planetary moon observation with DE parent positions
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.';
-- pg_orrery 0.3.0 -> 0.4.0 migration
--
-- Adds observation-to-TLE fitting via batch weighted least-squares
-- differential correction (Vallado & Crawford 2008, AIAA 2008-6770).
-- Uses equinoctial elements internally for singularity-free optimization.
-- LAPACK dgelss_() for SVD solve.
-- ============================================================
-- Phase 6: Orbit determination (TLE fitting from observations)
-- ============================================================
-- Fit TLE from ECI position/velocity ephemeris
CREATE FUNCTION tle_from_eci(
positions eci_position[],
times timestamptz[],
seed tle DEFAULT NULL,
fit_bstar boolean DEFAULT false,
max_iter int4 DEFAULT 15,
OUT fitted_tle tle,
OUT iterations int4,
OUT rms_final float8,
OUT rms_initial float8,
OUT status text
) RETURNS RECORD
AS 'MODULE_PATHNAME' LANGUAGE C STABLE PARALLEL SAFE;
COMMENT ON FUNCTION tle_from_eci(eci_position[], timestamptz[], tle, boolean, int4) IS
'Fit a TLE from ECI position/velocity observations via differential correction. Returns fitted TLE, iteration count, RMS residuals, and convergence status. Requires >= 6 observations.';
-- Fit TLE from topocentric observations (az/el/range)
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,
OUT fitted_tle tle,
OUT iterations int4,
OUT rms_final float8,
OUT rms_initial float8,
OUT status text
) RETURNS RECORD
AS 'MODULE_PATHNAME' LANGUAGE C STABLE PARALLEL SAFE;
COMMENT ON FUNCTION tle_from_topocentric(topocentric[], timestamptz[], observer, tle, boolean, int4) IS
'Fit a TLE from topocentric (az/el/range) observations via differential correction. Requires seed TLE and >= 6 observations.';
-- 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.';

862
sql/pg_orrery--0.5.0.sql Normal file
View File

@ -0,0 +1,862 @@
-- 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 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.';
-- ============================================================
-- 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);
-- pg_orrery 0.1.0 -> 0.2.0 migration
--
-- Phase 1: Stars, comets, and Keplerian propagation.
-- Adds heliocentric type, star observation, and two-body propagation.
-- ============================================================
-- 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.';
-- ============================================================
-- Phase 2: 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.';
-- ============================================================
-- Phase 3: 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.';
-- ============================================================
-- Phase 3: 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.';
-- ============================================================
-- Phase 4: 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.';
-- pg_orrery 0.2.0 -> 0.3.0 migration
--
-- Adds optional JPL DE440/441 ephemeris functions.
-- Existing VSOP87/ELP2000-82B functions are unchanged (still IMMUTABLE).
-- New _de() functions are STABLE (depend on external DE binary file).
-- When DE is unavailable, _de() functions fall back to VSOP87 silently.
-- ============================================================
-- Phase 5: DE ephemeris functions (optional high-precision)
-- ============================================================
-- Planet observation with DE ephemeris
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.';
-- Lambert transfer with DE positions
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.';
-- Planetary moon observation with DE parent positions
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.';
-- pg_orrery 0.3.0 -> 0.4.0 migration
--
-- Adds observation-to-TLE fitting via batch weighted least-squares
-- differential correction (Vallado & Crawford 2008, AIAA 2008-6770).
-- Uses equinoctial elements internally for singularity-free optimization.
-- LAPACK dgelss_() for SVD solve.
-- ============================================================
-- Phase 6: Orbit determination (TLE fitting from observations)
-- ============================================================
-- Fit TLE from ECI position/velocity ephemeris
CREATE FUNCTION tle_from_eci(
positions eci_position[], times timestamptz[],
seed tle DEFAULT NULL, fit_bstar boolean DEFAULT false,
max_iter int4 DEFAULT 15,
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) IS
'Fit a TLE from ECI position/velocity observations via differential correction. Returns fitted TLE, iteration count, RMS residuals, convergence status, condition number, and formal covariance matrix.';
-- Fit TLE from topocentric observations (az/el/range) — single observer
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,
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) IS
'Fit a TLE from topocentric (az/el/range) observations via differential correction. 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,
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) IS
'Fit a TLE from topocentric observations collected by multiple ground stations. observer_ids[i] indexes into observers[]. Returns convergence status, condition number, and formal covariance matrix.';
-- 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.';

220
src/astro_math.h Normal file
View File

@ -0,0 +1,220 @@
/*
* astro_math.h -- Shared astronomical math for pg_orrery
*
* Static inline functions used by star_funcs.c, kepler_funcs.c,
* and future planet/moon observation code.
*
* Using static inline preserves the project convention of no
* cross-translation-unit symbol coupling.
*/
#ifndef PG_ORRERY_ASTRO_MATH_H
#define PG_ORRERY_ASTRO_MATH_H
#include <math.h>
#include "types.h"
#define DEG_TO_RAD (M_PI / 180.0)
#define RAD_TO_DEG (180.0 / M_PI)
#define ARCSEC_TO_RAD (M_PI / (180.0 * 3600.0))
/* Pre-computed obliquity trig (IAU 1976, OBLIQUITY_J2000 = 0.40909280422232897 rad).
* Avoids recomputing cos/sin on every frame rotation call. */
#define COS_OBLIQUITY_J2000 0.91748206206918181
#define SIN_OBLIQUITY_J2000 0.39777715593191371
/*
* Greenwich Mean Sidereal Time from Julian date.
* Vallado, "Fundamentals of Astrodynamics", Eq. 3-47.
* Returns angle in radians.
*/
static inline double
gmst_from_jd(double jd)
{
double t_ut1 = (jd - 2451545.0) / 36525.0;
double gmst = 67310.54841
+ (876600.0 * 3600.0 + 8640184.812866) * t_ut1
+ 0.093104 * t_ut1 * t_ut1
- 6.2e-6 * t_ut1 * t_ut1 * t_ut1;
gmst = fmod(gmst * M_PI / 43200.0, 2.0 * M_PI);
if (gmst < 0.0)
gmst += 2.0 * M_PI;
return gmst;
}
/*
* IAU 1976 precession: rotate J2000 equatorial to mean equatorial of date.
*
* Source: Lieske (1979), A&A 73, 282.
* Three angles zeta_A, z_A, theta_A define the precession rotation.
* Valid within ~1 arcsecond for several centuries around J2000.
*/
static inline void
precess_j2000_to_date(double jd, double ra_j2000, double dec_j2000,
double *ra_date, double *dec_date)
{
double T, T2, T3;
double zeta_A, z_A, theta_A;
double cos_dec, sin_dec, cos_ra_zeta, sin_ra_zeta;
double cos_theta, sin_theta;
double A, B, C;
T = (jd - J2000_JD) / 36525.0;
T2 = T * T;
T3 = T2 * T;
/* Precession angles in arcseconds (Lieske 1979) */
zeta_A = (2306.2181 + 1.39656 * T - 0.000139 * T2) * T
+ (0.30188 - 0.000344 * T) * T2 + 0.017998 * T3;
z_A = (2306.2181 + 1.39656 * T - 0.000139 * T2) * T
+ (1.09468 + 0.000066 * T) * T2 + 0.018203 * T3;
theta_A = (2004.3109 - 0.85330 * T - 0.000217 * T2) * T
- (0.42665 + 0.000217 * T) * T2 - 0.041833 * T3;
/* Arcseconds to radians */
zeta_A *= ARCSEC_TO_RAD;
z_A *= ARCSEC_TO_RAD;
theta_A *= ARCSEC_TO_RAD;
/* Direct formula: R3(-z_A) R2(theta_A) R3(-zeta_A) applied to (ra, dec) */
cos_dec = cos(dec_j2000);
sin_dec = sin(dec_j2000);
cos_ra_zeta = cos(ra_j2000 + zeta_A);
sin_ra_zeta = sin(ra_j2000 + zeta_A);
cos_theta = cos(theta_A);
sin_theta = sin(theta_A);
A = cos_dec * sin_ra_zeta;
B = cos_theta * cos_dec * cos_ra_zeta - sin_theta * sin_dec;
C = sin_theta * cos_dec * cos_ra_zeta + cos_theta * sin_dec;
*dec_date = asin(C);
*ra_date = atan2(A, B) + z_A;
if (*ra_date < 0.0)
*ra_date += 2.0 * M_PI;
if (*ra_date >= 2.0 * M_PI)
*ra_date -= 2.0 * M_PI;
}
/*
* Equatorial (hour angle, declination) to horizontal (azimuth, elevation).
* All angles in radians.
* Azimuth convention: 0=N, pi/2=E, pi=S, 3*pi/2=W.
*/
static inline void
equatorial_to_horizontal(double ha, double dec, double lat,
double *az, double *el)
{
double sin_lat = sin(lat);
double cos_lat = cos(lat);
double sin_dec = sin(dec);
double cos_dec = cos(dec);
double cos_ha = cos(ha);
double y, x;
*el = asin(sin_lat * sin_dec + cos_lat * cos_dec * cos_ha);
y = -cos_dec * sin(ha);
x = cos_lat * sin_dec - sin_lat * cos_dec * cos_ha;
*az = atan2(y, x);
if (*az < 0.0)
*az += 2.0 * M_PI;
}
/*
* Ecliptic J2000 to equatorial J2000.
* Simple rotation around X-axis by -obliquity.
*
* NOT safe for aliased (in-place) calls: ecl and equ must not overlap.
* Writing equ[1] before reading ecl[1] for equ[2] produces wrong results
* when ecl == equ. The vendored sgp4/sdp4.c has separate in-place versions
* that use a temp variable; do not confuse the two.
*/
static inline void
ecliptic_to_equatorial(const double *ecl, double *equ)
{
equ[0] = ecl[0];
equ[1] = ecl[1] * COS_OBLIQUITY_J2000 - ecl[2] * SIN_OBLIQUITY_J2000;
equ[2] = ecl[1] * SIN_OBLIQUITY_J2000 + ecl[2] * COS_OBLIQUITY_J2000;
}
/*
* Equatorial J2000 to ecliptic J2000.
* Rotation around X-axis by +obliquity.
*
* NOT safe for aliased (in-place) calls: equ and ecl must not overlap.
* Same ordering hazard as ecliptic_to_equatorial() above.
*/
static inline void
equatorial_to_ecliptic(const double *equ, double *ecl)
{
ecl[0] = equ[0];
ecl[1] = equ[1] * COS_OBLIQUITY_J2000 + equ[2] * SIN_OBLIQUITY_J2000;
ecl[2] = -equ[1] * SIN_OBLIQUITY_J2000 + equ[2] * COS_OBLIQUITY_J2000;
}
/*
* Cartesian to spherical: (x, y, z) -> (ra, dec, dist).
* ra in [0, 2*pi), dec in [-pi/2, pi/2], dist in same units as input.
*/
static inline void
cartesian_to_spherical(const double *xyz, double *ra, double *dec, double *dist)
{
*dist = sqrt(xyz[0] * xyz[0] + xyz[1] * xyz[1] + xyz[2] * xyz[2]);
*dec = asin(xyz[2] / *dist);
*ra = atan2(xyz[1], xyz[0]);
if (*ra < 0.0)
*ra += 2.0 * M_PI;
}
/*
* Geocentric observation pipeline (shared by all observation functions).
*
* Takes geocentric ecliptic J2000 position in AU, observer location,
* and Julian date. Converts through equatorial, precesses to date,
* and computes topocentric az/el.
*
* This is the canonical path:
* ecliptic J2000 -> equatorial J2000 -> precess to date ->
* sidereal time -> hour angle -> az/el
*/
static inline void
observe_from_geocentric(const double geo_ecl_au[3], double jd,
const pg_observer *obs, pg_topocentric *result)
{
double geo_equ[3];
double ra_j2000, dec_j2000, geo_dist;
double ra_date, dec_date;
double gmst_val, lst, ha;
double az, el;
/* Ecliptic J2000 -> equatorial J2000 */
ecliptic_to_equatorial(geo_ecl_au, geo_equ);
/* Cartesian -> spherical */
cartesian_to_spherical(geo_equ, &ra_j2000, &dec_j2000, &geo_dist);
/* Precess J2000 -> date */
precess_j2000_to_date(jd, ra_j2000, dec_j2000, &ra_date, &dec_date);
/* Hour angle and az/el */
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; /* no velocity computation yet */
}
#endif /* PG_ORRERY_ASTRO_MATH_H */

View File

@ -1,5 +1,5 @@
/* /*
* coord_funcs.c -- Coordinate transform functions for pg_orbit * coord_funcs.c -- Coordinate transform functions for pg_orrery
* *
* TEME -> WGS-84 geodetic and TEME -> topocentric transforms. * TEME -> WGS-84 geodetic and TEME -> topocentric transforms.
* *

666
src/de_funcs.c Normal file
View File

@ -0,0 +1,666 @@
/*
* de_funcs.c -- SQL-facing DE ephemeris function variants
*
* Each _de() function is a STABLE STRICT PARALLEL SAFE variant of an
* existing IMMUTABLE function. On any DE failure, falls back to the
* compiled-in VSOP87/ELP2000-82B equivalent with a NOTICE.
*
* The observation pipeline is identical:
* 1. Heliocentric ecliptic J2000 position (DE or fallback)
* 2. Geocentric ecliptic (subtract Earth's heliocentric)
* 3. observe_from_geocentric() -> topocentric az/el
*
* Constant chain of custody rule 7:
* Both target and Earth ALWAYS come from the same provider.
* If DE fails for the target, we don't use DE for Earth either.
*/
#include "postgres.h"
#include "fmgr.h"
#include "funcapi.h"
#include "catalog/pg_type.h"
#include "utils/builtins.h"
#include "utils/timestamp.h"
#include "types.h"
#include "astro_math.h"
#include "eph_provider.h"
#include "vsop87.h"
#include "elp82b.h"
#include "lambert.h"
#include "l12.h"
#include "tass17.h"
#include "gust86.h"
#include "marssat.h"
#include <math.h>
#include <string.h>
/* Forward declarations */
PG_FUNCTION_INFO_V1(planet_heliocentric_de);
PG_FUNCTION_INFO_V1(planet_observe_de);
PG_FUNCTION_INFO_V1(sun_observe_de);
PG_FUNCTION_INFO_V1(moon_observe_de);
PG_FUNCTION_INFO_V1(lambert_transfer_de);
PG_FUNCTION_INFO_V1(lambert_c3_de);
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(pg_orrery_ephemeris_info);
/* ================================================================
* planet_heliocentric_de(body_id int, timestamptz) -> heliocentric
*
* DE variant of planet_heliocentric(). STABLE.
* Falls back to VSOP87 if DE is unavailable.
* ================================================================
*/
Datum
planet_heliocentric_de(PG_FUNCTION_ARGS)
{
int32 body_id = PG_GETARG_INT32(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double xyz[6];
pg_heliocentric *result;
if (body_id == BODY_SUN)
{
result = (pg_heliocentric *) palloc(sizeof(pg_heliocentric));
result->x = 0.0;
result->y = 0.0;
result->z = 0.0;
PG_RETURN_POINTER(result);
}
if (body_id < BODY_MERCURY || body_id > BODY_NEPTUNE)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("invalid body_id %d: must be 0 (Sun) or 1-8 (Mercury-Neptune)",
body_id)));
jd = timestamptz_to_jd(ts);
/* Try DE first */
if (!eph_de_planet(body_id, jd, xyz))
{
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, vsop_body, xyz);
}
result = (pg_heliocentric *) palloc(sizeof(pg_heliocentric));
result->x = xyz[0];
result->y = xyz[1];
result->z = xyz[2];
PG_RETURN_POINTER(result);
}
/* ================================================================
* planet_observe_de(body_id int, observer, timestamptz) -> topocentric
*
* DE variant of planet_observe(). STABLE.
* Rule 7: both planet and Earth from the same provider.
* ================================================================
*/
Datum
planet_observe_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];
pg_topocentric *result;
if (body_id < BODY_MERCURY || body_id > BODY_NEPTUNE)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("planet_observe_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);
/* Try DE for both planet and Earth (rule 7: 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);
}
/* 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_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric(geo_ecl, jd, obs, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* sun_observe_de(observer, timestamptz) -> topocentric
*
* DE variant of sun_observe(). STABLE.
* ================================================================
*/
Datum
sun_observe_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];
pg_topocentric *result;
jd = timestamptz_to_jd(ts);
/* Sun geocentric = -Earth_heliocentric */
if (eph_de_earth(jd, earth_xyz))
{
geo_ecl[0] = -earth_xyz[0];
geo_ecl[1] = -earth_xyz[1];
geo_ecl[2] = -earth_xyz[2];
}
else
{
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];
}
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric(geo_ecl, jd, obs, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* moon_observe_de(observer, timestamptz) -> topocentric
*
* DE variant of moon_observe(). STABLE.
* ================================================================
*/
Datum
moon_observe_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];
pg_topocentric *result;
jd = timestamptz_to_jd(ts);
/* Moon geocentric ecliptic J2000 */
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_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric(moon_ecl, jd, obs, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* Lambert transfer functions with DE positions
* ================================================================
*/
/*
* Compute planet heliocentric velocity via numerical differentiation.
*
* use_de: if true, use DE positions; if false, use VSOP87.
* Must match the provider used for the corresponding position query
* (rule 7: same provider for position and velocity).
*/
static void
planet_velocity_de(int body_id, double jd, bool use_de, double vel[3])
{
double pos_fwd[6], pos_bwd[6];
double dt = 0.01; /* days */
if (use_de)
{
bool got_fwd = eph_de_planet(body_id, jd + dt, pos_fwd);
bool got_bwd = eph_de_planet(body_id, jd - dt, pos_bwd);
if (!got_fwd || !got_bwd)
{
/* DE boundary straddled — use VSOP87 for both to stay consistent */
int vsop_body = body_id - 1;
GetVsop87Coor(jd + dt, vsop_body, pos_fwd);
GetVsop87Coor(jd - dt, vsop_body, pos_bwd);
}
}
else
{
int vsop_body = body_id - 1;
GetVsop87Coor(jd + dt, vsop_body, pos_fwd);
GetVsop87Coor(jd - dt, vsop_body, pos_bwd);
}
vel[0] = (pos_fwd[0] - pos_bwd[0]) / (2.0 * dt);
vel[1] = (pos_fwd[1] - pos_bwd[1]) / (2.0 * dt);
vel[2] = (pos_fwd[2] - pos_bwd[2]) / (2.0 * dt);
}
Datum
lambert_transfer_de(PG_FUNCTION_ARGS)
{
int32 dep_body = PG_GETARG_INT32(0);
int32 arr_body = PG_GETARG_INT32(1);
int64 dep_ts = PG_GETARG_INT64(2);
int64 arr_ts = PG_GETARG_INT64(3);
double dep_jd, arr_jd, tof_days;
double r1[6], r2[6];
double v_planet_dep[3], v_planet_arr[3];
double v_inf_dep[3], v_inf_arr[3];
double v_inf_dep_mag, v_inf_arr_mag;
double c3_dep, c3_arr;
lambert_result lr;
TupleDesc tupdesc;
Datum values[6];
bool nulls[6];
HeapTuple tuple;
double au_per_day_to_km_per_s;
int k;
bool have_de;
if (dep_body < 1 || dep_body > 8)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("lambert_transfer_de: dep_body_id %d must be 1-8", dep_body)));
if (arr_body < 1 || arr_body > 8)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("lambert_transfer_de: arr_body_id %d must be 1-8", arr_body)));
if (dep_body == arr_body)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("lambert_transfer_de: departure and arrival bodies must differ")));
dep_jd = timestamptz_to_jd(dep_ts);
arr_jd = timestamptz_to_jd(arr_ts);
tof_days = arr_jd - dep_jd;
if (tof_days <= 0.0)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("lambert_transfer_de: arrival must be after departure")));
/* Try DE for both positions (rule 7: same provider) */
have_de = eph_de_planet(dep_body, dep_jd, r1) &&
eph_de_planet(arr_body, arr_jd, r2);
if (!have_de)
{
int dep_vsop = dep_body - 1;
int arr_vsop = arr_body - 1;
if (eph_de_available())
ereport(NOTICE,
(errmsg("DE ephemeris unavailable, falling back to VSOP87")));
GetVsop87Coor(dep_jd, dep_vsop, r1);
GetVsop87Coor(arr_jd, arr_vsop, r2);
}
if (!lambert_solve_uv(r1, r2, tof_days, GAUSS_K2, 1, &lr))
PG_RETURN_NULL();
/* Planet velocities (same provider as positions — rule 7) */
planet_velocity_de(dep_body, dep_jd, have_de, v_planet_dep);
planet_velocity_de(arr_body, arr_jd, have_de, v_planet_arr);
au_per_day_to_km_per_s = AU_KM / 86400.0;
for (k = 0; k < 3; k++) {
v_inf_dep[k] = (lr.v1[k] - v_planet_dep[k]) * au_per_day_to_km_per_s;
v_inf_arr[k] = (lr.v2[k] - v_planet_arr[k]) * au_per_day_to_km_per_s;
}
v_inf_dep_mag = sqrt(v_inf_dep[0]*v_inf_dep[0] +
v_inf_dep[1]*v_inf_dep[1] +
v_inf_dep[2]*v_inf_dep[2]);
v_inf_arr_mag = sqrt(v_inf_arr[0]*v_inf_arr[0] +
v_inf_arr[1]*v_inf_arr[1] +
v_inf_arr[2]*v_inf_arr[2]);
c3_dep = v_inf_dep_mag * v_inf_dep_mag;
c3_arr = v_inf_arr_mag * v_inf_arr_mag;
if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("function returning record called in context "
"that cannot accept type record")));
tupdesc = BlessTupleDesc(tupdesc);
memset(nulls, 0, sizeof(nulls));
values[0] = Float8GetDatum(c3_dep);
values[1] = Float8GetDatum(c3_arr);
values[2] = Float8GetDatum(v_inf_dep_mag);
values[3] = Float8GetDatum(v_inf_arr_mag);
values[4] = Float8GetDatum(tof_days);
values[5] = Float8GetDatum(lr.sma);
tuple = heap_form_tuple(tupdesc, values, nulls);
PG_RETURN_DATUM(HeapTupleGetDatum(tuple));
}
Datum
lambert_c3_de(PG_FUNCTION_ARGS)
{
int32 dep_body = PG_GETARG_INT32(0);
int32 arr_body = PG_GETARG_INT32(1);
int64 dep_ts = PG_GETARG_INT64(2);
int64 arr_ts = PG_GETARG_INT64(3);
double dep_jd, arr_jd, tof_days;
double r1[6], r2[6];
double v_planet_dep[3];
double v_inf_dep[3];
double c3_dep;
lambert_result lr;
double au_per_day_to_km_per_s;
int k;
bool have_de;
if (dep_body < 1 || dep_body > 8 || arr_body < 1 || arr_body > 8)
PG_RETURN_NULL();
if (dep_body == arr_body)
PG_RETURN_NULL();
dep_jd = timestamptz_to_jd(dep_ts);
arr_jd = timestamptz_to_jd(arr_ts);
tof_days = arr_jd - dep_jd;
if (tof_days <= 0.0)
PG_RETURN_NULL();
have_de = eph_de_planet(dep_body, dep_jd, r1) &&
eph_de_planet(arr_body, arr_jd, r2);
if (!have_de)
{
int dep_vsop = dep_body - 1;
int arr_vsop = arr_body - 1;
GetVsop87Coor(dep_jd, dep_vsop, r1);
GetVsop87Coor(arr_jd, arr_vsop, r2);
}
if (!lambert_solve_uv(r1, r2, tof_days, GAUSS_K2, 1, &lr))
PG_RETURN_NULL();
planet_velocity_de(dep_body, dep_jd, have_de, v_planet_dep);
au_per_day_to_km_per_s = AU_KM / 86400.0;
for (k = 0; k < 3; k++)
v_inf_dep[k] = (lr.v1[k] - v_planet_dep[k]) * au_per_day_to_km_per_s;
c3_dep = v_inf_dep[0]*v_inf_dep[0] +
v_inf_dep[1]*v_inf_dep[1] +
v_inf_dep[2]*v_inf_dep[2];
PG_RETURN_FLOAT8(c3_dep);
}
/* ================================================================
* Planetary moon observation with DE parent positions
*
* For each planetary moon, the moon-theory offset (L1.2, TASS17,
* GUST86, MarsSat) is computed relative to its parent planet.
* The parent's position comes from DE instead of VSOP87, giving
* sub-arcsecond accuracy for the parent while keeping the
* moon-theory accuracy for the relative offset.
* ================================================================
*/
/*
* Internal: observe a planetary moon using DE for the parent planet
* and Earth positions. Falls back to VSOP87 if DE is unavailable.
*
* moon_rel[3]: moon position relative to parent (ecliptic J2000, AU)
* parent_body_id: pg_orrery body ID of parent (5=Jupiter, 6=Saturn, etc.)
*/
static void
observe_moon_de(const double moon_rel[3], int parent_body_id,
double jd, const pg_observer *obs,
pg_topocentric *result)
{
double parent_xyz[6];
double earth_xyz[6];
double geo_ecl[3];
bool have_de;
/* Rule 7: both parent and Earth from same provider */
have_de = eph_de_planet(parent_body_id, jd, parent_xyz) &&
eph_de_earth(jd, earth_xyz);
if (!have_de)
{
int vsop_parent = parent_body_id - 1;
if (eph_de_available())
ereport(NOTICE,
(errmsg("DE ephemeris unavailable, falling back to VSOP87")));
GetVsop87Coor(jd, vsop_parent, parent_xyz);
GetVsop87Coor(jd, 2, earth_xyz); /* VSOP87 body 2 = Earth */
}
/* Moon geocentric = (parent + moon_relative) - Earth */
geo_ecl[0] = (parent_xyz[0] + moon_rel[0]) - earth_xyz[0];
geo_ecl[1] = (parent_xyz[1] + moon_rel[1]) - earth_xyz[1];
geo_ecl[2] = (parent_xyz[2] + moon_rel[2]) - earth_xyz[2];
observe_from_geocentric(geo_ecl, jd, obs, result);
}
Datum
galilean_observe_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 moon_xyz[3];
pg_topocentric *result;
if (body_id < L12_IO || body_id > L12_CALLISTO)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("galilean_observe_de: body_id %d must be 0-3 (Io-Callisto)",
body_id)));
jd = timestamptz_to_jd(ts);
GetL12Coor(jd, body_id, moon_xyz, NULL);
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_moon_de(moon_xyz, BODY_JUPITER, jd, obs, result);
PG_RETURN_POINTER(result);
}
Datum
saturn_moon_observe_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 moon_xyz[3];
pg_topocentric *result;
if (body_id < TASS17_MIMAS || body_id > TASS17_HYPERION)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("saturn_moon_observe_de: body_id %d must be 0-7 (Mimas-Hyperion)",
body_id)));
jd = timestamptz_to_jd(ts);
GetTass17Coor(jd, body_id, moon_xyz, NULL);
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_moon_de(moon_xyz, BODY_SATURN, jd, obs, result);
PG_RETURN_POINTER(result);
}
Datum
uranus_moon_observe_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 moon_xyz[3];
pg_topocentric *result;
if (body_id < GUST86_MIRANDA || body_id > GUST86_OBERON)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("uranus_moon_observe_de: body_id %d must be 0-4 (Miranda-Oberon)",
body_id)));
jd = timestamptz_to_jd(ts);
GetGust86Coor(jd, body_id, moon_xyz, NULL);
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_moon_de(moon_xyz, BODY_URANUS, jd, obs, result);
PG_RETURN_POINTER(result);
}
Datum
mars_moon_observe_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 moon_xyz[3];
pg_topocentric *result;
if (body_id < MARS_SAT_PHOBOS || body_id > MARS_SAT_DEIMOS)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("mars_moon_observe_de: body_id %d must be 0-1 (Phobos-Deimos)",
body_id)));
jd = timestamptz_to_jd(ts);
GetMarsSatCoor(jd, body_id, moon_xyz, NULL);
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_moon_de(moon_xyz, BODY_MARS, jd, obs, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* pg_orrery_ephemeris_info() -> RECORD
*
* Diagnostic function: returns current ephemeris provider status.
* STABLE (not STRICT no args), PARALLEL SAFE.
* ================================================================
*/
Datum
pg_orrery_ephemeris_info(PG_FUNCTION_ARGS)
{
TupleDesc tupdesc;
Datum values[6];
bool nulls[6];
HeapTuple tuple;
if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("function returning record called in context "
"that cannot accept type record")));
tupdesc = BlessTupleDesc(tupdesc);
memset(nulls, 0, sizeof(nulls));
if (eph_de_available())
{
const char *path = eph_get_path();
values[0] = CStringGetTextDatum("JPL_DE");
values[1] = path ? CStringGetTextDatum(path) : CStringGetTextDatum("");
values[2] = Float8GetDatum(eph_de_start_jd());
values[3] = Float8GetDatum(eph_de_end_jd());
values[4] = Int32GetDatum(eph_de_version());
values[5] = Float8GetDatum(eph_de_au_km());
}
else
{
values[0] = CStringGetTextDatum("VSOP87");
values[1] = (Datum) 0;
values[2] = (Datum) 0;
values[3] = (Datum) 0;
values[4] = (Datum) 0;
nulls[1] = true; /* no file path */
nulls[2] = true; /* no start_jd */
nulls[3] = true; /* no end_jd */
nulls[4] = true; /* no version */
values[5] = Float8GetDatum((double)AU_KM);
}
tuple = heap_form_tuple(tupdesc, values, nulls);
PG_RETURN_DATUM(HeapTupleGetDatum(tuple));
}

694
src/de_reader.c Normal file
View File

@ -0,0 +1,694 @@
/*
* de_reader.c -- Clean-room JPL Development Ephemeris reader
*
* Reads JPL DE binary format (DE430/DE440/DE441).
* No GPL dependency: derived from the public format specification.
*
* The JPL binary ephemeris consists of fixed-size records:
* Record 1: Header (titles, constant names, JD range, layout table)
* Record 2: Constant values
* Records 3+: Chebyshev coefficients for all bodies
*
* Each data record covers a time interval (32 days for DE441).
* Within a record, each body has Chebyshev coefficients for x,y,z
* (or longitude/latitude/distance), possibly split into sub-intervals.
*
* Evaluation uses the Clenshaw recurrence for Chebyshev polynomials.
*
* Reference:
* JPL IOM 312.N-03-009 (Standish 1998)
* "Explanatory Supplement to the Astronomical Almanac" Ch. 8
*/
#include "de_reader.h"
#include <assert.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <unistd.h>
#include <fcntl.h>
/* swap_double() assumes 8-byte doubles (IEEE 754 on all platforms PG supports) */
_Static_assert(sizeof(double) == 8, "DE reader requires 8-byte doubles");
/* Known AU value for byte-order detection */
#define DE_AU_KNOWN 149597870.700
/*
* Byte-swap a double (for big-endian DE files on little-endian hosts).
*/
static void
swap_double(double *val)
{
unsigned char *p = (unsigned char *)val;
unsigned char tmp;
tmp = p[0]; p[0] = p[7]; p[7] = tmp;
tmp = p[1]; p[1] = p[6]; p[6] = tmp;
tmp = p[2]; p[2] = p[5]; p[5] = tmp;
tmp = p[3]; p[3] = p[4]; p[4] = tmp;
}
/*
* Swap an array of doubles in-place.
*/
static void
swap_doubles(double *arr, int count)
{
int i;
for (i = 0; i < count; i++)
swap_double(&arr[i]);
}
/*
* Read exactly n bytes from fd at the given offset.
* Returns 0 on success, -1 on failure.
*/
static int
read_at(int fd, void *buf, size_t n, off_t offset)
{
ssize_t total = 0;
ssize_t got;
if (lseek(fd, offset, SEEK_SET) == (off_t)-1)
return -1;
while ((size_t)total < n)
{
got = read(fd, (char *)buf + total, n - total);
if (got <= 0)
return -1;
total += got;
}
return 0;
}
/*
* Evaluate a Chebyshev polynomial using the Clenshaw recurrence.
*
* coeffs: array of Chebyshev coefficients (T0, T1, ..., T_{n-1})
* n: number of coefficients
* x: normalized argument in [-1, +1]
*
* Returns: sum_{i=0}^{n-1} coeffs[i] * T_i(x)
*/
static double
chebyshev_eval(const double *coeffs, int n, double x)
{
double bk1 = 0.0, bk2 = 0.0, bk;
int i;
for (i = n - 1; i >= 1; i--)
{
bk = 2.0 * x * bk1 - bk2 + coeffs[i];
bk2 = bk1;
bk1 = bk;
}
return x * bk1 - bk2 + coeffs[0];
}
/*
* Interpolate a single component (x, y, or z) for a body at a JD.
*
* h: reader handle (with record already loaded in record_buf)
* body: body group index (0-12)
* comp: component (0=x, 1=y, 2=z)
* jd: Julian date
*
* Returns the interpolated value.
*/
static double
interp_component(de_handle *h, int body, int comp, double jd)
{
de_body_layout *lay = &h->layout[body];
double rec_start, sub_length, t_sub, x;
int sub_idx, coeff_offset;
/* Record start JD */
rec_start = h->record_buf[0];
/* Sub-interval length in days */
sub_length = h->interval_days / lay->nsub;
/* Which sub-interval? */
t_sub = jd - rec_start;
sub_idx = (int)(t_sub / sub_length);
if (sub_idx >= lay->nsub)
sub_idx = lay->nsub - 1;
if (sub_idx < 0)
sub_idx = 0;
/* Normalize to [-1, +1] within the sub-interval */
x = 2.0 * (t_sub - sub_idx * sub_length) / sub_length - 1.0;
/* Clamp to [-1, +1]: floating-point arithmetic at interval
* boundaries can produce |x| slightly > 1.0, which causes
* Chebyshev polynomials to diverge. Legitimate rounding should
* never exceed ~1e-14; anything larger indicates a structural
* arithmetic error in the normalization above. */
if (x > 1.0)
{
assert(x < 1.0 + 1e-10);
x = 1.0;
}
if (x < -1.0)
{
assert(x > -1.0 - 1e-10);
x = -1.0;
}
/*
* Coefficient offset in the record buffer.
* Layout offset is 1-based (Fortran convention), convert to 0-based.
* Each sub-interval has ncoeff coefficients for each of 3 components.
*/
coeff_offset = (lay->offset - 1)
+ sub_idx * lay->ncoeff * 3
+ comp * lay->ncoeff;
return chebyshev_eval(&h->record_buf[coeff_offset], lay->ncoeff, x);
}
/*
* Load the record containing the given JD into the handle's buffer.
* Returns DE_OK or DE_ERR_*.
*/
static int
load_record(de_handle *h, double jd)
{
int recno;
off_t offset;
if (jd < h->start_jd || jd > h->end_jd)
return DE_ERR_RANGE;
recno = (int)((jd - h->start_jd) / h->interval_days);
/* Clamp to last record if jd == end_jd */
{
int max_rec = (int)((h->end_jd - h->start_jd) / h->interval_days) - 1;
if (recno > max_rec)
recno = max_rec;
}
/* Already cached? */
if (recno == h->cached_recno)
return DE_OK;
/* Seek and read the record (skip 2 header records) */
offset = h->data_offset + (off_t)recno * h->record_bytes;
if (read_at(h->fd, h->record_buf, h->record_bytes, offset) != 0)
return DE_ERR_READ;
if (h->swap_bytes)
swap_doubles(h->record_buf, h->ncoeff);
h->cached_recno = recno;
return DE_OK;
}
/*
* Parse the header and validate the ephemeris file.
* Supports DE405 and later (DE430, DE440, DE441).
* Earlier formats have different header sizes and are not supported.
*/
static int
parse_header(de_handle *h)
{
/*
* The header spans 2 records of ncoeff doubles each.
* But we don't know ncoeff yet it's at a fixed position
* in the first record.
*
* Header layout (byte offsets for first record):
* 0..251: 3 title lines (84 chars each)
* 252..2651: 400 constant names (6 chars each)
* 2652..2675: SS[3] = {start_jd, end_jd, interval_days}
* 2676..2679: NCON (int32)
* 2680..2687: AU (double)
* 2688..2695: EMRAT (double)
* 2696..2839: IPT[12][3] = 12 body groups x 3 ints (offset, ncoeff, nsub)
* 2840..2843: DE version number (int32)
* 2844..2855: IPT[12][3] for 13th body (librations)
*
* After IPT parsing, we know ncoeff and can size the record buffer.
*
* All byte offsets assume the first record starts at byte 0.
* The record size in bytes = ncoeff * 8.
*/
unsigned char buf[4096]; /* DE405+ header fits in 4096 bytes */
double ss[3];
double au_val;
int32_t ncon;
int ipt[13][3];
int32_t de_ver;
int i, j;
int max_offset_needed;
/* Read the first 4096 bytes of the file */
if (read_at(h->fd, buf, 4096, 0) != 0)
return DE_ERR_READ;
/* SS: start_jd, end_jd, interval (at byte 2652) */
memcpy(ss, buf + 2652, 3 * sizeof(double));
/* AU (at byte 2680) */
memcpy(&au_val, buf + 2680, sizeof(double));
/*
* Byte-order detection: compare AU against known value.
* If it doesn't match, try byte-swapping.
*/
h->swap_bytes = 0;
if (fabs(au_val - DE_AU_KNOWN) > 1.0)
{
swap_double(&au_val);
if (fabs(au_val - DE_AU_KNOWN) > 1.0)
return DE_ERR_ENDIAN;
h->swap_bytes = 1;
swap_doubles(ss, 3);
}
h->start_jd = ss[0];
h->end_jd = ss[1];
h->interval_days = ss[2];
h->au_km = au_val;
/* NCON at byte 2676 */
memcpy(&ncon, buf + 2676, sizeof(int32_t));
if (h->swap_bytes)
{
unsigned char *p = (unsigned char *)&ncon;
unsigned char tmp;
tmp = p[0]; p[0] = p[3]; p[3] = tmp;
tmp = p[1]; p[1] = p[2]; p[2] = tmp;
}
/* EMRAT at byte 2688 */
memcpy(&h->emrat, buf + 2688, sizeof(double));
if (h->swap_bytes)
swap_double(&h->emrat);
/* IPT: 12 body groups at byte 2696, each 3 x int32 */
for (i = 0; i < 12; i++)
{
int32_t vals[3];
memcpy(vals, buf + 2696 + i * 12, 12);
if (h->swap_bytes)
{
for (j = 0; j < 3; j++)
{
unsigned char *p = (unsigned char *)&vals[j];
unsigned char tmp;
tmp = p[0]; p[0] = p[3]; p[3] = tmp;
tmp = p[1]; p[1] = p[2]; p[2] = tmp;
}
}
ipt[i][0] = vals[0];
ipt[i][1] = vals[1];
ipt[i][2] = vals[2];
}
/* DE version at byte 2840 */
memcpy(&de_ver, buf + 2840, sizeof(int32_t));
if (h->swap_bytes)
{
unsigned char *p = (unsigned char *)&de_ver;
unsigned char tmp;
tmp = p[0]; p[0] = p[3]; p[3] = tmp;
tmp = p[1]; p[1] = p[2]; p[2] = tmp;
}
h->de_version = de_ver;
/* IPT[12] (librations) at byte 2844 */
{
int32_t vals[3];
memcpy(vals, buf + 2844, 12);
if (h->swap_bytes)
{
for (j = 0; j < 3; j++)
{
unsigned char *p = (unsigned char *)&vals[j];
unsigned char tmp;
tmp = p[0]; p[0] = p[3]; p[3] = tmp;
tmp = p[1]; p[1] = p[2]; p[2] = tmp;
}
}
ipt[12][0] = vals[0];
ipt[12][1] = vals[1];
ipt[12][2] = vals[2];
}
/* Store layout and compute ncoeff */
max_offset_needed = 0;
for (i = 0; i < DE_NUM_BODIES; i++)
{
h->layout[i].offset = ipt[i][0];
h->layout[i].ncoeff = ipt[i][1];
h->layout[i].nsub = ipt[i][2];
if (ipt[i][0] > 0 && ipt[i][1] > 0 && ipt[i][2] > 0)
{
int end;
int ncomp = (i == DE_NUTATION) ? 2 : 3;
end = (ipt[i][0] - 1) + ipt[i][1] * ncomp * ipt[i][2];
if (end > max_offset_needed)
max_offset_needed = end;
}
}
/*
* ncoeff: the header record size in doubles.
* The record must be large enough to hold all coefficient data
* plus the 2-double JD range at the start of each data record.
*/
h->ncoeff = max_offset_needed;
if (h->ncoeff < 2)
return DE_ERR_HEADER;
h->record_bytes = h->ncoeff * sizeof(double);
/* Data starts after 2 header records */
h->data_offset = 2L * h->record_bytes;
/* Validate: start_jd < end_jd, interval > 0 */
if (h->start_jd >= h->end_jd || h->interval_days <= 0.0)
return DE_ERR_HEADER;
return DE_OK;
}
/*
* Canary validation: evaluate Earth at J2000.0 and check against
* known DE441 reference position.
*
* Expected Earth ICRS position at J2000.0 (JD 2451545.0):
* x ~ -0.1771 AU, y ~ 0.8873 AU, z ~ 0.3848 AU
*
* Tolerance: 0.01 AU (very loose just catches gross file corruption).
*/
static int
canary_check(de_handle *h)
{
double pos[3];
int err;
err = de_reader_get_pos(h, 2451545.0, DE_EMB, DE_SUN, pos);
if (err != DE_OK)
return DE_ERR_CANARY;
/* Earth-Sun distance varies 0.983-1.017 AU over the year.
* Tight tolerance catches garbled files that 0.9-1.1 would miss. */
{
double dist = sqrt(pos[0]*pos[0] + pos[1]*pos[1] + pos[2]*pos[2]);
if (dist < 0.97 || dist > 1.04)
return DE_ERR_CANARY;
}
return DE_OK;
}
de_handle *
de_reader_open(const char *path, int *errcode)
{
de_handle *h;
int err;
h = (de_handle *)calloc(1, sizeof(de_handle));
if (!h)
{
*errcode = DE_ERR_OPEN;
return NULL;
}
h->fd = -1;
h->cached_recno = -1;
h->record_buf = NULL;
/* Open the ephemeris file (read-only, close-on-exec to prevent FD
* leaks to child processes such as COPY ... PROGRAM or archiving) */
h->fd = open(path, O_RDONLY | O_CLOEXEC);
if (h->fd < 0)
{
*errcode = DE_ERR_OPEN;
free(h);
return NULL;
}
/* Parse header */
err = parse_header(h);
if (err != DE_OK)
{
*errcode = err;
close(h->fd);
free(h);
return NULL;
}
/* Allocate record buffer */
h->record_buf = (double *)calloc(h->ncoeff, sizeof(double));
if (!h->record_buf)
{
*errcode = DE_ERR_OPEN;
close(h->fd);
free(h);
return NULL;
}
/* Canary check */
err = canary_check(h);
if (err != DE_OK)
{
*errcode = err;
free(h->record_buf);
close(h->fd);
free(h);
return NULL;
}
*errcode = DE_OK;
return h;
}
/*
* Get raw body position (3 components) from the ephemeris.
* No center subtraction. Returns position in AU (ICRS equatorial).
*/
static int
get_raw_pos(de_handle *h, double jd, int body, double pos[3])
{
int err;
if (body < 0 || body > DE_SUN)
return DE_ERR_BODY;
/* Nutation and libration are not position queries */
if (body == DE_NUTATION || body == DE_LIBRATION)
return DE_ERR_BODY;
/* Body exists in the DE body table but may have no coefficients
* in this particular DE edition (e.g., Pluto omitted from some files).
* Returns DE_ERR_BODY since callers handle both "invalid index" and
* "not present" by falling back to VSOP87. */
{
de_body_layout *lay = &h->layout[body];
if (lay->offset <= 0 || lay->ncoeff <= 0 || lay->nsub <= 0)
return DE_ERR_BODY;
}
err = load_record(h, jd);
if (err != DE_OK)
return err;
pos[0] = interp_component(h, body, 0, jd);
pos[1] = interp_component(h, body, 1, jd);
pos[2] = interp_component(h, body, 2, jd);
/* Convert from km to AU */
pos[0] /= h->au_km;
pos[1] /= h->au_km;
pos[2] /= h->au_km;
return DE_OK;
}
/*
* Derive Earth position from Earth-Moon Barycenter and Moon.
*
* Earth = EMB - Moon * (1 / (1 + EMRAT))
*
* where EMRAT = M_earth / M_moon ~ 81.3.
* The Moon position in the DE file is geocentric, so:
* Earth = EMB - Moon / (1 + EMRAT)
*/
static int
get_earth_pos(de_handle *h, double jd, double pos[3])
{
double emb[3], moon[3];
double factor;
int err;
err = get_raw_pos(h, jd, DE_EMB, emb);
if (err != DE_OK)
return err;
err = get_raw_pos(h, jd, DE_MOON, moon);
if (err != DE_OK)
return err;
factor = 1.0 / (1.0 + h->emrat);
pos[0] = emb[0] - moon[0] * factor;
pos[1] = emb[1] - moon[1] * factor;
pos[2] = emb[2] - moon[2] * factor;
return DE_OK;
}
int
de_reader_get_pos(de_handle *h, double jd, int target, int center,
double pos[3])
{
double tpos[3], cpos[3];
int err;
if (!h)
return DE_ERR_OPEN;
/* Get target position */
if (target == DE_EMB)
{
/* EMB is stored directly */
err = get_raw_pos(h, jd, DE_EMB, tpos);
}
else if (target == DE_MOON && center != -1)
{
/*
* Moon in the file is geocentric. For Moon relative to
* something other than Earth, we need Earth + geocentric Moon.
*/
double earth[3], moon_geo[3];
err = get_earth_pos(h, jd, earth);
if (err != DE_OK)
return err;
err = get_raw_pos(h, jd, DE_MOON, moon_geo);
if (err != DE_OK)
return err;
/* Moon barycentric = Earth + geocentric_moon */
tpos[0] = earth[0] + moon_geo[0];
tpos[1] = earth[1] + moon_geo[1];
tpos[2] = earth[2] + moon_geo[2];
}
else if (target >= DE_MERCURY && target <= DE_PLUTO && target != DE_EMB)
{
/* Planets are stored directly as SSB-relative */
err = get_raw_pos(h, jd, target, tpos);
}
else if (target == DE_SUN)
{
err = get_raw_pos(h, jd, DE_SUN, tpos);
}
else
{
/* Raw mode for other body types */
err = get_raw_pos(h, jd, target, tpos);
}
if (err != DE_OK)
return err;
/* No center subtraction requested */
if (center < 0)
{
pos[0] = tpos[0];
pos[1] = tpos[1];
pos[2] = tpos[2];
return DE_OK;
}
/* Get center position */
if (center == DE_SUN)
{
err = get_raw_pos(h, jd, DE_SUN, cpos);
}
else if (center == DE_EMB)
{
err = get_raw_pos(h, jd, DE_EMB, cpos);
}
else if (center == 99)
{
/* Special code for "Earth" as center */
err = get_earth_pos(h, jd, cpos);
}
else if (center >= DE_MERCURY && center <= DE_PLUTO)
{
err = get_raw_pos(h, jd, center, cpos);
}
else
{
return DE_ERR_BODY;
}
if (err != DE_OK)
return err;
pos[0] = tpos[0] - cpos[0];
pos[1] = tpos[1] - cpos[1];
pos[2] = tpos[2] - cpos[2];
return DE_OK;
}
void
de_reader_close(de_handle *h)
{
if (!h)
return;
if (h->fd >= 0)
close(h->fd);
if (h->record_buf)
free(h->record_buf);
free(h);
}
double
de_reader_get_const(de_handle *h, const char *name)
{
/*
* Constant lookup requires reading constant names from record 1
* and values from record 2. For now, we expose the key values
* that are already parsed from the header.
*/
if (!h || !name)
return NAN;
if (strcmp(name, "AU") == 0)
return h->au_km;
if (strcmp(name, "EMRAT") == 0)
return h->emrat;
/* NAN sentinel: callers use isnan() to distinguish "not found"
* from a legitimate zero-valued constant. */
return NAN;
}

140
src/de_reader.h Normal file
View File

@ -0,0 +1,140 @@
/*
* de_reader.h -- Clean-room JPL Development Ephemeris reader
*
* Reads JPL DE430/DE440/DE441 binary ephemeris files.
* Implements Chebyshev polynomial evaluation via Clenshaw recurrence.
*
* No GPL dependency: written from the public JPL binary format spec.
* No global state: each handle is independently opened/managed.
*
* Reference:
* JPL IOM 312.N-03-009 "The JPL Planetary and Lunar Ephemerides,
* DE405/LE405" (Standish 1998) — format description.
*/
#ifndef PG_ORRERY_DE_READER_H
#define PG_ORRERY_DE_READER_H
#include <stdint.h>
/*
* JPL DE body targets.
*
* The ephemeris stores 13 "body groups" in a specific order.
* Each group has its own coefficient layout (offset, ncoeff, nsub).
*/
#define DE_MERCURY 0
#define DE_VENUS 1
#define DE_EMB 2 /* Earth-Moon Barycenter */
#define DE_MARS 3
#define DE_JUPITER 4
#define DE_SATURN 5
#define DE_URANUS 6
#define DE_NEPTUNE 7
#define DE_PLUTO 8
#define DE_MOON 9 /* Moon (geocentric) */
#define DE_SUN 10
#define DE_NUTATION 11 /* nutations (dpsi, deps) */
#define DE_LIBRATION 12 /* lunar librations */
#define DE_NUM_BODIES 13
/*
* DE reader error codes.
*/
#define DE_OK 0
#define DE_ERR_OPEN -1 /* cannot open file */
#define DE_ERR_READ -2 /* read() failed or short read */
#define DE_ERR_HEADER -3 /* header validation failed */
#define DE_ERR_RANGE -4 /* JD out of ephemeris range */
#define DE_ERR_BODY -5 /* invalid body target */
#define DE_ERR_CANARY -6 /* canary validation failed */
#define DE_ERR_ENDIAN -7 /* byte order detection failed */
/*
* Coefficient layout for one body group.
* Parsed from the header's IPT array.
*/
typedef struct de_body_layout
{
int offset; /* word offset within record (1-based) */
int ncoeff; /* number of Chebyshev coefficients per component */
int nsub; /* number of sub-intervals per record interval */
} de_body_layout;
/*
* DE reader handle.
*
* One per PostgreSQL backend. Owns its file descriptor and
* a pre-allocated coefficient buffer.
*/
typedef struct de_handle
{
int fd; /* file descriptor */
int swap_bytes; /* 1 if byte-swapping needed */
/* Header metadata */
double start_jd; /* first valid JD */
double end_jd; /* last valid JD */
double interval_days; /* days per record */
double au_km; /* AU in km (from header) */
double emrat; /* Earth-Moon mass ratio */
int ncoeff; /* number of doubles per record */
int de_version; /* e.g. 441 */
/* Coefficient layout per body group */
de_body_layout layout[DE_NUM_BODIES];
/* Record buffer: re-used across queries */
double *record_buf; /* ncoeff doubles */
int cached_recno; /* which record is loaded, -1 = none */
/* Record geometry */
int record_bytes; /* ncoeff * sizeof(double) */
long data_offset; /* byte offset to first data record */
} de_handle;
/*
* Open and validate a JPL DE binary file.
*
* Returns a heap-allocated handle on success, NULL on failure.
* Sets *errcode to one of DE_OK / DE_ERR_*.
* Caller must eventually call de_reader_close().
*
* Does NOT use ereport() caller translates error codes.
*/
de_handle *de_reader_open(const char *path, int *errcode);
/*
* Get the position of a body relative to a center.
*
* target: DE_MERCURY..DE_SUN (0-10)
* center: DE_SUN (10) for heliocentric, DE_EMB (2) for geocentric, etc.
* Use -1 for "raw" (no center subtraction)
* jd: Julian date (TDB)
* pos[3]: output position in AU (ICRS equatorial frame)
*
* Returns DE_OK on success, DE_ERR_* on failure.
*
* For Earth position: returns Earth (derived from EMB and Moon)
* For Moon position with center=Earth: returns geocentric Moon
*/
int de_reader_get_pos(de_handle *h, double jd, int target, int center,
double pos[3]);
/*
* Close the DE reader handle and free all resources.
* Safe to call with NULL handle.
*/
void de_reader_close(de_handle *h);
/*
* Look up a named constant from the DE header.
* Returns the value, or NAN if not found (use isnan() to check).
*/
double de_reader_get_const(de_handle *h, const char *name);
#endif /* PG_ORRERY_DE_READER_H */

View File

@ -0,0 +1,149 @@
/************************************************************************
The code in this file is heavily inspired by the TASS17 and GUST86 theories
found on
ftp://ftp.imcce.fr/pub/ephem/satel
I (Johannes Gajdosik) have just taken the Fortran code and data
obtained from above and rearranged it into this piece of software.
I can neither allow nor forbid the above theories.
The copyright notice below covers just my work,
that is the compilation of the data obtained from above
into the software supplied in this file.
Copyright (c) 2005 Johannes Gajdosik
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
****************************************************************/
#include "elliptic_to_rectangular.h"
#include <math.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
/*
Given the orbital elements at some time t0 calculate the
rectangular coordinates at time (t0+dt).
mu = G*(m1+m2) .. gravitational constant of the two body problem
a .. semi major axis
n = mean motion = 2*M_PI/(orbit period)
elem[0] .. irrelevant (either a (called by EllipticToRectangularA()) or n (called by EllipticToRectangularN())
elem[1] .. L
elem[2] .. K=e*cos(Omega+omega)
elem[3] .. H=e*sin(Omega+omega)
elem[4] .. Q=sin(i/2)*cos(Omega)
elem[5] .. P=sin(i/2)*sin(Omega)
Omega = longitude of ascending node
omega = argument of pericenter
L = mean longitude = Omega + omega + M
M = mean anomaly
i = inclination
e = eccentricity
Units are suspected to be: Julian days, AU, rad
*/
static void
EllipticToRectangular(const double a,const double n,
const double elem[6],const double dt,double xyz[]) {
const double L = fmod(elem[1]+n*dt,2.0*M_PI);
/* solve Keplers equation
x = L - elem[2]*sin(x) + elem[3]*cos(x)
not by trivially iterating
x_0 = L
x_{j+1} = L - elem[2]*sin(x_j) + elem[3]*cos(x_j)
but instead by Newton approximation:
0 = f(x) = x - L - elem[2]*sin(x) + elem[3]*cos(x)
f'(x) = 1 - elem[2]*cos(x) - elem[3]*sin(x)
x_0 = L or whatever, perhaps first step of trivial iteration
x_{j+1} = x_j - f(x_j)/f'(x_j)
*/
double Le = L - elem[2]*sin(L) + elem[3]*cos(L);
for (;;) {
const double cLe = cos(Le);
const double sLe = sin(Le);
/* for eccentricity < 1 we have denominator > 0 */
const double dLe = (L - Le + elem[2]*sLe - elem[3]*cLe)
/ (1.0 - elem[2]*cLe - elem[3]*sLe);
Le += dLe;
if (fabs(dLe) <= 1e-14) break; /* L1: <1e-12 */
}
{
const double cLe = cos(Le);
const double sLe = sin(Le);
const double dlf = -elem[2]*sLe + elem[3]*cLe;
const double phi = sqrt(1.0 - elem[2]*elem[2] - elem[3]*elem[3]);
const double psi = 1.0 / (1.0 + phi);
const double x1 = a * (cLe - elem[2] - psi*dlf*elem[3]);
const double y1 = a * (sLe - elem[3] + psi*dlf*elem[2]);
const double elem_4q = elem[4] * elem[4]; // Q²
const double elem_5q = elem[5] * elem[5]; // P²
const double dwho = 2.0 * sqrt(1.0 - elem_4q - elem_5q);
const double rtp = 1.0 - elem_5q - elem_5q;
const double rtq = 1.0 - elem_4q - elem_4q;
const double rdg = 2.0 * elem[5] * elem[4];
xyz[0] = x1 * rtp + y1 * rdg;
xyz[1] = x1 * rdg + y1 * rtq;
xyz[2] = (-x1 * elem[5] + y1 * elem[4]) * dwho;
// GZ 2017-11: Re-enable these lines, they provide velocity!
const double rsam1 = -elem[2]*cLe - elem[3]*sLe;
const double h = a*n / (1.0 + rsam1);
const double vx1 = h * (-sLe - psi*rsam1*elem[3]);
const double vy1 = h * ( cLe + psi*rsam1*elem[2]);
xyz[3] = vx1 * rtp + vy1 * rdg;
xyz[4] = vx1 * rdg + vy1 * rtq;
xyz[5] = (-vx1 * elem[5] + vy1 * elem[4]) * dwho;
}
}
void EllipticToRectangularN(double mu,const double elem[6],double dt,
double xyz[]) {
const double n = elem[0];
#if defined __USE_MISC || defined __USE_XOPEN_EXTENDED || defined __USE_ISOC99
/* linux math.h declares cbrt: */
const double a = cbrt(mu/(n*n));
#else
const double a = exp(log(mu/(n*n))/3.0);
#endif
EllipticToRectangular(a,n,elem,dt,xyz);
}
void EllipticToRectangularA(double mu,const double elem[6],double dt,
double xyz[]) {
const double a = elem[0];
const double n = sqrt(mu/(a*a*a)); // mean motion
EllipticToRectangular(a,n,elem,dt,xyz);
}

View File

@ -0,0 +1,84 @@
/************************************************************************
The code in this file is heavily inspired by the TASS17 and GUST86 theories
found on
ftp://ftp.imcce.fr/pub/ephem/satel
I (Johannes Gajdosik) have just taken the Fortran code and data
obtained from above and rearranged it into this piece of software.
I can neither allow nor forbid the above theories.
The copyright notice below covers just my work,
that is the compilation of the data obtained from above
into the software supplied in this file.
Copyright (c) 2005 Johannes Gajdosik
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
****************************************************************/
#ifndef ELLIPTIC_TO_RECTANGULAR_H
#define ELLIPTIC_TO_RECTANGULAR_H
#ifdef __cplusplus
extern "C" {
#endif
/*
Given the orbital elements at some time t0 calculate the
rectangular coordinates at time (t0+dt).
mu = G*(m1+m2) .. gravitational constant of the two body problem
a .. semi major axis
n = mean motion = 2*M_PI/(orbit period)
elem[0] .. either a (EllipticToRectangularA()) or n (EllipticToRectangularN())
elem[1] .. L
elem[2] .. K=e*cos(Omega+omega)
elem[3] .. H=e*sin(Omega+omega)
elem[4] .. Q=sin(i/2)*cos(Omega)
elem[5] .. P=sin(i/2)*sin(Omega)
Omega = longitude of ascending node
omega = argument of pericenter
L = mean longitude = Omega + omega + M
M = mean anomaly
i = inclination
e = eccentricity
Units are suspected to be: Julian days, AU, rad
Results:
xyz[0,1,2]=Position [AU]
xyz[3,4,5]=Velocity [AU/d]
*/
void EllipticToRectangularN(double mu,const double elem[6],double dt,
double xyz[]);
void EllipticToRectangularA(double mu,const double elem[6],double dt,
double xyz[]);
#ifdef __cplusplus
}
#endif
#endif

162761
src/elp82b.c Normal file

File diff suppressed because it is too large Load Diff

92
src/elp82b.h Normal file
View File

@ -0,0 +1,92 @@
/************************************************************************
LUNAR SOLUTION ELP2000-82B
by Chapront-Touze M., Chapront J.
ftp://ftp.imcce.fr/pub/ephem/moon/elp82b
I (Johannes Gajdosik) have just taken the Fortran code and data
obtained from above and used it to create this piece of software.
I can neither allow nor forbid the usage of ELP2000-82B.
The copyright notice below covers not the works of
Chapront-Touze M. and Chapront J., but just my work,
that is the compilation and rearrangement of
the Fortran code and data obtained from above
into the software supplied in this file.
Copyright (c) 2005 Johannes Gajdosik
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
My implementation of ELP2000-82B has the following modifications compared to
the original Fortran code:
1) fundamentally rearrange the series into optimized instructions
for fast calculation of the results
2) units are: julian day, AU
This file is derived from the Stellarium project
(https://github.com/Stellarium/stellarium). Modified for pg_orrery:
removed static mutable state for thread safety (PostgreSQL PARALLEL SAFE).
****************************************************************/
#ifndef ELP82B_H
#define ELP82B_H
#ifdef __cplusplus
extern "C" {
#endif
void GetElp82bCoor(double jd,double xyz[3]);
/* Return the rectangular coordinates of the earths moon
on the given julian date jd expressed in dynamical time (TAI+32.184s).
The origin of the xyz-coordinates is the center of the earth.
The reference frame is "dynamical equinox and ecliptic J2000",
which is the reference frame in VSOP87 and VSOP87A.
According to vsop87.doc VSOP87 coordinates can be transformed to FK5 by
X cos(psi) -sin(psi) 0 1 0 0 X
Y = sin(psi) cos(psi) 0 * 0 cos(eps) -sin(eps) * Y
Z FK5 0 0 1 0 sin(eps) cos(eps) Z VSOP87
with psi = -0.0000275 degrees = -0.099 arcsec and
eps = 23.4392803055556 degrees = 23d26m21.4091sec.
http://ssd.jpl.nasa.gov/horizons_doc.html#frames says:
"J2000" selects an Earth Mean-Equator and dynamical Equinox of
Epoch J2000.0 inertial reference system, where the Epoch of J2000.0
is the Julian date 2451545.0. "Mean" indicates nutation effects are
ignored in the frame definition. The system is aligned with the
IAU-sponsored J2000 frame of the Radio Source Catalog of the
International Earth Rotational Service (ICRF).
The ICRF is thought to differ from FK5 by at most 0.01 arcsec.
From this I conclude that in the context of stellarium
ICRF, J2000 and FK5 are the same, while the transformation
ICRF <-> VSOP87 must be done with the matrix given above.
*/
#ifdef __cplusplus
}
#endif
#endif

349
src/eph_provider.c Normal file
View File

@ -0,0 +1,349 @@
/*
* eph_provider.c -- Ephemeris provider management for pg_orrery
*
* Manages the lifecycle of the optional JPL DE ephemeris reader
* within PostgreSQL's multi-process architecture.
*
* Critical design:
* - Each backend (including parallel workers) opens its own
* file descriptor via lazy initialization on first DE call.
* - The postmaster never opens the file (_PG_init only registers GUCs).
* - on_proc_exit callback ensures cleanup.
* - ICRS equatorial -> ecliptic J2000 rotation happens here,
* at the provider boundary, before data enters the observation
* pipeline.
*
* Constant chain of custody:
* - DE returns positions in ICRS equatorial (AU, km internally)
* - We rotate to ecliptic J2000 via equatorial_to_ecliptic()
* - Both target and Earth always come from the same provider
*/
#include "postgres.h"
#include "fmgr.h"
#include "utils/guc.h"
#include "storage/ipc.h"
#include "eph_provider.h"
#include "de_reader.h"
#include "astro_math.h"
#include <math.h>
#include <string.h>
/* GUC variable: path to DE ephemeris file */
static char *eph_path_guc = NULL;
/*
* Per-backend state (safe: each process gets its own copy after fork).
* These are NOT shared memory they live in each backend's address space.
*/
static de_handle *de_handle_ptr = NULL;
static bool de_init_attempted = false;
static bool de_init_success = false;
void
eph_register_gucs(void)
{
DefineCustomStringVariable(
"pg_orrery.ephemeris_path",
"Path to JPL DE binary ephemeris file (DE430/DE440/DE441).",
"When set, enables high-precision _de() function variants. "
"Default empty = no DE support (VSOP87 only). "
"Relative paths are relative to $PGDATA.",
&eph_path_guc,
"", /* default: empty = disabled */
PGC_BACKEND, /* Set at backend startup only.
* Live reload (PGC_SIGHUP) would require an
* assign_hook that resets de_init_attempted,
* closes any open handle, and re-initializes. */
GUC_SUPERUSER_ONLY, /* only superuser can set */
NULL, /* check_hook */
NULL, /* assign_hook */
NULL /* show_hook */
);
}
/*
* Lazy initialization: open the DE file on first call.
* Never called from _PG_init() (postmaster context).
*/
static void
ensure_de_init(void)
{
int errcode;
if (de_init_attempted)
return;
de_init_attempted = true;
de_init_success = false;
/* No path configured? DE is simply unavailable. */
if (eph_path_guc == NULL || eph_path_guc[0] == '\0')
return;
de_handle_ptr = de_reader_open(eph_path_guc, &errcode);
if (de_handle_ptr == NULL)
{
ereport(NOTICE,
(errmsg("pg_orrery: cannot open DE ephemeris at \"%s\" (error %d), "
"DE functions will fall back to VSOP87",
eph_path_guc, errcode)));
return;
}
/* Verify AU consistency with pg_orrery's compiled-in value */
if (fabs(de_handle_ptr->au_km - AU_KM) > 0.01)
{
ereport(NOTICE,
(errmsg("pg_orrery: DE ephemeris AU = %.3f km, expected %.3f km; "
"DE functions will fall back to VSOP87",
de_handle_ptr->au_km, (double)AU_KM)));
de_reader_close(de_handle_ptr);
de_handle_ptr = NULL;
return;
}
ereport(DEBUG1,
(errmsg("pg_orrery: DE%d ephemeris loaded, JD %.1f to %.1f",
de_handle_ptr->de_version,
de_handle_ptr->start_jd,
de_handle_ptr->end_jd)));
de_init_success = true;
}
void
eph_cleanup(int code, Datum arg)
{
if (de_handle_ptr != NULL)
{
de_reader_close(de_handle_ptr);
de_handle_ptr = NULL;
}
de_init_attempted = false;
de_init_success = false;
}
bool
eph_de_available(void)
{
ensure_de_init();
return de_init_success;
}
EphProvider
eph_current_provider(void)
{
if (eph_de_available())
return EPH_JPL_DE;
return EPH_VSOP87;
}
const char *
eph_get_path(void)
{
return eph_path_guc;
}
/*
* Internal: get heliocentric position of a DE body and convert
* from ICRS equatorial to ecliptic J2000.
*
* Returns true on success.
*/
static bool
de_get_helio_ecliptic(int de_target, double jd, double ecl[3])
{
double icrs[3];
int err;
if (!de_handle_ptr)
return false;
/* Get position relative to Sun (DE_SUN = 10) */
err = de_reader_get_pos(de_handle_ptr, jd, de_target, 10, icrs);
if (err != DE_OK)
return false;
/* ICRS equatorial -> ecliptic J2000 */
equatorial_to_ecliptic(icrs, ecl);
return true;
}
/*
* Internal: get Earth's heliocentric position from DE.
*
* Earth = EMB(helio) - Moon(geocentric) / (1 + EMRAT).
* The EMB-to-Earth correction is also in de_reader.c:get_earth_pos();
* both must use the same formula.
*/
static bool
de_get_earth_helio_ecliptic(double jd, double ecl[3])
{
double icrs[3];
int err;
if (!de_handle_ptr)
return false;
/* EMB heliocentric, then correct to Earth below */
err = de_reader_get_pos(de_handle_ptr, jd, DE_EMB, DE_SUN, icrs);
if (err != DE_OK)
return false;
/* Correct EMB-helio to Earth-helio: subtract Moon/(1+EMRAT) */
{
double moon_icrs[3];
double factor;
err = de_reader_get_pos(de_handle_ptr, jd, DE_MOON, -1, moon_icrs);
if (err != DE_OK)
return false;
factor = 1.0 / (1.0 + de_handle_ptr->emrat);
icrs[0] -= moon_icrs[0] * factor;
icrs[1] -= moon_icrs[1] * factor;
icrs[2] -= moon_icrs[2] * factor;
}
equatorial_to_ecliptic(icrs, ecl);
return true;
}
bool
eph_de_planet(int body_id, double jd, double xyz[6])
{
int de_target;
double ecl[3];
if (!eph_de_available())
return false;
de_target = pgbody_to_de_target(body_id);
if (de_target < 0)
return false;
/* Earth is special: derived from EMB and Moon */
if (body_id == BODY_EARTH)
{
if (!de_get_earth_helio_ecliptic(jd, ecl))
return false;
}
else
{
if (!de_get_helio_ecliptic(de_target, jd, ecl))
return false;
}
xyz[0] = ecl[0];
xyz[1] = ecl[1];
xyz[2] = ecl[2];
xyz[3] = 0.0; /* velocity not yet implemented */
xyz[4] = 0.0;
xyz[5] = 0.0;
return true;
}
bool
eph_de_earth(double jd, double xyz[6])
{
double ecl[3];
if (!eph_de_available())
return false;
if (!de_get_earth_helio_ecliptic(jd, ecl))
return false;
xyz[0] = ecl[0];
xyz[1] = ecl[1];
xyz[2] = ecl[2];
xyz[3] = 0.0;
xyz[4] = 0.0;
xyz[5] = 0.0;
return true;
}
bool
eph_de_moon(double jd, double xyz[3])
{
double icrs[3];
int err;
if (!eph_de_available())
return false;
/* Moon geocentric: use center=-1 for raw geocentric Moon directly.
* The Moon is stored geocentric in the DE file, so center=-1 avoids
* the roundabout of computing Earth (EMB - Moon/(1+EMRAT)) just to
* subtract it back out (~4x fewer interpolations). */
err = de_reader_get_pos(de_handle_ptr, jd, DE_MOON, -1, icrs);
if (err != DE_OK)
return false;
/* ICRS -> ecliptic J2000 */
equatorial_to_ecliptic(icrs, xyz);
return true;
}
bool
eph_de_sun(double jd, double xyz[6])
{
/* Sun is at the origin of heliocentric coordinates */
(void)jd;
if (!eph_de_available())
return false;
xyz[0] = xyz[1] = xyz[2] = 0.0;
xyz[3] = xyz[4] = xyz[5] = 0.0;
return true;
}
double
eph_de_start_jd(void)
{
if (de_handle_ptr)
return de_handle_ptr->start_jd;
return 0.0;
}
double
eph_de_end_jd(void)
{
if (de_handle_ptr)
return de_handle_ptr->end_jd;
return 0.0;
}
int
eph_de_version(void)
{
if (de_handle_ptr)
return de_handle_ptr->de_version;
return 0;
}
double
eph_de_au_km(void)
{
if (de_handle_ptr)
return de_handle_ptr->au_km;
return 0.0;
}

110
src/eph_provider.h Normal file
View File

@ -0,0 +1,110 @@
/*
* eph_provider.h -- Ephemeris provider dispatch for pg_orrery
*
* Manages the optional JPL DE ephemeris alongside the compiled-in
* VSOP87 and ELP2000-82B theories.
*
* Key design constraints:
* - Per-backend lazy initialization (never in _PG_init/postmaster)
* - ICRS equatorial -> ecliptic J2000 frame rotation at the boundary
* - Automatic VSOP87 fallback on any DE error
* - No shared state between PostgreSQL backends
*/
#ifndef PG_ORRERY_EPH_PROVIDER_H
#define PG_ORRERY_EPH_PROVIDER_H
#include "postgres.h"
#include "types.h"
/*
* Ephemeris provider identifiers.
*/
typedef enum
{
EPH_VSOP87, /* compiled-in VSOP87 / ELP2000-82B (always available) */
EPH_JPL_DE /* JPL DE binary file (optional, STABLE) */
} EphProvider;
/*
* Initialize the GUC variable (pg_orrery.ephemeris_path).
* Called from _PG_init(). Does NOT open the DE file.
*/
void eph_register_gucs(void);
/*
* Cleanup callback for on_proc_exit.
* Closes the DE file descriptor if open.
*/
void eph_cleanup(int code, Datum arg);
/*
* Attempt to initialize the DE reader for this backend.
* Returns true if DE is available and ready, false otherwise.
* On first call, opens the file and validates. On subsequent calls,
* returns cached status.
*
* Thread-safe per backend (each backend has its own static state).
*/
bool eph_de_available(void);
/*
* Which provider is currently active?
*/
EphProvider eph_current_provider(void);
/*
* Get the configured ephemeris file path (or NULL if not set).
*/
const char *eph_get_path(void);
/*
* Get planet heliocentric ecliptic J2000 position via DE.
*
* body_id: pg_orrery body ID (1=Mercury..8=Neptune)
* jd: Julian date (TDB)
* xyz[6]: output position (AU) in ecliptic J2000 frame
* (xyz[3..5] are zero velocity not yet implemented)
*
* Returns true on success. On failure, xyz is unchanged and
* caller should fall back to VSOP87.
*/
bool eph_de_planet(int body_id, double jd, double xyz[6]);
/*
* Get Earth heliocentric ecliptic J2000 position via DE.
*
* jd: Julian date (TDB)
* xyz[6]: output position (AU) in ecliptic J2000 frame
*
* Returns true on success.
*/
bool eph_de_earth(double jd, double xyz[6]);
/*
* Get Moon geocentric ecliptic J2000 position via DE.
*
* jd: Julian date (TDB)
* xyz[3]: output position (AU) in ecliptic J2000 frame
*
* Returns true on success. On failure, caller falls back to ELP2000-82B.
*/
bool eph_de_moon(double jd, double xyz[3]);
/*
* Get Sun "heliocentric" position (always 0,0,0 but consistent API).
*/
bool eph_de_sun(double jd, double xyz[6]);
/*
* DE metadata accessors (for pg_orrery_ephemeris_info).
* Return 0/0.0 if DE is not loaded.
*/
double eph_de_start_jd(void);
double eph_de_end_jd(void);
int eph_de_version(void);
double eph_de_au_km(void);
#endif /* PG_ORRERY_EPH_PROVIDER_H */

440
src/gust86.c Normal file
View File

@ -0,0 +1,440 @@
/************************************************************************
COMPUTATION OF THE COORDINATES OF THE URANIAN SATELLITES (GUST86),
version 0.1 (1988,1995) by LASKAR J. and JACOBSON, R. can be found at
ftp://ftp.imcce.fr/pub/ephem/satel/gust86
I (Johannes Gajdosik) have just taken the Fortran code and data
obtained from above and rearranged it into this piece of software.
I can neither allow nor forbid the usage of the GUST86 theory.
The copyright notice below covers not the works of LASKAR J. and JACOBSON, R.,
but just my work, that is the compilation of the GUST86 theory
into the software supplied in this file.
Copyright (c) 2005 Johannes Gajdosik
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Derived from Stellarium's GUST86 implementation.
Modified for pg_orrery: removed all static mutable state for thread safety
(PostgreSQL PARALLEL SAFE). The original used static caching arrays and
CalcInterpolatedElements for performance; this version computes fresh on
each call which is acceptable for SQL query workloads.
1) Rotate results to "dynamical equinox and ecliptic J2000",
the reference frame of VSOP87 and VSOP87A.
2) units: julian day, AU, rad
3) use EllipticToRectangularN from elliptic_to_rectangular.h
****************************************************************/
#include "gust86.h"
#include "elliptic_to_rectangular.h"
#include <math.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
static
const double fqn[5] = {4.44519055,
2.492952519,
1.516148111,
0.721718509,
0.46669212};
static
const double fqe[5] = {20.082*M_PI/(180*365.25),
6.217*M_PI/(180*365.25),
2.865*M_PI/(180*365.25),
2.078*M_PI/(180*365.25),
0.386*M_PI/(180*365.25)};
static
const double fqi[5] = {-20.309*M_PI/(180*365.25),
-6.288*M_PI/(180*365.25),
-2.836*M_PI/(180*365.25),
-1.843*M_PI/(180*365.25),
-0.259*M_PI/(180*365.25)};
static
const double phn[5] = {-0.238051,
3.098046,
2.285402,
0.856359,
-0.915592};
static
const double phe[5] = {0.611392,
2.408974,
2.067774,
0.735131,
0.426767};
static
const double phi[5] = {5.702313,
0.395757,
0.589326,
1.746237,
4.206896};
static
void CalcGust86Elem(double t, double elem[30]) {
double an[5], ae[5], ai[5];
int i;
for (i = 0; i < 5; i++) {
an[i] = fmod(fqn[i] * t + phn[i], 2*M_PI);
ae[i] = fmod(fqe[i] * t + phe[i], 2*M_PI);
ai[i] = fmod(fqi[i] * t + phi[i], 2*M_PI);
}
elem[0*6+0] = 4.44352267
- cos(an[0] - an[1] * 3. + an[2] * 2.) * 3.492e-5
+ cos(an[0] * 2. - an[1] * 6. + an[2] * 4.) * 8.47e-6
+ cos(an[0] * 3. - an[1] * 9. + an[2] * 6.) * 1.31e-6
- cos(an[0] - an[1] ) * 5.228e-5
- cos(an[0] * 2. - an[1] * 2. ) * 1.3665e-4;
elem[0*6+1] =
sin(an[0] - an[1] * 3. + an[2] * 2.) * .02547217
- sin(an[0] * 2. - an[1] * 6. + an[2] * 4.) * .00308831
- sin(an[0] * 3. - an[1] * 9. + an[2] * 6.) * 3.181e-4
- sin(an[0] * 4. - an[1] * 12 + an[2] * 8.) * 3.749e-5
- sin(an[0] - an[1] ) * 5.785e-5
- sin(an[0] * 2. - an[1] * 2. ) * 6.232e-5
- sin(an[0] * 3. - an[1] * 3. ) * 2.795e-5
+ t * 4.44519055 - .23805158;
elem[0*6+2] = cos(ae[0]) * .00131238
+ cos(ae[1]) * 7.181e-5
+ cos(ae[2]) * 6.977e-5
+ cos(ae[3]) * 6.75e-6
+ cos(ae[4]) * 6.27e-6
+ cos(an[0]) * 1.941e-4
- cos(-an[0] + an[1] * 2.) * 1.2331e-4
+ cos(an[0] * -2. + an[1] * 3.) * 3.952e-5;
elem[0*6+3] = sin(ae[0]) * .00131238
+ sin(ae[1]) * 7.181e-5
+ sin(ae[2]) * 6.977e-5
+ sin(ae[3]) * 6.75e-6
+ sin(ae[4]) * 6.27e-6
+ sin(an[0]) * 1.941e-4
- sin(-an[0] + an[1] * 2.) * 1.2331e-4
+ sin(an[0] * -2. + an[1] * 3.) * 3.952e-5;
elem[0*6+4] = cos(ai[0]) * .03787171
+ cos(ai[1]) * 2.701e-5
+ cos(ai[2]) * 3.076e-5
+ cos(ai[3]) * 1.218e-5
+ cos(ai[4]) * 5.37e-6;
elem[0*6+5] = sin(ai[0]) * .03787171
+ sin(ai[1]) * 2.701e-5
+ sin(ai[2]) * 3.076e-5
+ sin(ai[3]) * 1.218e-5
+ sin(ai[4]) * 5.37e-6;
elem[1*6+0] = 2.49254257
+ cos(an[0] - an[1] * 3. + an[2] * 2.) * 2.55e-6
- cos( an[1] - an[2] ) * 4.216e-5
- cos( an[1] * 2. - an[2] * 2.) * 1.0256e-4;
elem[1*6+1] =
- sin(an[0] - an[1] * 3. + an[2] * 2.) * .0018605
+ sin(an[0] * 2. - an[1] * 6. + an[2] * 4.) * 2.1999e-4
+ sin(an[0] * 3. - an[1] * 9. + an[2] * 6.) * 2.31e-5
+ sin(an[0] * 4. - an[1] * 12 + an[2] * 8.) * 4.3e-6
- sin( an[1] - an[2] ) * 9.011e-5
- sin( an[1] * 2. - an[2] * 2.) * 9.107e-5
- sin( an[1] * 3. - an[2] * 3.) * 4.275e-5
- sin( an[1] * 2. - an[3] * 2.) * 1.649e-5
+ t * 2.49295252 + 3.09804641;
elem[1*6+2] = cos(ae[0]) * -3.35e-6
+ cos(ae[1]) * .00118763
+ cos(ae[2]) * 8.6159e-4
+ cos(ae[3]) * 7.15e-5
+ cos(ae[4]) * 5.559e-5
- cos(-an[1] + an[2] * 2.) * 8.46e-5
+ cos(an[1] * -2. + an[2] * 3.) * 9.181e-5
+ cos(-an[1] + an[3] * 2.) * 2.003e-5
+ cos(an[1]) * 8.977e-5;
elem[1*6+3] = sin(ae[0]) * -3.35e-6
+ sin(ae[1]) * .00118763
+ sin(ae[2]) * 8.6159e-4
+ sin(ae[3]) * 7.15e-5
+ sin(ae[4]) * 5.559e-5
- sin(-an[1] + an[2] * 2.) * 8.46e-5
+ sin(an[1] * -2. + an[2] * 3.) * 9.181e-5
+ sin(-an[1] + an[3] * 2.) * 2.003e-5
+ sin(an[1]) * 8.977e-5;
elem[1*6+4] = cos(ai[0]) * -1.2175e-4
+ cos(ai[1]) * 3.5825e-4
+ cos(ai[2]) * 2.9008e-4
+ cos(ai[3]) * 9.778e-5
+ cos(ai[4]) * 3.397e-5;
elem[1*6+5] = sin(ai[0]) * -1.2175e-4
+ sin(ai[1]) * 3.5825e-4
+ sin(ai[2]) * 2.9008e-4
+ sin(ai[3]) * 9.778e-5
+ sin(ai[4]) * 3.397e-5;
elem[2*6+0] = 1.5159549
+ cos(an[2] - an[3] * 2. + ae[2]) * 9.74e-6
- cos(an[1] - an[2]) * 1.06e-4
+ cos(an[1] * 2. - an[2] * 2.) * 5.416e-5
- cos(an[2] - an[3]) * 2.359e-5
- cos(an[2] * 2. - an[3] * 2.) * 7.07e-5
- cos(an[2] * 3. - an[3] * 3.) * 3.628e-5;
elem[2*6+1] =
sin(an[0] - an[1] * 3. + an[2] * 2.) * 6.6057e-4
- sin(an[0] * 2. - an[1] * 6. + an[2] * 4.) * 7.651e-5
- sin(an[0] * 3. - an[1] * 9. + an[2] * 6.) * 8.96e-6
- sin(an[0] * 4. - an[1] * 12. + an[2] * 8.) * 2.53e-6
- sin(an[2] - an[3] * 4. + an[4] * 3.) * 5.291e-5
- sin(an[2] - an[3] * 2. + ae[4]) * 7.34e-6
- sin(an[2] - an[3] * 2. + ae[3]) * 1.83e-6
+ sin(an[2] - an[3] * 2. + ae[2]) * 1.4791e-4
+ sin(an[2] - an[3] * 2. + ae[1]) * -7.77e-6
+ sin(an[1] - an[2]) * 9.776e-5
+ sin(an[1] * 2. - an[2] * 2.) * 7.313e-5
+ sin(an[1] * 3. - an[2] * 3.) * 3.471e-5
+ sin(an[1] * 4. - an[2] * 4.) * 1.889e-5
- sin(an[2] - an[3]) * 6.789e-5
- sin(an[2] * 2. - an[3] * 2.) * 8.286e-5
+ sin(an[2] * 3. - an[3] * 3.) * -3.381e-5
- sin(an[2] * 4. - an[3] * 4.) * 1.579e-5
- sin(an[2] - an[4]) * 1.021e-5
- sin(an[2] * 2. - an[4] * 2.) * 1.708e-5
+ t * 1.51614811 + 2.28540169;
elem[2*6+2] = cos(ae[0]) * -2.1e-7
- cos(ae[1]) * 2.2795e-4
+ cos(ae[2]) * .00390469
+ cos(ae[3]) * 3.0917e-4
+ cos(ae[4]) * 2.2192e-4
+ cos(an[1]) * 2.934e-5
+ cos(an[2]) * 2.62e-5
+ cos(-an[1] + an[2] * 2.) * 5.119e-5
- cos(an[1] * -2. + an[2] * 3.) * 1.0386e-4
- cos(an[1] * -3. + an[2] * 4.) * 2.716e-5
+ cos(an[3]) * -1.622e-5
+ cos(-an[2] + an[3] * 2.) * 5.4923e-4
+ cos(an[2] * -2. + an[3] * 3.) * 3.47e-5
+ cos(an[2] * -3. + an[3] * 4.) * 1.281e-5
+ cos(-an[2] + an[4] * 2.) * 2.181e-5
+ cos(an[2]) * 4.625e-5;
elem[2*6+3] = sin(ae[0]) * -2.1e-7
- sin(ae[1]) * 2.2795e-4
+ sin(ae[2]) * .00390469
+ sin(ae[3]) * 3.0917e-4
+ sin(ae[4]) * 2.2192e-4
+ sin(an[1]) * 2.934e-5
+ sin(an[2]) * 2.62e-5
+ sin(-an[1] + an[2] * 2.) * 5.119e-5
- sin(an[1] * -2. + an[2] * 3.) * 1.0386e-4
- sin(an[1] * -3. + an[2] * 4.) * 2.716e-5
+ sin(an[3]) * -1.622e-5
+ sin(-an[2] + an[3] * 2.) * 5.4923e-4
+ sin(an[2] * -2. + an[3] * 3.) * 3.47e-5
+ sin(an[2] * -3. + an[3] * 4.) * 1.281e-5
+ sin(-an[2] + an[4] * 2.) * 2.181e-5
+ sin(an[2]) * 4.625e-5;
elem[2*6+4] = cos(ai[0]) * -1.086e-5
- cos(ai[1]) * 8.151e-5
+ cos(ai[2]) * .00111336
+ cos(ai[3]) * 3.5014e-4
+ cos(ai[4]) * 1.065e-4;
elem[2*6+5] = sin(ai[0]) * -1.086e-5
- sin(ai[1]) * 8.151e-5
+ sin(ai[2]) * .00111336
+ sin(ai[3]) * 3.5014e-4
+ sin(ai[4]) * 1.065e-4;
elem[3*6+0] = .72166316
- cos(an[2] - an[3] * 2. + ae[2]) * 2.64e-6
- cos(an[3] * 2. - an[4] * 3. + ae[4]) * 2.16e-6
+ cos(an[3] * 2. - an[4] * 3. + ae[3]) * 6.45e-6
- cos(an[3] * 2. - an[4] * 3. + ae[2]) * 1.11e-6
+ cos(an[1] - an[3]) * -6.223e-5
- cos(an[2] - an[3]) * 5.613e-5
- cos(an[3] - an[4]) * 3.994e-5
- cos(an[3] * 2. - an[4] * 2.) * 9.185e-5
- cos(an[3] * 3. - an[4] * 3.) * 5.831e-5
- cos(an[3] * 4. - an[4] * 4.) * 3.86e-5
- cos(an[3] * 5. - an[4] * 5.) * 2.618e-5
- cos(an[3] * 6. - an[4] * 6.) * 1.806e-5;
elem[3*6+1] =
sin(an[2] - an[3] * 4. + an[4] * 3.) * 2.061e-5
- sin(an[2] - an[3] * 2. + ae[4]) * 2.07e-6
- sin(an[2] - an[3] * 2. + ae[3]) * 2.88e-6
- sin(an[2] - an[3] * 2. + ae[2]) * 4.079e-5
+ sin(an[2] - an[3] * 2. + ae[1]) * 2.11e-6
- sin(an[3] * 2. - an[4] * 3. + ae[4]) * 5.183e-5
+ sin(an[3] * 2. - an[4] * 3. + ae[3]) * 1.5987e-4
+ sin(an[3] * 2. - an[4] * 3. + ae[2]) * -3.505e-5
- sin(an[3] * 3. - an[4] * 4. + ae[4]) * 1.56e-6
+ sin(an[1] - an[3]) * 4.054e-5
+ sin(an[2] - an[3]) * 4.617e-5
- sin(an[3] - an[4]) * 3.1776e-4
- sin(an[3] * 2. - an[4] * 2.) * 3.0559e-4
- sin(an[3] * 3. - an[4] * 3.) * 1.4836e-4
- sin(an[3] * 4. - an[4] * 4.) * 8.292e-5
+ sin(an[3] * 5. - an[4] * 5.) * -4.998e-5
- sin(an[3] * 6. - an[4] * 6.) * 3.156e-5
- sin(an[3] * 7. - an[4] * 7.) * 2.056e-5
- sin(an[3] * 8. - an[4] * 8.) * 1.369e-5
+ t * .72171851 + .85635879;
elem[3*6+2] = cos(ae[0]) * -2e-8
- cos(ae[1]) * 1.29e-6
- cos(ae[2]) * 3.2451e-4
+ cos(ae[3]) * 9.3281e-4
+ cos(ae[4]) * .00112089
+ cos(an[1]) * 3.386e-5
+ cos(an[3]) * 1.746e-5
+ cos(-an[1] + an[3] * 2.) * 1.658e-5
+ cos(an[2]) * 2.889e-5
- cos(-an[2] + an[3] * 2.) * 3.586e-5
+ cos(an[3]) * -1.786e-5
- cos(an[4]) * 3.21e-5
- cos(-an[3] + an[4] * 2.) * 1.7783e-4
+ cos(an[3] * -2. + an[4] * 3.) * 7.9343e-4
+ cos(an[3] * -3. + an[4] * 4.) * 9.948e-5
+ cos(an[3] * -4. + an[4] * 5.) * 4.483e-5
+ cos(an[3] * -5. + an[4] * 6.) * 2.513e-5
+ cos(an[3] * -6. + an[4] * 7.) * 1.543e-5;
elem[3*6+3] = sin(ae[0]) * -2e-8
- sin(ae[1]) * 1.29e-6
- sin(ae[2]) * 3.2451e-4
+ sin(ae[3]) * 9.3281e-4
+ sin(ae[4]) * .00112089
+ sin(an[1]) * 3.386e-5
+ sin(an[3]) * 1.746e-5
+ sin(-an[1] + an[3] * 2.) * 1.658e-5
+ sin(an[2]) * 2.889e-5
- sin(-an[2] + an[3] * 2.) * 3.586e-5
+ sin(an[3]) * -1.786e-5
- sin(an[4]) * 3.21e-5
- sin(-an[3] + an[4] * 2.) * 1.7783e-4
+ sin(an[3] * -2. + an[4] * 3.) * 7.9343e-4
+ sin(an[3] * -3. + an[4] * 4.) * 9.948e-5
+ sin(an[3] * -4. + an[4] * 5.) * 4.483e-5
+ sin(an[3] * -5. + an[4] * 6.) * 2.513e-5
+ sin(an[3] * -6. + an[4] * 7.) * 1.543e-5;
elem[3*6+4] = cos(ai[0]) * -1.43e-6
- cos(ai[1]) * 1.06e-6
- cos(ai[2]) * 1.4013e-4
+ cos(ai[3]) * 6.8572e-4
+ cos(ai[4]) * 3.7832e-4;
elem[3*6+5] = sin(ai[0]) * -1.43e-6
- sin(ai[1]) * 1.06e-6
- sin(ai[2]) * 1.4013e-4
+ sin(ai[3]) * 6.8572e-4
+ sin(ai[4]) * 3.7832e-4;
elem[4*6+0] = .46658054
+ cos(an[3] * 2. - an[4] * 3. + ae[4]) * 2.08e-6
- cos(an[3] * 2. - an[4] * 3. + ae[3]) * 6.22e-6
+ cos(an[3] * 2. - an[4] * 3. + ae[2]) * 1.07e-6
- cos(an[1] - an[4]) * 4.31e-5
+ cos(an[2] - an[4]) * -3.894e-5
- cos(an[3] - an[4]) * 8.011e-5
+ cos(an[3] * 2. - an[4] * 2.) * 5.906e-5
+ cos(an[3] * 3. - an[4] * 3.) * 3.749e-5
+ cos(an[3] * 4. - an[4] * 4.) * 2.482e-5
+ cos(an[3] * 5. - an[4] * 5.) * 1.684e-5;
elem[4*6+1] =
- sin(an[2] - an[3] * 4. + an[4] * 3.) * 7.82e-6
+ sin(an[3] * 2. - an[4] * 3. + ae[4]) * 5.129e-5
- sin(an[3] * 2. - an[4] * 3. + ae[3]) * 1.5824e-4
+ sin(an[3] * 2. - an[4] * 3. + ae[2]) * 3.451e-5
+ sin(an[1] - an[4]) * 4.751e-5
+ sin(an[2] - an[4]) * 3.896e-5
+ sin(an[3] - an[4]) * 3.5973e-4
+ sin(an[3] * 2. - an[4] * 2.) * 2.8278e-4
+ sin(an[3] * 3. - an[4] * 3.) * 1.386e-4
+ sin(an[3] * 4. - an[4] * 4.) * 7.803e-5
+ sin(an[3] * 5. - an[4] * 5.) * 4.729e-5
+ sin(an[3] * 6. - an[4] * 6.) * 3e-5
+ sin(an[3] * 7. - an[4] * 7.) * 1.962e-5
+ sin(an[3] * 8. - an[4] * 8.) * 1.311e-5
+ t * .46669212 - .9155918;
elem[4*6+2] = cos(ae[1]) * -3.5e-7
+ cos(ae[2]) * 7.453e-5
- cos(ae[3]) * 7.5868e-4
+ cos(ae[4]) * .00139734
+ cos(an[1]) * 3.9e-5
+ cos(-an[1] + an[4] * 2.) * 1.766e-5
+ cos(an[2]) * 3.242e-5
+ cos(an[3]) * 7.975e-5
+ cos(an[4]) * 7.566e-5
+ cos(-an[3] + an[4] * 2.) * 1.3404e-4
- cos(an[3] * -2. + an[4] * 3.) * 9.8726e-4
- cos(an[3] * -3. + an[4] * 4.) * 1.2609e-4
- cos(an[3] * -4. + an[4] * 5.) * 5.742e-5
- cos(an[3] * -5. + an[4] * 6.) * 3.241e-5
- cos(an[3] * -6. + an[4] * 7.) * 1.999e-5
- cos(an[3] * -7. + an[4] * 8.) * 1.294e-5;
elem[4*6+3] = sin(ae[1]) * -3.5e-7
+ sin(ae[2]) * 7.453e-5
- sin(ae[3]) * 7.5868e-4
+ sin(ae[4]) * .00139734
+ sin(an[1]) * 3.9e-5
+ sin(-an[1] + an[4] * 2.) * 1.766e-5
+ sin(an[2]) * 3.242e-5
+ sin(an[3]) * 7.975e-5
+ sin(an[4]) * 7.566e-5
+ sin(-an[3] + an[4] * 2.) * 1.3404e-4
- sin(an[3] * -2. + an[4] * 3.) * 9.8726e-4
- sin(an[3] * -3. + an[4] * 4.) * 1.2609e-4
- sin(an[3] * -4. + an[4] * 5.) * 5.742e-5
- sin(an[3] * -5. + an[4] * 6.) * 3.241e-5
- sin(an[3] * -6. + an[4] * 7.) * 1.999e-5
- sin(an[3] * -7. + an[4] * 8.) * 1.294e-5;
elem[4*6+4] = cos(ai[0]) * -4.4e-7
- cos(ai[1]) * 3.1e-7
+ cos(ai[2]) * 3.689e-5
- cos(ai[3]) * 5.9633e-4
+ cos(ai[4]) * 4.5169e-4;
elem[4*6+5] = sin(ai[0]) * -4.4e-7
- sin(ai[1]) * 3.1e-7
+ sin(ai[2]) * 3.689e-5
- sin(ai[3]) * 5.9633e-4
+ sin(ai[4]) * 4.5169e-4;
}
static
const double gust86_rmu[5] = {
1.291892353675174e-08,
1.291910570526396e-08,
1.291910102284198e-08,
1.291942656265575e-08,
1.291935967091320e-08};
static const double GUST86toVsop87[9] = {
9.753206632086812015e-01, 6.194425668001473004e-02, 2.119257251551559653e-01,
-2.006444610981783542e-01,-1.519328516640849367e-01, 9.678110398294910731e-01,
9.214881523275189928e-02,-9.864478281437795399e-01,-1.357544776485127136e-01
};
void GetGust86Coor(double jd, int body, double *xyz, double *xyzdot) {
double elem[5*6];
double x[6];
const double t = jd - 2444239.5;
CalcGust86Elem(t, elem);
EllipticToRectangularN(gust86_rmu[body], elem + (body * 6), 0.0, x);
xyz[0] = GUST86toVsop87[0]*x[0] + GUST86toVsop87[1]*x[1] + GUST86toVsop87[2]*x[2];
xyz[1] = GUST86toVsop87[3]*x[0] + GUST86toVsop87[4]*x[1] + GUST86toVsop87[5]*x[2];
xyz[2] = GUST86toVsop87[6]*x[0] + GUST86toVsop87[7]*x[1] + GUST86toVsop87[8]*x[2];
if (xyzdot) {
xyzdot[0] = GUST86toVsop87[0]*x[3] + GUST86toVsop87[1]*x[4] + GUST86toVsop87[2]*x[5];
xyzdot[1] = GUST86toVsop87[3]*x[3] + GUST86toVsop87[4]*x[4] + GUST86toVsop87[5]*x[5];
xyzdot[2] = GUST86toVsop87[6]*x[3] + GUST86toVsop87[7]*x[4] + GUST86toVsop87[8]*x[5];
}
}

78
src/gust86.h Normal file
View File

@ -0,0 +1,78 @@
/************************************************************************
COMPUTATION OF THE COORDINATES OF THE URANIAN SATELLITES (GUST86),
version 0.1 (1988,1995) by LASKAR J. and JACOBSON, R. can be found at
ftp://ftp.imcce.fr/pub/ephem/satel/gust86
I (Johannes Gajdosik) have just taken the Fortran code and data
obtained from above and rearranged it into this piece of software.
I can neither allow nor forbid the usage of the GUST86 theory.
The copyright notice below covers not the works of LASKAR J. and JACOBSON, R.,
but just my work, that is the compilation of the GUST86 theory
into the software supplied in this file.
Copyright (c) 2005 Johannes Gajdosik
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Derived from Stellarium's GUST86 implementation.
Modified for pg_orrery: removed static mutable state and
CalcInterpolatedElements caching for thread safety
(PostgreSQL PARALLEL SAFE). Elements are computed fresh on each call.
1) Rotate results to "dynamical equinox and ecliptic J2000",
the reference frame of VSOP87 and VSOP87A.
2) units: julian day, AU, rad
****************************************************************/
#ifndef PG_ORRERY_GUST86_H
#define PG_ORRERY_GUST86_H
#ifdef __cplusplus
extern "C" {
#endif
#define GUST86_MIRANDA 0
#define GUST86_ARIEL 1
#define GUST86_UMBRIEL 2
#define GUST86_TITANIA 3
#define GUST86_OBERON 4
void GetGust86Coor(double jd, int body, double *xyz, double *xyzdot);
/* Return the rectangular coordinates of the given satellite
and the given julian date jd expressed in dynamical time (TAI+32.184s).
The origin of the xyz-coordinates is the center of Uranus.
The reference frame is "dynamical equinox and ecliptic J2000",
which is the reference frame in VSOP87 and VSOP87A.
body: 0=Miranda, 1=Ariel, 2=Umbriel, 3=Titania, 4=Oberon
xyz[3]: position (AU), relative to Uranus center
xyzdot[3]: velocity (AU/day), may be NULL
*/
#ifdef __cplusplus
}
#endif
#endif

427
src/kepler_funcs.c Normal file
View File

@ -0,0 +1,427 @@
/*
* kepler_funcs.c -- Keplerian propagation and heliocentric type
*
* Two-body propagation from classical orbital elements for comets
* and asteroids. Handles elliptic (e<1), near-parabolic (e~1),
* and hyperbolic (e>1) orbits.
*
* Also provides the heliocentric type (ecliptic J2000 position in AU)
* and comet_observe() which computes topocentric coordinates given
* the comet's orbital elements and Earth's heliocentric position.
*/
#include "postgres.h"
#include "fmgr.h"
#include "funcapi.h"
#include "utils/timestamp.h"
#include "libpq/pqformat.h"
#include "types.h"
#include "astro_math.h"
#include <math.h>
/* Heliocentric type I/O */
PG_FUNCTION_INFO_V1(heliocentric_in);
PG_FUNCTION_INFO_V1(heliocentric_out);
PG_FUNCTION_INFO_V1(heliocentric_recv);
PG_FUNCTION_INFO_V1(heliocentric_send);
PG_FUNCTION_INFO_V1(helio_x);
PG_FUNCTION_INFO_V1(helio_y);
PG_FUNCTION_INFO_V1(helio_z);
PG_FUNCTION_INFO_V1(helio_distance);
/* Propagation functions */
PG_FUNCTION_INFO_V1(kepler_propagate);
PG_FUNCTION_INFO_V1(comet_observe);
/* ================================================================
* Kepler equation solvers
* ================================================================
*/
/*
* Elliptic Kepler equation: M = E - e*sin(E).
* Newton-Raphson, converges in 3-5 iterations for e < 0.99.
*/
static double
solve_kepler_elliptic(double M, double e)
{
double E = M;
int i;
/* Better initial guess for high eccentricity */
if (e > 0.8)
E = M_PI;
for (i = 0; i < 30; i++)
{
double dE = (E - e * sin(E) - M) / (1.0 - e * cos(E));
E -= dE;
if (fabs(dE) < 1.0e-15)
break;
}
return E;
}
/*
* Hyperbolic Kepler equation: M = e*sinh(H) - H.
* Newton-Raphson.
*/
static double
solve_kepler_hyperbolic(double M, double e)
{
/* Initial guess: asinh(M/e) for large M, M for small */
double H = (fabs(M) > 1.0) ? copysign(log(fabs(M) / e + 1.0), M) : M;
int i;
for (i = 0; i < 30; i++)
{
double dH = (e * sinh(H) - H - M) / (e * cosh(H) - 1.0);
H -= dH;
if (fabs(dH) < 1.0e-15)
break;
}
return H;
}
/*
* Near-parabolic: Barker's equation W^3 + 3W - 3M = 0.
* Returns true anomaly directly.
*/
static double
solve_kepler_parabolic(double M)
{
double W, f, fp;
int i;
/* Cardano's formula for W^3 + 3W - 3M = 0 gives decent initial guess */
double disc = sqrt(1.0 + M * M);
W = cbrt(3.0 * M + 3.0 * disc) - cbrt(-3.0 * M + 3.0 * disc);
for (i = 0; i < 30; i++)
{
f = W * W * W + 3.0 * W - 3.0 * M;
fp = 3.0 * W * W + 3.0;
W -= f / fp;
if (fabs(f / fp) < 1.0e-15)
break;
}
return 2.0 * atan(W);
}
/* ================================================================
* Internal: Keplerian position from orbital elements
*
* Input: q (AU), e, inc (rad), omega (rad), Omega (rad),
* T_peri (JD), jd (observation JD)
* Output: pos[3] in AU, ecliptic J2000 frame
* ================================================================
*/
static void
kepler_position(double q, double e, double inc, double omega, double Omega,
double T_peri, double jd, double pos[3])
{
double dt = jd - T_peri;
double v = 0.0, r = 0.0;
double x_orb, y_orb;
double cos_om, sin_om, cos_Om, sin_Om, cos_i, sin_i;
double Px, Py, Pz, Qx, Qy, Qz;
if (e < 0.99)
{
double a = q / (1.0 - e);
double n = GAUSS_K / (a * sqrt(a));
double M = fmod(n * dt, 2.0 * M_PI);
double E;
if (M < 0.0)
M += 2.0 * M_PI;
E = solve_kepler_elliptic(M, e);
v = 2.0 * atan2(sqrt(1.0 + e) * sin(E / 2.0),
sqrt(1.0 - e) * cos(E / 2.0));
r = a * (1.0 - e * cos(E));
}
else if (e > 1.01)
{
double a = q / (e - 1.0);
double n = GAUSS_K / (a * sqrt(a));
double M = n * dt;
double H = solve_kepler_hyperbolic(M, e);
v = 2.0 * atan2(sqrt(e + 1.0) * tanh(H / 2.0),
sqrt(e - 1.0));
r = a * (e * cosh(H) - 1.0);
}
else
{
double n = GAUSS_K * sqrt(1.0 / (2.0 * q * q * q));
double M = n * dt;
v = solve_kepler_parabolic(M);
r = q * (1.0 + tan(v / 2.0) * tan(v / 2.0));
}
x_orb = r * cos(v);
y_orb = r * sin(v);
/* Perifocal-to-ecliptic rotation (P, Q vectors) */
cos_om = cos(omega);
sin_om = sin(omega);
cos_Om = cos(Omega);
sin_Om = sin(Omega);
cos_i = cos(inc);
sin_i = sin(inc);
Px = cos_Om * cos_om - sin_Om * sin_om * cos_i;
Py = sin_Om * cos_om + cos_Om * sin_om * cos_i;
Pz = sin_om * sin_i;
Qx = -cos_Om * sin_om - sin_Om * cos_om * cos_i;
Qy = -sin_Om * sin_om + cos_Om * cos_om * cos_i;
Qz = cos_om * sin_i;
pos[0] = Px * x_orb + Qx * y_orb;
pos[1] = Py * x_orb + Qy * y_orb;
pos[2] = Pz * x_orb + Qz * y_orb;
}
/* ================================================================
* Heliocentric type I/O
*
* Text format: (x_au, y_au, z_au)
* ================================================================
*/
Datum
heliocentric_in(PG_FUNCTION_ARGS)
{
char *str = PG_GETARG_CSTRING(0);
pg_heliocentric *result;
double x, y, z;
int nfields;
result = (pg_heliocentric *) palloc(sizeof(pg_heliocentric));
nfields = sscanf(str, " ( %lf , %lf , %lf )", &x, &y, &z);
if (nfields != 3)
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid input syntax for type heliocentric: \"%s\"", str),
errhint("Expected (x_au,y_au,z_au).")));
result->x = x;
result->y = y;
result->z = z;
PG_RETURN_POINTER(result);
}
Datum
heliocentric_out(PG_FUNCTION_ARGS)
{
pg_heliocentric *h = (pg_heliocentric *) PG_GETARG_POINTER(0);
PG_RETURN_CSTRING(psprintf("(%.10f,%.10f,%.10f)", h->x, h->y, h->z));
}
Datum
heliocentric_recv(PG_FUNCTION_ARGS)
{
StringInfo buf = (StringInfo) PG_GETARG_POINTER(0);
pg_heliocentric *result;
result = (pg_heliocentric *) palloc(sizeof(pg_heliocentric));
result->x = pq_getmsgfloat8(buf);
result->y = pq_getmsgfloat8(buf);
result->z = pq_getmsgfloat8(buf);
PG_RETURN_POINTER(result);
}
Datum
heliocentric_send(PG_FUNCTION_ARGS)
{
pg_heliocentric *h = (pg_heliocentric *) PG_GETARG_POINTER(0);
StringInfoData buf;
pq_begintypsend(&buf);
pq_sendfloat8(&buf, h->x);
pq_sendfloat8(&buf, h->y);
pq_sendfloat8(&buf, h->z);
PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
}
/* --- heliocentric accessors --- */
Datum
helio_x(PG_FUNCTION_ARGS)
{
pg_heliocentric *h = (pg_heliocentric *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(h->x);
}
Datum
helio_y(PG_FUNCTION_ARGS)
{
pg_heliocentric *h = (pg_heliocentric *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(h->y);
}
Datum
helio_z(PG_FUNCTION_ARGS)
{
pg_heliocentric *h = (pg_heliocentric *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(h->z);
}
Datum
helio_distance(PG_FUNCTION_ARGS)
{
pg_heliocentric *h = (pg_heliocentric *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(sqrt(h->x * h->x + h->y * h->y + h->z * h->z));
}
/* ================================================================
* kepler_propagate(q, e, i_deg, omega_deg, Omega_deg, T_peri_jd,
* timestamptz) -> heliocentric
*
* Propagate a body from classical orbital elements via two-body
* Keplerian dynamics. Returns heliocentric ecliptic J2000 position.
* ================================================================
*/
Datum
kepler_propagate(PG_FUNCTION_ARGS)
{
double q_au = PG_GETARG_FLOAT8(0);
double ecc = PG_GETARG_FLOAT8(1);
double inc_deg = PG_GETARG_FLOAT8(2);
double omega_deg = PG_GETARG_FLOAT8(3);
double Omega_deg = PG_GETARG_FLOAT8(4);
double T_peri_jd = PG_GETARG_FLOAT8(5);
int64 ts = PG_GETARG_INT64(6);
double jd;
double pos[3];
pg_heliocentric *result;
if (q_au <= 0.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("perihelion distance must be positive: %.6f", q_au)));
if (ecc < 0.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("eccentricity must be non-negative: %.6f", ecc)));
jd = timestamptz_to_jd(ts);
kepler_position(q_au, ecc,
inc_deg * DEG_TO_RAD,
omega_deg * DEG_TO_RAD,
Omega_deg * DEG_TO_RAD,
T_peri_jd, 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);
}
/* ================================================================
* comet_observe(q, e, i, omega, Omega, T_peri,
* earth_x_au, earth_y_au, earth_z_au,
* observer, timestamptz) -> topocentric
*
* Full pipeline: Keplerian propagation -> geocentric position ->
* equatorial J2000 -> precession to date -> topocentric az/el.
*
* Earth's heliocentric ecliptic J2000 position must be provided.
* In Phase 1, Skyfield or similar supplies this.
* In Phase 2, planet_heliocentric(BODY_EARTH, ts) provides it.
* ================================================================
*/
Datum
comet_observe(PG_FUNCTION_ARGS)
{
double q_au = PG_GETARG_FLOAT8(0);
double ecc = PG_GETARG_FLOAT8(1);
double inc_deg = PG_GETARG_FLOAT8(2);
double omega_deg = PG_GETARG_FLOAT8(3);
double Omega_deg = PG_GETARG_FLOAT8(4);
double T_peri_jd = PG_GETARG_FLOAT8(5);
double earth_x = PG_GETARG_FLOAT8(6);
double earth_y = PG_GETARG_FLOAT8(7);
double earth_z = PG_GETARG_FLOAT8(8);
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(9);
int64 ts = PG_GETARG_INT64(10);
double jd;
double comet_helio[3];
double geo_ecl[3];
double geo_equ[3];
double ra_j2000, dec_j2000, geo_dist;
double ra_date, dec_date;
double gmst_val, lst, ha;
double az, el;
pg_topocentric *result;
if (q_au <= 0.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("perihelion distance must be positive: %.6f", q_au)));
if (ecc < 0.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("eccentricity must be non-negative: %.6f", ecc)));
jd = timestamptz_to_jd(ts);
/* Comet heliocentric ecliptic J2000 position */
kepler_position(q_au, ecc,
inc_deg * DEG_TO_RAD,
omega_deg * DEG_TO_RAD,
Omega_deg * DEG_TO_RAD,
T_peri_jd, jd, comet_helio);
/* Geocentric ecliptic position = comet_helio - earth_helio */
geo_ecl[0] = comet_helio[0] - earth_x;
geo_ecl[1] = comet_helio[1] - earth_y;
geo_ecl[2] = comet_helio[2] - earth_z;
/* Ecliptic J2000 -> equatorial J2000 */
ecliptic_to_equatorial(geo_ecl, geo_equ);
/* Cartesian -> spherical (RA, Dec, distance) */
cartesian_to_spherical(geo_equ, &ra_j2000, &dec_j2000, &geo_dist);
/* Precess J2000 RA/Dec to date */
precess_j2000_to_date(jd, ra_j2000, dec_j2000, &ra_date, &dec_date);
/* Hour angle and az/el */
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 = (pg_topocentric *) palloc(sizeof(pg_topocentric));
result->azimuth = az;
result->elevation = el;
result->range_km = geo_dist * AU_KM;
result->range_rate = 0.0; /* no velocity computation in Phase 1 */
PG_RETURN_POINTER(result);
}

918
src/l12.c Normal file
View File

@ -0,0 +1,918 @@
/************************************************************************
L1.2 Galilean satellite theory -- Lainey, Duriez & Vienne
Clean-room implementation for pg_orrery.
Positions and velocities of Io, Europa, Ganymede, and Callisto
relative to Jupiter's center, in VSOP87 ecliptic J2000 coordinates.
Reference:
Lainey V., Duriez L., Vienne A.
"New accurate ephemerides for the Galilean satellites of Jupiter"
Astronomy & Astrophysics, 2004
ftp://ftp.imcce.fr/pub/ephem/satel/galilean/L1/L1.2/
The theory expresses each moon's orbit using modified Delaunay
variables {a, L, K, H, Q, P}, where:
a = semi-major axis (AU)
L = mean longitude (radians)
K = e * cos(varpi) (eccentricity vector, real part)
H = e * sin(varpi) (eccentricity vector, imaginary part)
Q = sin(i/2) * cos(Omega) (inclination vector, real part)
P = sin(i/2) * sin(Omega) (inclination vector, imaginary part)
Each variable is computed as a Fourier series in time since the
theory epoch (JD 2433282.5 = 1950 Jan 1.0 TT).
Copyright (c) 2026 Ryan Malloy <ryan@supported.systems>
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Thread-safe: all functions are reentrant with no static mutable state.
****************************************************************/
#include "l12.h"
#include <math.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846264338327950288
#endif
#define TWO_PI (2.0 * M_PI)
/* L1.2 theory epoch: JD 2433282.5 (1950 Jan 1.0 TT) */
#define L12_EPOCH_JD 2433282.5
/* Kepler equation convergence threshold */
#define KEPLER_TOL 1.0e-12
/* Maximum series lengths across all four moons */
#define MAX_TERMS_A 38
#define MAX_TERMS_L 36
#define MAX_TERMS_Z 50
#define MAX_TERMS_ZETA 25
/*
* A single Fourier term: amplitude * trig(phase + frequency * t)
*
* For semi-major axis 'a': amplitude * cos(phase + freq * t)
* For mean longitude 'L': amplitude * sin(phase + freq * t)
* For complex eccentricity 'z': amplitude * cos/sin(phase + freq * t)
* For complex inclination 'zeta': amplitude * cos/sin(phase + freq * t)
*/
typedef struct {
double amp;
double phi;
double nu;
} l12_fourier_term;
/*
* All theory data for one Galilean moon.
*/
typedef struct {
double grav_param; /* mu: GM_Jupiter in AU^3/day^2 */
double lon0; /* mean longitude at epoch (rad) */
double lon_rate; /* mean longitude rate (rad/day) */
int n_a; /* number of semi-major axis terms */
int n_l; /* number of mean longitude terms */
int n_z; /* number of eccentricity terms */
int n_zeta; /* number of inclination terms */
l12_fourier_term fa[MAX_TERMS_A];
l12_fourier_term fl[MAX_TERMS_L];
l12_fourier_term fz[MAX_TERMS_Z];
l12_fourier_term fzeta[MAX_TERMS_ZETA];
} l12_moon_data;
/*
* Rotation matrix: L1.2 reference frame -> VSOP87 ecliptic J2000
*
* This is the product of the equatorial-to-ecliptic rotation
* evaluated at J2000, representing the physical orientation of
* Jupiter's Laplacian plane relative to the ecliptic. These nine
* values are an astronomical coordinate transform (physical constant).
*
* Stored row-major: row i, col j = rot_l12_to_vsop87[3*i + j]
*/
static const double rot_l12_to_vsop87[9] = {
9.994327815023905713e-01,
3.039550993390781261e-02,
-1.449924943755843383e-02,
-3.089770442223671880e-02,
9.988822846893227815e-01,
-3.577028369016394015e-02,
1.339578739122566807e-02,
3.619798764705610479e-02,
9.992548516622136737e-01
};
/*
* Convert modified Delaunay elements to position and velocity.
*
* Elements: {a, L, K, H, Q, P} where
* a = semi-major axis
* L = mean longitude
* K = e*cos(varpi), H = e*sin(varpi)
* Q = sin(i/2)*cos(Omega), P = sin(i/2)*sin(Omega)
*
* The mean anomaly in modified Delaunay variables is simply L
* (since the perturbation series already accounts for the
* longitude of periapse through K and H).
*
* Kepler's equation in K,H form:
* E = L + K*sin(E) - H*cos(E)
* where E is the eccentric longitude (not anomaly).
*/
static void
delaunay_to_cartesian(double grav_param, const double elems[6],
double pos[3], double vel[3])
{
double sma, mean_lon, kk, hh, qq, pp;
double mean_mot, ecc_lon, cos_e, sin_e;
double delta, dle, rsm1, inv_r_over_a;
double phi_ecc, psi_fac;
double xp, yp, vxp, vyp;
double f2, one_m_2pp, one_m_2qq, two_pq;
int iter;
sma = elems[0];
mean_lon = elems[1];
kk = elems[2];
hh = elems[3];
qq = elems[4];
pp = elems[5];
/* mean motion from Kepler's third law */
mean_mot = sqrt(grav_param / (sma * sma * sma));
/* solve generalized Kepler equation:
* E = L + K*sin(E) - H*cos(E)
* using Newton-Raphson iteration */
ecc_lon = mean_lon + kk * sin(mean_lon) - hh * cos(mean_lon);
for (iter = 0; iter < 20; iter++) {
cos_e = cos(ecc_lon);
sin_e = sin(ecc_lon);
delta = (mean_lon - ecc_lon + kk * sin_e - hh * cos_e)
/ (1.0 - kk * cos_e - hh * sin_e);
ecc_lon += delta;
if (fabs(delta) <= KEPLER_TOL)
break;
}
cos_e = cos(ecc_lon);
sin_e = sin(ecc_lon);
/* auxiliary quantities */
dle = hh * cos_e - kk * sin_e;
rsm1 = -kk * cos_e - hh * sin_e; /* (r/a - 1) */
inv_r_over_a = 1.0 / (1.0 + rsm1); /* a/r */
/* eccentricity-related factor */
phi_ecc = sqrt(1.0 - kk * kk - hh * hh);
psi_fac = 1.0 / (1.0 + phi_ecc);
/* position in orbital plane */
xp = sma * (cos_e - kk - psi_fac * hh * dle);
yp = sma * (sin_e - hh + psi_fac * kk * dle);
/* velocity in orbital plane */
vxp = mean_mot * inv_r_over_a * sma * (-sin_e - psi_fac * hh * rsm1);
vyp = mean_mot * inv_r_over_a * sma * ( cos_e + psi_fac * kk * rsm1);
/* rotate from orbital plane to 3D using Q, P (inclination vector) */
f2 = 2.0 * sqrt(1.0 - qq * qq - pp * pp);
one_m_2pp = 1.0 - 2.0 * pp * pp;
one_m_2qq = 1.0 - 2.0 * qq * qq;
two_pq = 2.0 * pp * qq;
pos[0] = xp * one_m_2pp + yp * two_pq;
pos[1] = xp * two_pq + yp * one_m_2qq;
pos[2] = (qq * yp - xp * pp) * f2;
vel[0] = vxp * one_m_2pp + vyp * two_pq;
vel[1] = vxp * two_pq + vyp * one_m_2qq;
vel[2] = (qq * vyp - vxp * pp) * f2;
}
/*
* Apply 3x3 rotation matrix to a 3-vector.
*/
static void
rotate_vec(const double mat[9], const double in[3], double out[3])
{
out[0] = mat[0] * in[0] + mat[1] * in[1] + mat[2] * in[2];
out[1] = mat[3] * in[0] + mat[4] * in[1] + mat[5] * in[2];
out[2] = mat[6] * in[0] + mat[7] * in[1] + mat[8] * in[2];
}
/* forward declaration -- data defined below */
static const l12_moon_data galilean_moons[4];
/*
* Evaluate the L1.2 Fourier series and compute Cartesian
* position/velocity for one Galilean moon.
*/
void
GetL12Coor(double jd, int body, double *xyz, double *xyzdot)
{
const l12_moon_data *md;
double dt, angle, accum, re, im;
double elems[6];
double body_pos[3], body_vel[3];
int j;
/* select moon data (bounds check) */
if (body < 0 || body > 3)
return;
md = &galilean_moons[body];
/* time since L1.2 epoch in days */
dt = jd - L12_EPOCH_JD;
/*
* 1. Semi-major axis: sum of cosine terms
* a = sum_j amp_j * cos(phi_j + nu_j * dt)
*/
accum = 0.0;
for (j = 0; j < md->n_a; j++) {
angle = md->fa[j].phi + md->fa[j].nu * dt;
accum += md->fa[j].amp * cos(angle);
}
elems[0] = accum;
/*
* 2. Mean longitude: linear trend + sine series
* L = lon0 + lon_rate * dt + sum_j amp_j * sin(phi_j + nu_j * dt)
*/
accum = md->lon0 + md->lon_rate * dt;
for (j = 0; j < md->n_l; j++) {
angle = md->fl[j].phi + md->fl[j].nu * dt;
accum += md->fl[j].amp * sin(angle);
}
accum = fmod(accum, TWO_PI);
if (accum < 0.0)
accum += TWO_PI;
elems[1] = accum;
/*
* 3. Complex eccentricity z = K + iH = sum_j amp_j * exp(i*(phi_j + nu_j*dt))
* K = sum amp * cos(angle), H = sum amp * sin(angle)
*/
re = 0.0;
im = 0.0;
for (j = 0; j < md->n_z; j++) {
angle = md->fz[j].phi + md->fz[j].nu * dt;
re += md->fz[j].amp * cos(angle);
im += md->fz[j].amp * sin(angle);
}
elems[2] = re;
elems[3] = im;
/*
* 4. Complex inclination zeta = Q + iP = sum_j amp_j * exp(i*(phi_j + nu_j*dt))
* Q = sum amp * cos(angle), P = sum amp * sin(angle)
*/
re = 0.0;
im = 0.0;
for (j = 0; j < md->n_zeta; j++) {
angle = md->fzeta[j].phi + md->fzeta[j].nu * dt;
re += md->fzeta[j].amp * cos(angle);
im += md->fzeta[j].amp * sin(angle);
}
elems[4] = re;
elems[5] = im;
/* convert elements to position/velocity in L1.2 frame */
delaunay_to_cartesian(md->grav_param, elems, body_pos, body_vel);
/* rotate from L1.2 frame to VSOP87 ecliptic J2000 */
rotate_vec(rot_l12_to_vsop87, body_pos, xyz);
if (xyzdot)
rotate_vec(rot_l12_to_vsop87, body_vel, xyzdot);
}
/* ================================================================
* Theory coefficients for the four Galilean moons.
*
* These are astronomical constants derived from fitting
* observations of the Galilean satellite system. They represent
* physical measurements of orbital parameters and are scientific
* data, not copyrightable expression.
*
* Source: L1.2 theory data files
* ftp://ftp.imcce.fr/pub/ephem/satel/galilean/L1/L1.2/
* ================================================================
*/
static const l12_moon_data galilean_moons[4] = {
/* ---- Io (J-I) ---- */
{
/* grav_param */ 0.2824894284338140e-06,
/* lon0 */ 0.1446213296021224e+01,
/* lon_rate */ 0.3551552286182400e+01,
/* n_a */ 38, /* n_l */ 32, /* n_z */ 23, /* n_zeta */ 15,
/* semi-major axis cosine series (38 terms) */
{
{ 0.0028210960212903, 0.00000000000000e+00, 0.00000000000000e+00},
{ 0.0000000762024588, 0.36392902322306e+01, 0.35644591656241e+01},
{ 0.0000000180900324, 0.99554707056522e+00, 0.71289183312483e+01},
{ 0.0000000172337652, 0.18196487820921e+01, 0.17822295777568e+01},
{ 0.0000000101726080, 0.28150559763861e+01, 0.89111478635073e+01},
{ 0.0000000094794086, 0.34760224933239e+01, 0.80200331112799e+01},
{ 0.0000000092196266, 0.46347004953370e+01, 0.10693377436209e+02},
{ 0.0000000058581604, 0.11586746335276e+01, 0.26733443704266e+01},
{-0.0000000036218148, 0.23173675289588e+01, 0.53466887181044e+01},
{ 0.0000000034892754, 0.17122470613669e+00, 0.12475607079684e+02},
{ 0.0000000030842852, 0.36170311370435e+01, 0.63501320826717e+01},
{ 0.0000000020794650, 0.19906655633153e+01, 0.14257836656755e+02},
{ 0.0000000013655244, 0.49369712857369e+01, 0.13584836518140e-01},
{ 0.0000000011682572, 0.57934065580556e+01, 0.13366721796637e+02},
{-0.0000000008031976, 0.66879731833041e+00, 0.16040066232595e+02},
{ 0.0000000007309510, 0.56300556878949e+01, 0.17822295806244e+02},
{ 0.0000000007014118, 0.43297377080515e+01, 0.71002044886497e+01},
{ 0.0000000006561624, 0.43188797534991e+01, 0.13034138433510e-01},
{ 0.0000000005753088, 0.54252179509841e+01, 0.95251981240076e+01},
{ 0.0000000004359548, 0.11670110887440e+01, 0.19604525331797e+02},
{ 0.0000000003711992, 0.14936154077537e+01, 0.12938928912340e-01},
{-0.0000000003412576, 0.26346374300664e+01, 0.15571117257960e-01},
{ 0.0000000003432980, 0.17994723387341e+01, 0.31750663461810e+01},
{ 0.0000000003228344, 0.29861854159944e+01, 0.21386754933987e+02},
{ 0.0000000003014418, 0.19871924348983e+00, 0.24675315510310e-01},
{ 0.0000000001707670, 0.50718778620273e+01, 0.35514255456604e+01},
{ 0.0000000001655832, 0.29783205832994e+01, 0.44555739317536e+01},
{ 0.0000000001612910, 0.48058392680935e+01, 0.23168984521460e+02},
{ 0.0000000001527992, 0.18275651107267e+01, 0.18713410599600e+02},
{ 0.0000000001523312, 0.46323297275220e+01, 0.44686092108182e+01},
{ 0.0000000001449720, 0.19079860214667e+01, 0.30506511533200e-02},
{ 0.0000000001188688, 0.53321680658912e+01, 0.70987549449082e+01},
{ 0.0000000001129258, 0.95031497804420e+00, 0.12700264165343e+02},
{ 0.0000000000986086, 0.34190944178580e+00, 0.24951214111224e+02},
{-0.0000000000877720, 0.36228267942948e+01, 0.17958145576535e+01},
{ 0.0000000000857194, 0.33682834215727e+01, 0.59736711266730e+01},
{-0.0000000000545492, 0.19473964103154e+01, 0.22929425718040e-01},
{ 0.0000000000326102, 0.24880420823571e+01, 0.25119610718870e-01}
},
/* mean longitude sine series (32 terms) */
{
{-0.0001925258348666, 0.49369589722645e+01, 0.13584836583050e-01},
{-0.0000970803596076, 0.43188796477322e+01, 0.13034138432430e-01},
{-0.0000898817416500, 0.19080016428617e+01, 0.30506486715800e-02},
{-0.0000553101050262, 0.14936156681569e+01, 0.12938928911550e-01},
{-0.0000503584426150, 0.36410196089987e+01, 0.35644591049605e+01},
{-0.0000444412770116, 0.18196478828985e+01, 0.17822295777568e+01},
{ 0.0000418078870490, 0.26346334480977e+01, 0.15571117221300e-01},
{ 0.0000372356597388, 0.21402440902650e+01, 0.14500977488900e-02},
{-0.0000234440533016, 0.19871945729267e+00, 0.24675315507400e-01},
{-0.0000160313164240, 0.28203470990931e+01, 0.95196190000000e-04},
{-0.0000119049755698, 0.99521552502799e+00, 0.71289183312483e+01},
{-0.0000109014269320, 0.11586742711973e+01, 0.26733443704266e+01},
{ 0.0000087217118104, 0.22995085327344e+01, 0.44456805185000e-03},
{ 0.0000082229455492, 0.84723690387904e+00, 0.54980078903000e-03},
{ 0.0000075365481720, 0.30644603245150e+01, 0.64826749624000e-03},
{-0.0000061452803962, 0.28150499448772e+01, 0.89111478635073e+01},
{-0.0000057575824778, 0.34760236756099e+01, 0.80200331112799e+01},
{-0.0000053196302672, 0.14952058549171e+01, 0.29001290992300e-02},
{-0.0000051181206936, 0.46347077042449e+01, 0.10693377436209e+02},
{-0.0000047817413326, 0.49236512419835e+01, 0.30554833877800e-02},
{ 0.0000045554015322, 0.19585097634352e+01, 0.22928625941210e-01},
{ 0.0000043204134698, 0.15842888614383e+01, 0.15677112478190e-01},
{ 0.0000037684098282, 0.23173652780077e+01, 0.53466887181044e+01},
{-0.0000031403738248, 0.22184076281042e+01, 0.25155489165510e-01},
{ 0.0000024336535428, 0.85320650238886e+00, 0.25426968834400e-02},
{-0.0000020289901692, 0.36168998565188e+01, 0.63501320826717e+01},
{ 0.0000018665438704, 0.48458061649481e+01, 0.13589674898130e-01},
{-0.0000018552431038, 0.17086811529922e+00, 0.12475607079684e+02},
{-0.0000016229875536, 0.62803206871082e+01, 0.60414171604000e-03},
{-0.0000013160987604, 0.14718125754925e+01, 0.14358460012320e-01},
{ 0.0000008070729808, 0.38735416148641e+00, 0.37658680379100e-02},
{ 0.0000002602397658, 0.14337589305551e+01, 0.45692429208000e-02}
},
/* complex eccentricity cos/sin series (23 terms) */
{
{ 0.0041510849668155, 0.40899396355450e+01, -0.12906864146660e-01},
{ 0.0006260521444113, 0.14461888986270e+01, 0.35515522949802e+01},
{ 0.0000352747346169, 0.21256287034578e+01, 0.12727416567000e-03},
{ 0.0000198194483636, 0.55835619926762e+01, 0.32065751140000e-04},
{ 0.0000146399842989, 0.44137212696837e+00, 0.26642533547700e-02},
{ 0.0000098749504021, 0.45076118781320e+00, -0.35773660260022e+01},
{-0.0000096819265753, 0.59097266550442e+01, 0.17693227079462e+01},
{-0.0000083063168209, 0.28751474873012e+00, 0.87820791951527e+00},
{ 0.0000059689735869, 0.50740752477871e+01, 0.71160118048918e+01},
{-0.0000052220588690, 0.27460731023666e+01, 0.67796100936000e-03},
{ 0.0000046538995236, 0.49143203339385e+01, -0.53595956184347e+01},
{ 0.0000045951340101, 0.42533513770304e+01, -0.44684808200578e+01},
{-0.0000037711061757, 0.54120093562773e+01, -0.17951364445825e+01},
{ 0.0000037405126681, 0.30946737347297e+01, -0.71418251640916e+01},
{ 0.0000022044764663, 0.54360702580001e+01, -0.26491700241240e-01},
{ 0.0000018698303790, 0.41124042914226e+01, -0.27985797954589e+01},
{-0.0000015410375360, 0.27141931505529e+01, 0.27731236679900e-02},
{ 0.0000013214613496, 0.12750177723530e+01, -0.89240547799787e+01},
{-0.0000012707585609, 0.51141075152507e+01, 0.37654227378982e+00},
{ 0.0000012193607962, 0.59977053365953e+01, -0.98566169956900e-02},
{-0.0000011886104747, 0.32658350285168e+01, 0.53337818460633e+01},
{ 0.0000008742035177, 0.23903528311144e+01, 0.25194921818800e-02},
{-0.0000007689215742, 0.38308837306225e+01, -0.27293225500100e-02}
},
/* complex inclination cos/sin series (15 terms) */
{
{ 0.0003142172466014, 0.27964219722923e+01, -0.23150960980000e-02},
{ 0.0000904169207946, 0.10477061879627e+01, -0.56920638196000e-03},
{ 0.0000175695395780, 0.24150809680215e+01, 0.00000000000000e+00},
{ 0.0000164452324013, 0.33368861773902e+01, -0.12491307197000e-03},
{ 0.0000055424829091, 0.59720202381027e+01, -0.30561164720000e-04},
{ 0.0000035856270353, 0.84898736841329e+00, -0.25244521900630e-01},
{ 0.0000024180760140, 0.55603770950923e+01, 0.29003681445800e-02},
{-0.0000008673084930, 0.28496686106299e+00, -0.14500593353200e-02},
{-0.0000003176227277, 0.53834633036029e+01, -0.23498632298700e-01},
{ 0.0000003152816608, 0.45569499027478e+01, 0.43504654304000e-02},
{ 0.0000002338676726, 0.17633292120047e+01, 0.14501339138600e-02},
{ 0.0000001754553689, 0.48429319984493e+01, -0.25688816532440e-01},
{ 0.0000001286319583, 0.57543347143871e+01, -0.25813660979740e-01},
{ 0.0000000967213304, 0.11503592426900e+01, -0.29001471397800e-02},
{ 0.0000000000692310, 0.40745966852008e+01, -0.32506757319070e-01}
}
},
/* ---- Europa (J-II) ---- */
{
/* grav_param */ 0.2824832743928930e-06,
/* lon0 */ -0.3735263437471362e+00,
/* lon_rate */ 0.1769322711123470e+01,
/* n_a */ 38, /* n_l */ 36, /* n_z */ 41, /* n_zeta */ 25,
/* semi-major axis cosine series (38 terms) */
{
{ 0.0044871037804314, 0.00000000000000e+00, 0.00000000000000e+00},
{ 0.0000004324367498, 0.18196456062910e+01, 0.17822295777568e+01},
{ 0.0000001603614750, 0.43002726529577e+01, 0.26733443704266e+01},
{-0.0000001019146786, 0.54589480865442e+01, 0.53466887181044e+01},
{ 0.0000000924734786, 0.56222139048906e+01, 0.89111478887838e+00},
{-0.0000000523665800, 0.36392846323417e+01, 0.35644591656241e+01},
{ 0.0000000511509000, 0.29783307371014e+01, 0.44555739317536e+01},
{-0.0000000311907780, 0.99466557754027e+00, 0.71289183312483e+01},
{-0.0000000272859938, 0.28144480309092e+01, 0.89111478635073e+01},
{ 0.0000000232225828, 0.62608434364366e+01, 0.27856729211550e+01},
{-0.0000000181310770, 0.43188692380649e+01, 0.13034138308860e-01},
{ 0.0000000174960544, 0.16563941638726e+01, 0.62378035398422e+01},
{-0.0000000122874072, 0.46421290370833e+01, 0.10693377254218e+02},
{-0.0000000095367130, 0.14936536615312e+01, 0.12938928820690e-01},
{-0.0000000084863836, 0.17146854643555e+00, 0.12475607079684e+02},
{ 0.0000000071939342, 0.49376739095661e+01, 0.13584833017030e-01},
{ 0.0000000069122354, 0.62488746138492e+01, 0.41785094280464e+01},
{ 0.0000000061377568, 0.33434976298081e+00, 0.80200331112799e+01},
{-0.0000000045343054, 0.19892156959655e+01, 0.14257836656755e+02},
{ 0.0000000044574684, 0.34597804303324e+00, 0.35357692227539e+01},
{ 0.0000000042350072, 0.62719655202169e+01, 0.13928364636651e+01},
{-0.0000000028783772, 0.38108811302610e+01, 0.16040066232595e+02},
{ 0.0000000024354662, 0.99587190880214e+00, 0.90414989297380e+00},
{ 0.0000000022532940, 0.52958965893939e+01, 0.98022627054664e+01},
{ 0.0000000021573570, 0.62379050559630e+01, 0.55713458670107e+01},
{-0.0000000016530062, 0.56456686036734e+01, 0.17822294694543e+02},
{ 0.0000000016464798, 0.26346435392424e+01, 0.15571117126760e-01},
{ 0.0000000011589838, 0.32732388195745e+01, 0.17691951440716e+01},
{-0.0000000010251826, 0.19079858535660e+01, 0.30506497838200e-02},
{-0.0000000010203510, 0.11692020351116e+01, 0.19604525194172e+02},
{ 0.0000000007614982, 0.16862812414995e+01, 0.35342961443230e+01},
{ 0.0000000007104494, 0.59112717191092e+01, 0.24092214574831e+01},
{-0.0000000006957184, 0.24879412197796e+01, 0.25119609730670e-01},
{-0.0000000005817914, 0.19872303312324e+00, 0.24675315511270e-01},
{-0.0000000003792178, 0.15765189821595e+01, 0.25244441830200e-01},
{ 0.0000000003397378, 0.58126953372535e+01, 0.25973067138760e-01},
{ 0.0000000003159492, 0.23545476741301e+01, 0.26068277099550e-01},
{ 0.0000000002538154, 0.19471441186087e+01, 0.22929424919760e-01}
},
/* mean longitude sine series (36 terms) */
{
{ 0.0008576433172936, 0.43188693178264e+01, 0.13034138308050e-01},
{ 0.0004549582875086, 0.14936531751079e+01, 0.12938928819620e-01},
{ 0.0003248939825174, 0.18196494533458e+01, 0.17822295777568e+01},
{-0.0003074250079334, 0.49377037005911e+01, 0.13584832867240e-01},
{ 0.0001982386144784, 0.19079869054760e+01, 0.30510121286900e-02},
{ 0.0001834063551804, 0.21402853388529e+01, 0.14500978933800e-02},
{-0.0001434383188452, 0.56222140366630e+01, 0.89111478887838e+00},
{-0.0000771939140944, 0.43002724372350e+01, 0.26733443704266e+01},
{-0.0000632289777196, 0.26346392822098e+01, 0.15571117084700e-01},
{ 0.0000446766477388, 0.54589448561143e+01, 0.53466887181044e+01},
{ 0.0000436574731410, 0.36392908617709e+01, 0.35644591656241e+01},
{ 0.0000349172750296, 0.28289867162553e+01, 0.29885749150000e-04},
{-0.0000325709094646, 0.53721409780230e+01, 0.12495233774000e-03},
{ 0.0000205826473860, 0.15258464215508e+01, 0.29001315522200e-02},
{-0.0000192706087556, 0.29783311531879e+01, 0.44555739317536e+01},
{ 0.0000168028316254, 0.24879414119403e+01, 0.25119609725650e-01},
{-0.0000141628733606, 0.29183576504413e+01, 0.64930403718000e-03},
{ 0.0000140713155600, 0.19872319369353e+00, 0.24675315510310e-01},
{ 0.0000131946915760, 0.99584744364935e+00, 0.71289183312483e+01},
{ 0.0000106598617620, 0.53356907396678e+01, 0.30233219231900e-02},
{-0.0000104011727738, 0.62608296198866e+01, 0.27856729211550e+01},
{ 0.0000100746080234, 0.44288900030073e+01, 0.55297871931000e-03},
{ 0.0000097414019416, 0.27312462188296e+01, 0.89111510230745e+01},
{-0.0000094651366640, 0.25010358163865e+01, 0.93478322470000e-04},
{ 0.0000091108073324, 0.15765182522628e+01, 0.25244441682120e-01},
{-0.0000087720567668, 0.15376962386886e+01, 0.15676393315070e-01},
{-0.0000078429703340, 0.58128473756772e+01, 0.25973069246350e-01},
{-0.0000075566039418, 0.30586251688920e+01, 0.43252872559000e-03},
{-0.0000066580990752, 0.19591270593390e+01, 0.22928567412490e-01},
{-0.0000065854142774, 0.18617673337640e+01, 0.26093058384670e-01},
{-0.0000058131135230, 0.16563893807978e+01, 0.62378035398422e+01},
{ 0.0000055720865276, 0.39565695752204e+01, 0.25481216339900e-02},
{-0.0000048198508906, 0.62720230965345e+01, 0.13928364605775e+01},
{ 0.0000042728431266, 0.46220912946918e+01, 0.10693377982182e+02},
{ 0.0000042175545304, 0.13509343368359e+01, 0.30164435787800e-02},
{ 0.0000037707624520, 0.51034507119889e+01, 0.25219658202250e-01}
},
/* complex eccentricity cos/sin series (41 terms) */
{
{-0.0093589104136341, 0.40899396509039e+01, -0.12906864146660e-01},
{ 0.0002988994545555, 0.59097265185595e+01, 0.17693227079462e+01},
{ 0.0002139036390350, 0.21256289300016e+01, 0.12727418407000e-03},
{ 0.0001980963564781, 0.27435168292650e+01, 0.67797343009000e-03},
{ 0.0001210388158965, 0.55839943711203e+01, 0.32056614900000e-04},
{ 0.0000837042048393, 0.16094538368039e+01, -0.90402165808846e+00},
{ 0.0000823525166369, 0.14461887708689e+01, 0.35515522949802e+01},
{-0.0000315906532820, 0.28751224400811e+00, 0.87820791951527e+00},
{-0.0000294503681314, 0.45078002968967e+00, -0.35773660260022e+01},
{-0.0000278946698536, 0.22704374310903e+01, -0.17951364497113e+01},
{ 0.0000144958688621, 0.29313956641719e+01, -0.26862512422390e+01},
{ 0.0000139052321679, 0.60542576187622e+01, -0.25941002404500e-01},
{ 0.0000108374431350, 0.59320761116863e+01, -0.10163502160128e+01},
{-0.0000082175838585, 0.49144730088838e+01, -0.53595956184347e+01},
{ 0.0000073925894084, 0.25962855881215e+01, -0.25845792927870e-01},
{ 0.0000062618381566, 0.62252936384007e+01, -0.71418248393794e+01},
{-0.0000051968296512, 0.54353355159239e+01, -0.26491696725040e-01},
{-0.0000043507065743, 0.51150292346242e+01, 0.37654221060604e+00},
{ 0.0000042081682285, 0.31202613836361e+01, 0.44427230757158e+01},
{ 0.0000041298266970, 0.42533371370636e+01, -0.44684808200578e+01},
{-0.0000036991221930, 0.52487564172390e+01, 0.26604375002057e+01},
{-0.0000027357551003, 0.12734806685602e+01, -0.89240546532297e+01},
{-0.0000026854901206, 0.75596663258784e+00, 0.15806953180460e-01},
{ 0.0000023074479953, 0.19438998534712e+01, 0.71160114825227e+01},
{ 0.0000020163445050, 0.58484195254467e+01, -0.24091843401454e+01},
{-0.0000018506530067, 0.26838225102582e+01, 0.68236609075000e-03},
{ 0.0000018159137522, 0.26048690461733e+01, 0.62248966818788e+01},
{-0.0000017894118824, 0.57385537790777e+01, -0.10706284328884e+02},
{ 0.0000016518864520, 0.32658492478888e+01, 0.53337818460633e+01},
{-0.0000015660692561, 0.61789350505156e+01, -0.11453588847960e-01},
{ 0.0000014426949422, 0.60014075911383e+01, -0.17664769149811e+01},
{ 0.0000013196935928, 0.55753025652974e+01, -0.62507103665413e+01},
{-0.0000011726743714, 0.50242932747650e+01, -0.14351210704140e-01},
{-0.0000009550285338, 0.28409403047363e+01, 0.14257595044900e-02},
{-0.0000007569857746, 0.38098760906143e+01, -0.27271627793800e-02},
{-0.0000007495662748, 0.29896372346394e+01, 0.20097553243900e-02},
{ 0.0000007091149133, 0.27139331814919e+01, -0.19924932783300e-02},
{ 0.0000005646670312, 0.21683602575236e+01, -0.12871803232940e-01},
{-0.0000002004455524, 0.12893849410519e+01, -0.32724923477800e-02},
{-0.0000001623489363, 0.24189454629613e+00, 0.44609337678800e-02},
{ 0.0000001058862562, 0.45356953407129e+01, 0.39269908172100e-02}
},
/* complex inclination cos/sin series (25 terms) */
{
{ 0.0040404917832303, 0.10477063169425e+01, -0.56920640540000e-03},
{ 0.0002200421034564, 0.33368857864364e+01, -0.12491307307000e-03},
{ 0.0001662544744719, 0.24134862374711e+01, 0.00000000000000e+00},
{ 0.0000590282470983, 0.59719930968366e+01, -0.30561602250000e-04},
{-0.0000105030331400, 0.27964978379152e+01, -0.23150966123800e-02},
{-0.0000102943248250, 0.84898796322150e+00, -0.25244521901650e-01},
{ 0.0000072600013020, 0.55603730312676e+01, 0.29003676713100e-02},
{ 0.0000018391258758, 0.28480515491153e+00, -0.14500579196900e-02},
{ 0.0000014880605763, 0.48429974929766e+01, -0.25688815138710e-01},
{-0.0000008828196274, 0.65011185407635e+00, 0.34696170683100e-02},
{ 0.0000008714042768, 0.17639430319108e+01, 0.14501352157600e-02},
{ 0.0000008536188044, 0.45568506666427e+01, 0.43504641410100e-02},
{ 0.0000006846214331, 0.57542117253981e+01, -0.25813768702630e-01},
{ 0.0000004471826348, 0.53834694321520e+01, -0.23498632366370e-01},
{ 0.0000003034392168, 0.22078201315180e+01, -0.25783170906020e-01},
{ 0.0000001799083735, 0.31858868501531e+01, 0.88086056517000e-03},
{-0.0000001792048645, 0.51949494917342e+01, -0.20193236931900e-02},
{-0.0000001098546626, 0.59286821904995e+01, 0.49197316579700e-02},
{-0.0000001083128732, 0.45808061408794e+01, -0.59959459406000e-03},
{ 0.0000001062153531, 0.38387102863271e+01, -0.53795085847000e-03},
{ 0.0000000768496749, 0.35553768729770e+01, 0.58005587812700e-02},
{-0.0000000692273841, 0.46440611341931e+01, 0.30253219029200e-02},
{ 0.0000000676969224, 0.13621319661456e+00, -0.44430413602000e-03},
{-0.0000000621559952, 0.30093497179950e+01, -0.13603287200690e-01},
{ 0.0000000000608298, 0.40529569532600e+01, -0.32510869900940e-01}
}
},
/* ---- Ganymede (J-III) ---- */
{
/* grav_param */ 0.2824981841847230e-06,
/* lon0 */ 0.2874089391143348e+00,
/* lon_rate */ 0.8782079235893280e+00,
/* n_a */ 38, /* n_l */ 31, /* n_z */ 50, /* n_zeta */ 18,
/* semi-major axis cosine series (38 terms) */
{
{ 0.0071566594572575, 0.00000000000000e+00, 0.00000000000000e+00},
{ 0.0000013930299110, 0.11586745884981e+01, 0.26733443704266e+01},
{ 0.0000006449829346, 0.56222145702102e+01, 0.89111478887838e+00},
{ 0.0000002298059520, 0.12995924044108e+01, 0.10034433456729e+01},
{-0.0000001221434370, 0.49612436330515e+01, 0.17822295777568e+01},
{ 0.0000001095798176, 0.19486708778350e+01, 0.15051650461529e+01},
{ 0.0000000701435616, 0.64978508114196e+00, 0.50172166963138e+00},
{ 0.0000000547868566, 0.25992050672074e+01, 0.20068866945508e+01},
{-0.0000000394635858, 0.23173535605652e+01, 0.53466887181044e+01},
{-0.0000000363221428, 0.36393008632056e+01, 0.35644591656241e+01},
{ 0.0000000290949364, 0.20123392230605e+01, 0.17535157350384e+01},
{ 0.0000000281244968, 0.32490010762048e+01, 0.25086083721948e+01},
{-0.0000000207924698, 0.29783308899923e+01, 0.44555739317536e+01},
{ 0.0000000146896774, 0.38988244013504e+01, 0.30103300418262e+01},
{-0.0000000119930042, 0.16563968316083e+01, 0.62378035398422e+01},
{ 0.0000000112067460, 0.43188665692819e+01, 0.13034138285340e-01},
{-0.0000000109535132, 0.49372826282154e+01, 0.13584834937940e-01},
{ 0.0000000099867772, 0.96700720263958e+00, 0.62699633776977e+00},
{ 0.0000000077668260, 0.45486373016444e+01, 0.35120517182683e+01},
{ 0.0000000074143972, 0.16140449852661e+00, 0.12531361661566e+00},
{ 0.0000000066346638, 0.33441073536010e+00, 0.80200331112799e+01},
{ 0.0000000057842684, 0.14936630646671e+01, 0.12938928799370e-01},
{-0.0000000055768352, 0.44651777597613e+01, 0.11287386144710e+01},
{-0.0000000049395106, 0.61563894598809e+01, 0.17520662093793e+01},
{ 0.0000000041439704, 0.51984558307998e+01, 0.40137734147421e+01},
{-0.0000000040765630, 0.99543742426922e+00, 0.71289183312483e+01},
{-0.0000000036862062, 0.46386836178626e+01, 0.10693377254218e+02},
{ 0.0000000033617538, 0.37493658441448e+01, 0.87808669180168e+00},
{ 0.0000000033348284, 0.22668196818990e+01, 0.16304394485673e+01},
{-0.0000000025754698, 0.33293196902303e+00, 0.17952648120307e+01},
{ 0.0000000024363084, 0.19604838407749e+01, 0.11232854513197e+00},
{ 0.0000000022265432, 0.58482745704418e+01, 0.45154950951905e+01},
{ 0.0000000020032676, 0.29166648062069e+01, 0.21321610765333e+01},
{-0.0000000018115706, 0.99782757414001e+00, 0.90414978368384e+00},
{ 0.0000000014535006, 0.18748212041600e+01, 0.89112137093506e+01},
{-0.0000000006819260, 0.19871670124324e+00, 0.24675315493830e-01},
{ 0.0000000004433776, 0.24880003003965e+01, 0.25119610196650e-01},
{-0.0000000002836658, 0.58126277034761e+01, 0.25973068607520e-01}
},
/* mean longitude sine series (31 terms) */
{
{ 0.0002310797886226, 0.21402987195942e+01, 0.14500978438400e-02},
{-0.0001828635964118, 0.43188672736968e+01, 0.13034138282630e-01},
{ 0.0001512378778204, 0.49373102372298e+01, 0.13584834812520e-01},
{-0.0001163720969778, 0.43002659861490e+01, 0.26733443704266e+01},
{-0.0000955478069846, 0.14936612842567e+01, 0.12938928798570e-01},
{ 0.0000815246854464, 0.56222137132535e+01, 0.89111478887838e+00},
{-0.0000801219679602, 0.12995922951532e+01, 0.10034433456729e+01},
{-0.0000607017260182, 0.64978769669238e+00, 0.50172167043264e+00},
{ 0.0000543922473002, 0.27927547440639e+01, 0.29880873700000e-04},
{-0.0000489253646474, 0.53711728089803e+01, 0.12495278292000e-03},
{-0.0000427574981536, 0.18196513407448e+01, 0.17822295777568e+01},
{-0.0000307360417826, 0.19498372703786e+01, 0.15051650064903e+01},
{-0.0000169767346458, 0.19078637281659e+01, 0.30507678226700e-02},
{ 0.0000154725890508, 0.56912713028984e+01, 0.65164073556000e-03},
{-0.0000145268863648, 0.18863875475387e+00, 0.12530827181195e+00},
{-0.0000135654458738, 0.27930238268852e+01, 0.55663681407000e-03},
{-0.0000134648621904, 0.25991972928128e+01, 0.20068866945508e+01},
{ 0.0000095524017320, 0.23173520454449e+01, 0.53466887181044e+01},
{ 0.0000087955125170, 0.36393024031096e+01, 0.35644591656241e+01},
{ 0.0000075462003630, 0.53560617584395e+01, 0.92426977490000e-04},
{-0.0000071146195958, 0.20120561622463e+01, 0.17535157644008e+01},
{ 0.0000064153141218, 0.15526366820734e+01, 0.29001309732400e-02},
{-0.0000063221625942, 0.32490122452649e+01, 0.25086083721948e+01},
{-0.0000056564973024, 0.24862139082596e+01, 0.44834622386000e-03},
{ 0.0000052570245720, 0.19871532348033e+00, 0.24675315501580e-01},
{ 0.0000047020767994, 0.29783317790630e+01, 0.44555739317536e+01},
{-0.0000047004229470, 0.96617050453708e+00, 0.62699712737505e+00},
{-0.0000046565198820, 0.36125113449716e+01, 0.43633231340000e-03},
{-0.0000042349322008, 0.19604744669606e+01, 0.11232854282257e+00},
{-0.0000038755741918, 0.22619624763183e+01, 0.25146663939730e-01},
{-0.0000032577733688, 0.56861827246039e+01, 0.17074576501600e-02}
},
/* complex eccentricity cos/sin series (50 terms) */
{
{ 0.0014289811307319, 0.21256295942739e+01, 0.12727413029000e-03},
{ 0.0007710931226760, 0.55836330003496e+01, 0.32064341100000e-04},
{ 0.0005925911780766, 0.40899396636448e+01, -0.12906864146660e-01},
{ 0.0002045597496146, 0.52713683670372e+01, -0.12523544076106e+00},
{ 0.0001785118648258, 0.28743156721063e+00, 0.87820792442520e+00},
{ 0.0001131999784893, 0.14462127277818e+01, 0.35515522949802e+01},
{-0.0000658778169210, 0.22702423990985e+01, -0.17951364394537e+01},
{ 0.0000497058888328, 0.59096792204858e+01, 0.17693227129285e+01},
{-0.0000316384926978, 0.16093054939404e+01, -0.90402165028424e+00},
{ 0.0000287801237327, 0.46217321268757e+01, -0.62695712341840e+00},
{-0.0000181744317896, 0.59210641379360e+01, 0.37648623991673e+00},
{ 0.0000105558175161, 0.39720191398746e+01, -0.11286788041058e+01},
{-0.0000070808673396, 0.60542548894164e+01, -0.25941002415210e-01},
{-0.0000070804404020, 0.27978433776854e+01, 0.67774258703000e-03},
{-0.0000061046181888, 0.14151685760988e+01, -0.87530769416913e+00},
{-0.0000057610853129, 0.42530537622646e+01, -0.44684807882788e+01},
{-0.0000057310334964, 0.29311803223072e+01, -0.26862512192699e+01},
{ 0.0000048299146941, 0.27138294508149e+01, 0.27731329671900e-02},
{ 0.0000046610005483, 0.33222980229554e+01, -0.16304004832039e+01},
{-0.0000038142769361, 0.25962943627643e+01, -0.25845792955510e-01},
{ 0.0000034982417330, 0.15866568011217e+01, 0.18816512920593e+01},
{-0.0000030091617315, 0.35921173988567e+01, -0.35773660056343e+01},
{-0.0000024732926446, 0.53461730094807e+01, 0.25122576835111e+00},
{ 0.0000024416432533, 0.47049477027963e+01, -0.25049613834712e+00},
{ 0.0000024171568015, 0.34508032389167e+01, 0.00000000000000e+00},
{ 0.0000023143850535, 0.55385759257808e+01, 0.28683339028800e-02},
{ 0.0000022651772374, 0.55608006706192e+01, 0.14501892967800e-02},
{ 0.0000022247695560, 0.26725424635341e+01, -0.21321221654766e+01},
{ 0.0000020947921969, 0.22350374116258e+01, 0.23833730192673e+01},
{-0.0000014042712722, 0.93718044411525e+00, 0.13799296041822e+01},
{ 0.0000011932531874, 0.28861941414418e+01, 0.28850946416201e+01},
{-0.0000011180389240, 0.49139919849718e+01, -0.53595955727170e+01},
{ 0.0000011076384510, 0.20227538540345e+01, -0.26338438454332e+01},
{-0.0000010371714944, 0.40722739402948e+00, -0.87385759089274e+00},
{-0.0000008993128501, 0.30942691883530e+01, -0.71418251640916e+01},
{ 0.0000007268381420, 0.54334774230433e+01, -0.26491687896550e-01},
{-0.0000007178049665, 0.52487423493616e+01, 0.26604375002057e+01},
{ 0.0000006908412319, 0.40596134184175e+01, -0.75221793556997e+00},
{-0.0000006784151570, 0.38846818226669e+01, 0.42496535534400e-02},
{ 0.0000006772314920, 0.23013479896873e+01, 0.26317235358158e+01},
{ 0.0000006659820028, 0.35359530295550e+01, 0.33868163258510e+01},
{-0.0000006339665249, 0.39268665697903e+01, 0.44426670658559e+01},
{-0.0000006286307601, 0.19440608894162e+01, 0.71160114019304e+01},
{-0.0000006128705113, 0.25027415074658e+01, 0.62249001971556e+01},
{ 0.0000005660807396, 0.13729316457251e+01, -0.31355655165873e+01},
{-0.0000005206551413, 0.55749300982469e+01, -0.62507103665413e+01},
{-0.0000004718481418, 0.45366605084874e+01, 0.16786677353000e-03},
{-0.0000004583970422, 0.19351070248496e+01, -0.98151695574500e+01},
{-0.0000004577854173, 0.62350780976534e+01, 0.17563373058434e+01},
{ 0.0000003466029660, 0.75412427489767e+00, 0.15807097495700e-01}
},
/* complex inclination cos/sin series (18 terms) */
{
{ 0.0015932721570848, 0.33368862796665e+01, -0.12491307058000e-03},
{ 0.0008533093128905, 0.24133881688166e+01, 0.00000000000000e+00},
{ 0.0003513347911037, 0.59720789850127e+01, -0.30561017710000e-04},
{-0.0001441929255483, 0.10477061764435e+01, -0.56920632124000e-03},
{ 0.0000157303527750, 0.55604041197704e+01, 0.29003665011200e-02},
{ 0.0000025161319881, 0.28477653709685e+00, -0.14500554486800e-02},
{ 0.0000020438305183, 0.17652628559998e+01, 0.14501383926500e-02},
{ 0.0000017939612784, 0.45568977341583e+01, 0.43504621590400e-02},
{ 0.0000013614276895, 0.84898872627945e+00, -0.25244521900630e-01},
{-0.0000008996109017, 0.46441156003340e+01, 0.30253214588300e-02},
{-0.0000008702078430, 0.27972000093551e+01, -0.23150965645100e-02},
{-0.0000004371144064, 0.48429530385679e+01, -0.25688816011500e-01},
{-0.0000002174259374, 0.57543785603741e+01, -0.25813642993310e-01},
{-0.0000001926397869, 0.20118539705648e+01, 0.29330596864500e-02},
{ 0.0000001589279656, 0.35554727018503e+01, 0.58005577768400e-02},
{-0.0000001432228753, 0.11966574544002e+01, -0.15750124983800e-02},
{-0.0000000926213408, 0.22052538606469e+01, -0.25782797426020e-01},
{ 0.0000000000106902, 0.45764213311755e+01, -0.32611614716800e-01}
}
},
/* ---- Callisto (J-IV) ---- */
{
/* grav_param */ 0.2824921448899090e-06,
/* lon0 */ -0.3620341291375704e+00,
/* lon_rate */ 0.3764862334338280e+00,
/* n_a */ 22, /* n_l */ 19, /* n_z */ 46, /* n_zeta */ 18,
/* semi-major axis cosine series (22 terms) */
{
{ 0.0125879701715314, 0.00000000000000e+00, 0.00000000000000e+00},
{ 0.0000035952049470, 0.64965776007116e+00, 0.50172168165034e+00},
{ 0.0000027580210652, 0.18084235781510e+01, 0.31750660413359e+01},
{ 0.0000012874896172, 0.62718908285025e+01, 0.13928364698403e+01},
{-0.0000004173729106, 0.12990650292663e+01, 0.10034433697108e+01},
{ 0.0000002790757718, 0.71428870045577e+00, 0.75007225869130e+00},
{-0.0000001998252258, 0.19489881012004e+01, 0.15051650461529e+01},
{-0.0000001001149838, 0.25987168731338e+01, 0.20068867266014e+01},
{-0.0000000513967092, 0.32484798706247e+01, 0.25086084022422e+01},
{-0.0000000475687992, 0.48635521917696e+01, 0.74862216593606e+00},
{ 0.0000000348242240, 0.15082713497295e+00, 0.37645917070525e+00},
{ 0.0000000283840630, 0.51672973364888e+01, 0.12530678073049e+00},
{-0.0000000263234638, 0.33499822210495e+01, 0.30103491232578e+01},
{ 0.0000000239106346, 0.43573519442736e+01, 0.62698238798737e+00},
{ 0.0000000219977422, 0.15075404808879e+01, 0.27986109086768e+01},
{-0.0000000171144478, 0.62607361864777e+01, 0.27856729335053e+01},
{-0.0000000141956834, 0.45481077718910e+01, 0.35120517575303e+01},
{-0.0000000120003630, 0.18583887479127e+01, 0.11287042579152e+01},
{ 0.0000000108418904, 0.54873138800427e+01, 0.67395238593127e+01},
{ 0.0000000108218254, 0.59772630516669e+01, 0.10163811590412e+01},
{ 0.0000000002477642, 0.56894071957878e+01, 0.65165021654000e-03},
{-0.0000000001874576, 0.28598333265121e+01, 0.55639542661000e-03}
},
/* mean longitude sine series (19 terms) */
{
{ 0.0005586040123824, 0.21404207189815e+01, 0.14500979323100e-02},
{-0.0003805813868176, 0.27358844897853e+01, 0.29729650620000e-04},
{ 0.0002205152863262, 0.64979652596400e+00, 0.50172167243580e+00},
{ 0.0001877895151158, 0.18084787604005e+01, 0.31750660413359e+01},
{ 0.0000766916975242, 0.62720114319755e+01, 0.13928364636651e+01},
{ 0.0000747056855106, 0.12995916202344e+01, 0.10034433456729e+01},
{-0.0000388323297366, 0.71289234751879e+00, 0.75007236972328e+00},
{ 0.0000335036484314, 0.53712641184981e+01, 0.12494011725000e-03},
{ 0.0000293032677938, 0.19493939340593e+01, 0.15051650209131e+01},
{ 0.0000185940935472, 0.14630998372377e+01, 0.29001339405200e-02},
{-0.0000170438022886, 0.56893382353856e+01, 0.65165044781000e-03},
{ 0.0000151393833114, 0.28749516044614e+01, 0.55646069067000e-03},
{-0.0000148825637256, 0.33321074618840e+01, 0.12530790075011e+00},
{ 0.0000129927896682, 0.25991973549465e+01, 0.20068866945508e+01},
{-0.0000116117398772, 0.56192268627131e+01, 0.93166256720000e-04},
{ 0.0000066211702894, 0.48564958193206e+01, 0.74862286166569e+00},
{ 0.0000065387442486, 0.35580120361824e+01, 0.16550513741900e-02},
{ 0.0000061580798140, 0.32490037889701e+01, 0.25086083721948e+01},
{ 0.0000046797140778, 0.96612169919707e+00, 0.62699716616712e+00}
},
/* complex eccentricity cos/sin series (46 terms) */
{
{ 0.0073755808467977, 0.55836071576084e+01, 0.32065099140000e-04},
{ 0.0002065924169942, 0.59209831565786e+01, 0.37648624194703e+00},
{ 0.0001589869764021, 0.28744006242623e+00, 0.87820792442520e+00},
{-0.0001561131605348, 0.21257397865089e+01, 0.12727441285000e-03},
{ 0.0001486043380971, 0.14462134301023e+01, 0.35515522949802e+01},
{ 0.0000635073108731, 0.59096803285954e+01, 0.17693227129285e+01},
{ 0.0000599351698525, 0.41125517584798e+01, -0.27985797954589e+01},
{ 0.0000540660842731, 0.55390350845569e+01, 0.28683408228300e-02},
{-0.0000489596900866, 0.46218149483338e+01, -0.62695712529519e+00},
{ 0.0000333682283528, 0.52066975238880e+01, -0.37358601734497e+00},
{ 0.0000295832427279, 0.59322697896516e+01, -0.10163502275209e+01},
{ 0.0000292325461337, 0.52707623402008e+01, -0.12523542448602e+00},
{ 0.0000197588369441, 0.33317768022759e+01, 0.00000000000000e+00},
{-0.0000183551029746, 0.39720443249757e+01, -0.11286788041058e+01},
{ 0.0000090411191759, 0.55606719963947e+01, 0.14501837490800e-02},
{-0.0000081987970452, 0.33223313720086e+01, -0.16304004832039e+01},
{-0.0000060406575087, 0.13970265485562e+01, 0.43191832032300e-02},
{ 0.0000056895636122, 0.41990956668120e+01, -0.37213592656720e+00},
{-0.0000040434854859, 0.47008406172134e+01, -0.25049602889288e+00},
{-0.0000039403527376, 0.26725832255243e+01, -0.21321221654766e+01},
{ 0.0000036901291978, 0.35207772267753e+00, 0.11265585018525e+01},
{-0.0000028551622596, 0.55601265129356e+01, -0.31584886140000e-04},
{-0.0000026588026505, 0.25969882784477e+00, 0.14182025553300e-02},
{-0.0000019711212463, 0.20228019680496e+01, -0.26338438454332e+01},
{ 0.0000019322089806, 0.51418595457408e+01, 0.25123117908847e+00},
{-0.0000018673159813, 0.93674892088247e+00, 0.13799296163047e+01},
{ 0.0000016838424078, 0.60796033426941e+01, 0.75294520843775e+00},
{-0.0000016695689644, 0.15867810488422e+01, 0.18816512864243e+01},
{ 0.0000016317841395, 0.45789534393209e+01, 0.14822429153000e-02},
{-0.0000016159095087, 0.30157253757329e+00, -0.14180284447900e-02},
{-0.0000014034621874, 0.59433512039442e+01, -0.24091866865037e+01},
{-0.0000012029942283, 0.27137754880270e+01, 0.27731373092900e-02},
{-0.0000011758260607, 0.40581098970285e+01, -0.75221789504525e+00},
{-0.0000010798624964, 0.22364861319452e+01, 0.23833729650229e+01},
{-0.0000010108880552, 0.13729872033949e+01, -0.31355655165873e+01},
{-0.0000008876681807, 0.50534107615010e+01, -0.50169281575682e+00},
{ 0.0000008869382117, 0.50147420853991e+01, 0.68353864231000e-03},
{-0.0000008194699011, 0.62190878357566e+01, -0.37503615934219e+00},
{ 0.0000007093782158, 0.44118312641559e+01, -0.24221246131672e+01},
{ 0.0000006728737059, 0.31910016062920e+01, -0.37068584881726e+00},
{ 0.0000006297345982, 0.13595719733984e+01, 0.11251084135564e+01},
{ 0.0000006128899757, 0.51402161299290e+01, 0.71160095483087e+01},
{-0.0000005580987049, 0.34117733109010e+01, -0.12539396666771e+01},
{ 0.0000005321318002, 0.35377046967957e+01, 0.57685340271300e-02},
{-0.0000004739086661, 0.21645217929478e+01, 0.58845474482000e-03},
{ 0.0000004518928658, 0.44963664372727e+01, 0.29023325111200e-02}
},
/* complex inclination cos/sin series (18 terms) */
{
{ 0.0038422977898495, 0.24133922085557e+01, 0.00000000000000e+00},
{ 0.0022453891791894, 0.59721736773277e+01, -0.30561255250000e-04},
{-0.0002604479450559, 0.33368746306409e+01, -0.12491309972000e-03},
{ 0.0000332112143230, 0.55604137742337e+01, 0.29003768850700e-02},
{ 0.0000049727136261, 0.28488229706820e+00, -0.14500571761900e-02},
{-0.0000049416729114, 0.10476908456459e+01, -0.56920298857000e-03},
{ 0.0000043945193428, 0.17684273746003e+01, 0.14501344524700e-02},
{ 0.0000037630501589, 0.45567680530533e+01, 0.43504645407000e-02},
{-0.0000030823418750, 0.20094360655956e+01, 0.29313051376700e-02},
{ 0.0000004719790711, 0.18055417618741e+01, 0.14195445432000e-02},
{-0.0000004637177865, 0.38277528822158e+01, -0.14808731001600e-02},
{ 0.0000003497224175, 0.46444360330108e+01, 0.30253130162300e-02},
{-0.0000003467132626, 0.10120757927163e+01, 0.43816126822900e-02},
{ 0.0000003324412570, 0.35549391686606e+01, 0.58005379032100e-02},
{ 0.0000001945374351, 0.61251687150860e+01, 0.28808264872800e-02},
{ 0.0000001727743329, 0.11900773236610e+01, -0.29001068524700e-02},
{-0.0000001485176585, 0.62335834706368e+01, 0.14807679092700e-02},
{ 0.0000000000666922, 0.40616225761771e+01, -0.32724923474890e-01}
}
}
};

73
src/l12.h Normal file
View File

@ -0,0 +1,73 @@
/************************************************************************
L1.2 Galilean satellite theory -- Lainey, Duriez & Vienne
Clean-room implementation for pg_orrery.
The L1.2 theory provides positions and velocities of Jupiter's four
Galilean moons (Io, Europa, Ganymede, Callisto) relative to Jupiter's
center, in the VSOP87 ecliptic J2000 reference frame.
Reference:
Lainey V., Duriez L., Vienne A.
"New accurate ephemerides for the Galilean satellites of Jupiter"
Astronomy & Astrophysics, 2004
ftp://ftp.imcce.fr/pub/ephem/satel/galilean/L1/L1.2/
The theory coefficients are astronomical constants derived from
fitting observations of the Galilean system. They represent
physical measurements and are not copyrightable.
Copyright (c) 2026 Ryan Malloy <ryan@supported.systems>
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Thread-safe: all functions are reentrant with no static mutable state.
****************************************************************/
#ifndef L12_H
#define L12_H
#ifdef __cplusplus
extern "C" {
#endif
#define L12_IO 0
#define L12_EUROPA 1
#define L12_GANYMEDE 2
#define L12_CALLISTO 3
/*
* Compute the position (and optionally velocity) of a Galilean moon
* using the L1.2 semi-analytic theory.
*
* jd: Julian date in Terrestrial Time (TT).
* body: Moon index: 0=Io, 1=Europa, 2=Ganymede, 3=Callisto.
* xyz: Output position in AU, VSOP87 ecliptic J2000 frame,
* relative to Jupiter's center. Must point to double[3].
* xyzdot: Output velocity in AU/day, same frame. May be NULL
* to skip velocity computation.
*/
void GetL12Coor(double jd, int body, double *xyz, double *xyzdot);
#ifdef __cplusplus
}
#endif
#endif /* L12_H */

249
src/lambert.c Normal file
View File

@ -0,0 +1,249 @@
/*
* lambert.c -- Lambert transfer orbit solver (Universal Variable)
*
* Solves Lambert's problem using the Universal Variable formulation
* with Stumpff functions c2(psi) and c3(psi). This handles elliptic,
* parabolic, and hyperbolic transfers with one unified algorithm.
*
* The approach:
* 1. Compute geometry: r1_mag, r2_mag, delta_theta
* 2. Compute A from geometry (determines short/long way)
* 3. Iterate on universal variable z to match time of flight
* 4. Extract departure/arrival velocities from Lagrange coefficients
*
* References:
* Curtis, "Orbital Mechanics for Engineering Students" (2014), Ch. 5
* Bate, Mueller & White, "Fundamentals of Astrodynamics" (1971), Ch. 5
*/
#include <math.h>
#include "lambert.h"
/* Stumpff function c2(psi) = (1 - cos(sqrt(psi))) / psi */
static double
stumpff_c2(double psi)
{
double sq;
if (psi > 1e-6) {
sq = sqrt(psi);
return (1.0 - cos(sq)) / psi;
}
if (psi < -1e-6) {
sq = sqrt(-psi);
return (1.0 - cosh(sq)) / psi;
}
/* Taylor series near zero: 1/2 - psi/24 + psi^2/720 */
return 1.0/2.0 - psi/24.0 + psi*psi/720.0;
}
/* Stumpff function c3(psi) = (sqrt(psi) - sin(sqrt(psi))) / (psi * sqrt(psi)) */
static double
stumpff_c3(double psi)
{
double sq;
if (psi > 1e-6) {
sq = sqrt(psi);
return (sq - sin(sq)) / (psi * sq);
}
if (psi < -1e-6) {
sq = sqrt(-psi);
return (sinh(sq) - sq) / (-psi * sq);
}
/* Taylor series near zero: 1/6 - psi/120 + psi^2/5040 */
return 1.0/6.0 - psi/120.0 + psi*psi/5040.0;
}
int
lambert_solve_uv(const double r1[3], const double r2[3],
double tof_days, double mu, int prograde,
lambert_result *result)
{
double r1_mag, r2_mag;
double cos_dtheta, sin_dtheta, dtheta;
double A, z, z_lo, z_hi;
double c2, c3, y, x, t, dt_dz;
double f, g, gdot;
int i;
int max_iter = 50;
double tol = 1e-10;
double cross_z;
result->converged = 0;
if (tof_days <= 0.0)
return 0;
/* Magnitudes */
r1_mag = sqrt(r1[0]*r1[0] + r1[1]*r1[1] + r1[2]*r1[2]);
r2_mag = sqrt(r2[0]*r2[0] + r2[1]*r2[1] + r2[2]*r2[2]);
if (r1_mag < 1e-12 || r2_mag < 1e-12)
return 0;
/* Transfer angle from cross product */
cos_dtheta = (r1[0]*r2[0] + r1[1]*r2[1] + r1[2]*r2[2]) / (r1_mag * r2_mag);
/* Clamp for numerical safety */
if (cos_dtheta > 1.0) cos_dtheta = 1.0;
if (cos_dtheta < -1.0) cos_dtheta = -1.0;
/* Cross product z-component determines orbit direction */
cross_z = r1[0]*r2[1] - r1[1]*r2[0];
if (prograde) {
if (cross_z < 0.0)
sin_dtheta = -sqrt(1.0 - cos_dtheta*cos_dtheta);
else
sin_dtheta = sqrt(1.0 - cos_dtheta*cos_dtheta);
} else {
if (cross_z >= 0.0)
sin_dtheta = -sqrt(1.0 - cos_dtheta*cos_dtheta);
else
sin_dtheta = sqrt(1.0 - cos_dtheta*cos_dtheta);
}
dtheta = atan2(sin_dtheta, cos_dtheta);
if (dtheta < 0.0)
dtheta += 2.0 * M_PI;
/* A parameter (Curtis eq. 5.35) */
A = sin_dtheta * sqrt(r1_mag * r2_mag / (1.0 - cos_dtheta));
if (fabs(A) < 1e-14)
return 0; /* Degenerate: 0 or 180 deg transfer */
/*
* Newton-Raphson iteration on z (universal variable).
* z > 0: elliptic, z = 0: parabolic, z < 0: hyperbolic.
* Initial bracket: z_lo = -4*pi^2 (max hyperbolic), z_hi from geometry.
*/
z_lo = -4.0 * M_PI * M_PI;
z_hi = 4.0 * M_PI * M_PI;
z = 0.0; /* Start at parabolic */
for (i = 0; i < max_iter; i++) {
c2 = stumpff_c2(z);
c3 = stumpff_c3(z);
y = r1_mag + r2_mag + A * (z * c3 - 1.0) / sqrt(c2);
if (y < 0.0) {
/* y must be positive; adjust z upward */
z_lo = z;
z = (z + z_hi) * 0.5;
continue;
}
x = sqrt(y / c2);
/* Time of flight for this z */
t = (x*x*x * c3 + A * sqrt(y)) / sqrt(mu);
/* Check convergence */
if (fabs(t - tof_days) < tol * fabs(tof_days) + 1e-12) {
result->converged = 1;
break;
}
/* Derivative dt/dz for Newton step */
if (fabs(z) > 1e-6) {
dt_dz = (x*x*x * (stumpff_c2(z) - 3.0*c3/(2.0*c2)) / (2.0*c2)
+ (3.0*c3*sqrt(y)/c2 + A/sqrt(y)*(1.0 - z*c3/c2)) * A / (2.0*c2*sqrt(c2)))
/ sqrt(mu);
/* Simplified: use bisection if Newton overshoots */
if (fabs(dt_dz) < 1e-20) {
/* Fall back to bisection */
if (t < tof_days)
z_lo = z;
else
z_hi = z;
z = (z_lo + z_hi) * 0.5;
continue;
}
z = z - (t - tof_days) / dt_dz;
} else {
/* Near parabolic: bisection */
if (t < tof_days)
z_lo = z;
else
z_hi = z;
z = (z_lo + z_hi) * 0.5;
}
/* Keep z in bounds */
if (z < z_lo) z = z_lo + 0.1 * (z_hi - z_lo);
if (z > z_hi) z = z_hi - 0.1 * (z_hi - z_lo);
}
if (!result->converged) {
/*
* Newton didn't converge; try pure bisection as fallback.
* Reset bounds and iterate.
*/
z_lo = -4.0 * M_PI * M_PI;
z_hi = 4.0 * M_PI * M_PI;
for (i = 0; i < 100; i++) {
z = (z_lo + z_hi) * 0.5;
c2 = stumpff_c2(z);
c3 = stumpff_c3(z);
y = r1_mag + r2_mag + A * (z * c3 - 1.0) / sqrt(c2);
if (y < 0.0) {
z_lo = z;
continue;
}
x = sqrt(y / c2);
t = (x*x*x * c3 + A * sqrt(y)) / sqrt(mu);
if (fabs(t - tof_days) < tol * fabs(tof_days) + 1e-12) {
result->converged = 1;
break;
}
if (t < tof_days)
z_lo = z;
else
z_hi = z;
if (fabs(z_hi - z_lo) < 1e-14)
break;
}
}
if (!result->converged)
return 0;
/* Lagrange coefficients (Curtis eqs. 5.46) */
c2 = stumpff_c2(z);
c3 = stumpff_c3(z);
y = r1_mag + r2_mag + A * (z * c3 - 1.0) / sqrt(c2);
f = 1.0 - y / r1_mag;
g = A * sqrt(y / mu);
gdot = 1.0 - y / r2_mag;
/* Departure and arrival velocity vectors */
{
int k;
for (k = 0; k < 3; k++) {
result->v1[k] = (r2[k] - f * r1[k]) / g;
result->v2[k] = (gdot * r2[k] - r1[k]) / g;
}
}
/* Semi-major axis */
if (fabs(c2) > 1e-14)
result->sma = y / c2 / (2.0); /* a = y / (2 * c2(z)) ... but simpler: */
else
result->sma = 1e30; /* Near-parabolic */
/* Correct SMA from z */
if (fabs(z) > 1e-10)
result->sma = y / (2.0 * c2);
return 1;
}

60
src/lambert.h Normal file
View File

@ -0,0 +1,60 @@
/*
* lambert.h -- Lambert transfer orbit solver
*
* Solves Lambert's problem: given two position vectors and a
* time of flight, find the orbit connecting them.
*
* Uses the Universal Variable formulation with Stumpff functions,
* handling elliptic, parabolic, and hyperbolic transfers uniformly.
*
* Reference:
* Bate, Mueller & White, "Fundamentals of Astrodynamics" (1971)
* Battin, "An Introduction to the Methods of Astrodynamics" (1999)
* Curtis, "Orbital Mechanics for Engineering Students" (2014)
*
* All units: AU, days, AU/day. Gravitational parameter is Gauss's
* constant squared (k^2 = GM_sun in AU^3/day^2).
*
* Thread-safe: no static mutable state.
*/
#ifndef PG_ORRERY_LAMBERT_H
#define PG_ORRERY_LAMBERT_H
#ifdef __cplusplus
extern "C" {
#endif
/*
* Result of a Lambert transfer orbit solution.
* All velocities in AU/day.
*/
typedef struct lambert_result
{
double v1[3]; /* departure velocity vector (AU/day) */
double v2[3]; /* arrival velocity vector (AU/day) */
double sma; /* semi-major axis (AU), negative for hyperbolic */
int converged; /* 1 if solver converged, 0 otherwise */
} lambert_result;
/*
* Solve Lambert's problem.
*
* r1[3]: departure position (AU, heliocentric ecliptic J2000)
* r2[3]: arrival position (AU, heliocentric ecliptic J2000)
* tof_days: time of flight in days (must be > 0)
* mu: gravitational parameter (AU^3/day^2), use GAUSS_K2 for Sun
* prograde: 1 for prograde (short way), 0 for retrograde (long way)
* result: output lambert_result
*
* Returns 1 on success, 0 on failure.
*/
int lambert_solve_uv(const double r1[3], const double r2[3],
double tof_days, double mu, int prograde,
lambert_result *result);
#ifdef __cplusplus
}
#endif
#endif /* PG_ORRERY_LAMBERT_H */

425
src/marssat.c Normal file
View File

@ -0,0 +1,425 @@
/************************************************************************
The Ephemerides of the Martian satellites
(adjustement from 1877 to 2005, Version 1.0)
by Valery Lainey can be obtained from Valery Lainey:
V.Lainey (Lainey@oma.be)
ROB- 3, Avenue Circulaire, B-1180 Bruxelles (Belgium)
IMCCE - 77, Avenue Denfert-Rochereau 75014 Paris (France)
-----------------------------------------------------------------------
I (Johannes Gajdosik) have just taken Valery Laineys Fortran code,
MarsSatV1-0.f, which he kindly supplied, and rearranged it into
this piece of software.
I can neither allow nor forbid the usage of Valery Laineys
Ephemerides of the Martian satellites.
The copyright notice below covers not the work of Valery Lainey
but just my work, that is the compilation of Valery Laineys
Ephemerides of the Martian satellites into the software supplied in this file.
Copyright (c) 2006 Johannes Gajdosik
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Derived from Stellarium's MARSSAT implementation.
Modified for pg_orrery: removed all static mutable state for thread safety
(PostgreSQL PARALLEL SAFE). The original used static caching arrays and
CalcInterpolatedElements for performance; this version computes fresh on
each call which is acceptable for SQL query workloads.
1) do not calculate constant terms at runtime but beforehand
2) unite terms with the same frequencies
****************************************************************/
#include "marssat.h"
#include "elliptic_to_rectangular.h"
#include <math.h>
#include <string.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
struct MarsSatTerm {
double phase, frequency, amplitude;
};
struct MarsSatTermList {
const struct MarsSatTerm *terms;
int size;
};
struct MarsSatBody {
double mu, l, acc;
double constants[6];
const struct MarsSatTermList lists[4];
};
static const struct MarsSatTerm mars_sat_phobos_0[16] = {
{ 5.013490350126586e+00, 2.715567382195733e+01, 4.537539999999999e-09},
{ 6.839910590780000e-01, 1.969446151585829e+01, 7.312000000000000e-10},
{ 4.514031245592586e+00, 4.073350897245015e+01, 7.785599999999993e-10},
{ 5.531351678889586e+00, 1.357783661756435e+01, 3.529399999999985e-10},
{ 5.700307292822586e+00, 4.685013393001369e+01, 2.094599999999994e-10},
{ 4.228062220664587e+00, 5.431134764391466e+01, 9.139999999999999e-11},
{ 1.167907583017500e+00, 7.461211875946264e+00, 6.204000000000000e-11},
{ 5.197274703601086e+00, 6.042797388545593e+01, 5.295999999999951e-11},
{ 5.160045430868086e+00, 3.938582310889583e+01, 1.811999999999999e-11},
{ 2.223277229900500e+00, 5.777452001970681e+01, 1.563999999999897e-11},
{ 6.921616240614999e-01, 2.103904892768837e+01, 1.535999999999999e-11},
{ 6.215802752555586e+00, 3.327229783044108e+01, 1.304000000000000e-11},
{ 1.368231494011000e+00, 3.938892179708230e+01, 1.661999999999999e-11},
{ 2.601539199675000e-01, 7.446011234647927e+00, 1.328000000000000e-11},
{ 5.903845687100000e-02, 3.941932465627423e+01, 8.559999999999978e-12},
{ 6.179310844209586e+00, 5.911910805492755e+01, 7.240000000000000e-12},
};
static const struct MarsSatTerm mars_sat_phobos_1[42] = {
{ 5.334169568271586e+00, 9.145914066599032e-03, 5.016848130000000e-05},
{ 5.334169148295587e+00, 9.145914113327495e-03, 5.016848130000000e-05},
{ 3.805345265960000e-01, 1.540952541904328e-03, 3.350264970000000e-05},
{ 3.805339484960000e-01, 1.540952613104260e-03, 3.350264903000000e-05},
{ 5.288363718535586e+00, 1.829243888046791e-02, 2.827839093000000e-05},
{ 5.288363809359586e+00, 1.829243887034939e-02, 2.827839093000000e-05},
{ 3.412685718051586e+00, 7.604696023359408e-03, 1.578862428000000e-05},
{ 3.412685998796586e+00, 7.604695992157328e-03, 1.578862427000000e-05},
{ 3.448999855869586e+00, 2.715567616927210e+01, 3.008844320000000e-05},
{ 9.194445444030001e-01, 3.101144183892481e-03, 8.178563170000001e-06},
{ 9.194443192780000e-01, 3.101144208596499e-03, 8.178563340000000e-06},
{ 5.387625479307586e+00, 1.969446275049270e+01, 2.044327843999999e-05},
{ 2.848996739890000e+00, 2.743829098982358e-02, 6.516951720000000e-06},
{ 2.848996748767000e+00, 2.743829098862537e-02, 6.516951720000000e-06},
{ 3.954411167839586e+00, 1.357783808463605e+01, 1.013316988000000e-05},
{ 2.912659283736000e+00, 4.073351425390815e+01, 7.483468759999740e-06},
{ 4.067858164824586e+00, 2.589727369338346e-02, 2.589359390000000e-06},
{ 4.067857973471586e+00, 2.589727371473152e-02, 2.589359390000000e-06},
{ 3.776271613850000e-01, 3.658417828853650e-02, 1.125147420000000e-06},
{ 3.776267985560000e-01, 3.658417832900902e-02, 1.125147420000000e-06},
{ 4.121936540919586e+00, 4.685014091671000e+01, 1.523532599999976e-06},
{ 1.231836210236000e+00, 1.674957754399965e-02, 4.976512300000000e-07},
{ 1.231836585135000e+00, 1.674957750247454e-02, 4.976512300000000e-07},
{ 2.488083095032000e+00, 1.520386207875350e-02, 6.841966800000000e-07},
{ 2.488083105051000e+00, 1.520386207728190e-02, 6.841966800000000e-07},
{ 1.576141880659500e+00, 3.504321962396548e-02, 1.124868580000000e-06},
{ 6.145328698142587e+00, 6.116624841770791e+00, 7.873411399999999e-07},
{ 2.626756105114500e+00, 5.431135233854420e+01, 1.040645720000000e-06},
{ 2.759579551372000e+00, 7.461213382164448e+00, 7.166789399999994e-07},
{ 6.005673273380586e+00, 1.314187651672942e+00, 6.022022799999936e-07},
{ 9.353090056600000e-02, 7.532115638546220e-04, 1.964976900000000e-07},
{ 9.343265452500001e-02, 7.532237916966188e-04, 1.964964500000000e-07},
{ 4.302928383505000e-01, 3.938582557777600e+01, 4.987307800000000e-07},
{ 3.587439959397586e+00, 6.042797969706294e+01, 4.804827999999994e-07},
{ 4.183547650369587e+00, 4.572998195120822e-02, 1.747133100000000e-07},
{ 4.183547815926586e+00, 4.572998193123842e-02, 1.747133100000000e-07},
{ 5.589444380180586e+00, 6.068411018120026e-03, 1.793076700000000e-07},
{ 5.589445119118587e+00, 6.068410936124942e-03, 1.793076800000000e-07},
{ 6.062696336024086e+00, 3.938892673561993e+01, 2.653755199999908e-07},
{ 4.628882088425586e+00, 3.327230135427769e+01, 2.539272800000000e-07},
{ 4.736462571780586e+00, 1.224558395734566e-02, 1.254715400000000e-07},
{ 4.736462588777586e+00, 1.224558396211886e-02, 1.254715500000000e-07},
};
static const struct MarsSatTerm mars_sat_phobos_2[27] = {
{ 1.404382124885000e+00, 7.595588511174286e-03, 1.514110912521000e-02},
{ 2.088385523034000e+00, 1.970205682773437e+01, 3.849620867400000e-04},
{ 3.895377361148586e+00, 1.069670280268190e-02, 6.903413242000000e-05},
{ 4.989572094750000e-01,-7.605272825959576e-03, 4.946101994000000e-05},
{ 8.215210221720000e-01, 4.685772969905357e+01, 3.671320788000000e-05},
{ 3.378076387479586e+00,-7.453616316894914e+00, 3.267983782000000e-05},
{ 2.772375719698000e+00, 3.939651873194720e+01, 8.750483050000000e-06},
{ 2.839303414769000e+00, 6.124220388371520e+00, 6.170938810000000e-06},
{ 3.184503170430000e-01, 6.043556479692822e+01, 6.957005940000000e-06},
{ 1.410329121503000e+00, 1.984322681200531e-02, 5.734088960000000e-06},
{ 1.336796551701000e+00, 3.327989343110183e+01, 3.431577860000000e-06},
{ 3.853646745987586e+00,-2.103145308554279e+01, 4.037409510000000e-06},
{ 2.547067366210000e-01, 1.549665594221090e-03, 2.115296220000000e-06},
{ 1.508224230072000e+00,-5.911151611514718e+01, 1.904827620000000e-06},
{ 3.462607551400000e-02,-5.165030141480156e+01, 8.696094700000001e-07},
{ 3.198655314207586e+00, 9.152774292423818e-03, 6.329868200000000e-07},
{ 5.163954670916587e+00, 1.674080796065678e-02, 6.130317800000000e-07},
{ 7.803937436990001e-01,-1.550610622055567e-03, 5.737217100000000e-07},
{ 3.264588234538586e+00, 2.716326950857758e+01, 8.181088300000000e-07},
{ 5.211329429707586e+00, 2.898903518276566e-02, 7.434145300000000e-07},
{ 2.691716763490000e+00,-2.714807769012140e+01, 5.441647800000001e-07},
{ 1.001733960626000e+00,-4.553367734785991e+01, 5.044515000000000e-07},
{ 3.212149602842586e+00,-1.968376490094933e+01, 4.284599100000000e-07},
{ 4.145658249560586e+00,-3.460928932187340e+01, 4.224937300000000e-07},
{ 5.063879137454586e+00, 2.589097180762378e-02, 3.581949800000000e-07},
{ 2.558639707373000e+00, 6.064928432880783e-03, 3.808881200000000e-07},
{ 5.292499025555586e+00, 3.075065445597794e-03, 4.055739200000000e-07},
};
static const struct MarsSatTerm mars_sat_phobos_3[28] = {
{ 2.058107128488000e+00,-7.604861328578004e-03, 9.408605183120001e-03},
{ 3.248856489376586e+00,-9.145863467943084e-03, 5.699538102000000e-05},
{ 4.557280987272586e+00, 1.829238959116374e-02, 2.474369483000000e-05},
{ 6.113423353213586e+00, 7.595702278066188e-03, 2.062715397000000e-05},
{ 2.067112903892000e+00, 2.743825478464904e-02, 5.945574160000000e-06},
{ 1.700689207004000e+00, 9.145729527513899e-03, 3.920190200000000e-06},
{ 3.577059353926586e+00,-1.829259493439447e-02, 2.167791970000000e-06},
{ 4.940284891770586e+00,-7.453616316894914e+00, 2.769763750000000e-06},
{ 2.117885747855000e+00, 3.941171892425920e+01, 1.813539170000000e-06},
{ 5.176055365230000e-01, 1.970205682773437e+01, 1.083429180000000e-06},
{ 5.864248782211586e+00, 3.658425999636452e-02, 1.066657010000000e-06},
{ 3.240692168340586e+00, 2.589639535977297e-02, 7.909853300000000e-07},
{ 7.570751994340000e-01,-1.321790869500707e+00, 8.880885099999999e-07},
{ 6.608910796060000e-01, 6.124220388371520e+00, 5.520758900000000e-07},
{ 5.519669134088586e+00, 4.685772969905357e+01, 4.397886000000000e-07},
{ 5.844000094873587e+00,-2.743825733444110e-02, 4.583609100000000e-07},
{ 4.602755395228586e+00,-1.675516082048598e-02, 3.533131100000000e-07},
{ 2.685649467156000e+00, 1.542518910643103e-03, 3.502988500000000e-07},
{ 3.353176877809586e+00, 1.225604581839110e+01, 3.828851700000000e-07},
{ 2.556761413073000e+00, 1.068971473943851e-02, 2.711247300000000e-07},
{ 4.622369718415587e+00,-2.589682634097252e-02, 1.991745400000000e-07},
{ 7.497690894330000e-01, 3.504362063026418e-02, 2.036897700000000e-07},
{ 3.965205602751586e+00, 2.714806830611846e+01, 1.726779600000000e-07},
{ 4.743858488241586e+00,-2.103145308554279e+01, 1.574472800000000e-07},
{ 3.405870070134586e+00, 4.572380100924578e-02, 1.698567700000000e-07},
{ 3.742380849210000e-01, 3.327989343110183e+01, 1.015004500000000e-07},
{ 1.374678754422000e+00,-1.970206671243377e+01, 8.259149000000001e-08},
{ 1.140160118874000e+00,-6.108652383295211e-03, 8.504923000000000e-08},
};
static const struct MarsSatTerm mars_sat_deimos_0[25] = {
{ 6.146803530569087e+00, 2.294413036846356e+00, 5.398360000000000e-09},
{ 3.432805186019586e+00, 9.935735425667586e+00, 7.095199999999997e-10},
{ 4.356581106602587e+00, 3.441619555269535e+00, 3.737800000000000e-10},
{ 5.915601483397086e+00, 9.926589650598594e+00, 2.337400000000000e-10},
{ 1.615243161446500e+00, 1.147206518423178e+00, 1.697000000000000e-10},
{ 2.114704906116000e+00, 9.917443791162608e+00, 5.314000000000000e-11},
{ 9.393388277920001e-01, 9.954027953247621e+00, 7.046000000000000e-11},
{ 3.949494792732586e+00, 9.944881541450725e+00, 4.233999999999998e-11},
{ 2.089322391969500e+00, 2.294097606691310e+00, 2.813999999999983e-11},
{ 7.795571756865000e-01, 2.294728469946876e+00, 2.549999999999971e-11},
{ 2.361851669664500e+00, 4.588826073692712e+00, 1.676000000000000e-11},
{ 2.770026883578500e+00, 9.954658773106329e+00, 9.719999999999976e-12},
{ 4.733522308629587e+00, 9.963173826265681e+00, 9.519999999999998e-12},
{ 3.258781379519086e+00, 1.472506551307767e+01, 9.739999999999990e-12},
{ 4.596299836113586e+00, 9.908297894321850e+00, 1.027999999999982e-11},
{ 3.057688443346500e+00, 2.682916339604879e+00, 6.679999999999999e-12},
{ 5.453932177963086e+00, 2.682285947505377e+00, 6.879999999999995e-12},
{ 4.328499239462586e+00, 9.936050924435195e+00, 6.199999999999996e-12},
{ 2.168321380562000e+00, 4.976699000854042e+00, 5.359999999999916e-12},
{ 2.516818588675000e+00, 9.935420104041299e+00, 3.359999999999995e-12},
{ 2.997827070725000e-01, 3.441304123224338e+00, 2.919999999999998e-12},
{ 2.143143420462500e+00, 2.682600911813873e+00, 2.979999999999999e-12},
{ 5.271781540988586e+00, 3.441934988580561e+00, 2.640000000000000e-12},
{ 5.382287205930000e-01, 9.926905039305762e+00, 2.039999999999966e-12},
{ 3.629187963751586e+00, 2.303558945733479e+00, 1.090000000000000e-12},
};
static const struct MarsSatTerm mars_sat_deimos_1[21] = {
{ 2.488175621789000e+00, 3.153377646687549e-04, 2.483031660190000e-03},
{ 2.488175634463000e+00, 3.153377641537758e-04, 2.483031659490000e-03},
{ 5.336889264964586e+00, 9.145915158660612e-03, 2.023426978200000e-04},
{ 5.336889346905586e+00, 9.145915155199243e-03, 2.023426978200000e-04},
{ 5.281538321363586e+00, 1.829244561727392e-02, 1.043167564200000e-04},
{ 5.281537958522586e+00, 1.829244563271799e-02, 1.043167564300000e-04},
{ 1.433490018835500e+00, 2.294413045224799e+00, 1.637133774599998e-04},
{ 2.852007788337500e+00, 2.743818628432718e-02, 4.820671420000000e-05},
{ 3.147176604035586e+00, 1.860783561096842e-02, 1.497066023000000e-05},
{ 3.147176307034586e+00, 1.860783562364318e-02, 1.497066034000000e-05},
{ 3.182516662993086e+00, 1.147206709032795e+00, 1.137028858000000e-05},
{ 5.917181875408586e+00, 3.441620120814550e+00, 8.350609860000001e-06},
{ 3.544871659744586e+00, 6.190897763118658e-04, 4.383254500000000e-06},
{ 3.544871649035586e+00, 6.190897769462623e-04, 4.383254500000000e-06},
{ 4.614193383590000e-01, 3.657933964533806e-02, 8.316176420000001e-06},
{ 6.754222685930000e-01, 2.775277029221846e-02, 6.447587580000001e-06},
{ 5.001640850015586e+00, 9.935735582783501e+00, 7.926687400000001e-06},
{ 4.485218623803586e+00, 8.828439970895009e-03, 3.905064210000000e-06},
{ 2.221013917980000e-01, 9.464721723028089e-03, 3.117801830000000e-06},
{ 4.485218622895586e+00, 8.828439971019056e-03, 3.905064210000000e-06},
{ 2.221013944660000e-01, 9.464721722885516e-03, 3.117801830000000e-06},
};
static const struct MarsSatTerm mars_sat_deimos_2[15] = {
{ 2.198649419514000e+00, 3.148401892560942e-04, 2.744131534600000e-04},
{ 4.366636518977586e+00, 4.977013897776327e+00, 6.015912711000000e-05},
{ 1.463816210370000e+00,-3.153164045300449e-04, 3.614585349000000e-05},
{ 1.362467588064000e+00, 2.682600831640474e+00, 2.577157903000000e-05},
{ 9.336792893320000e-01,-4.958721582505435e+00, 6.801997830000000e-06},
{ 3.152432010349586e+00, 1.535394310272536e+00, 4.452979140000000e-06},
{ 4.734051493918586e+00,-4.949575691510945e+00, 2.238482920000000e-06},
{ 5.736642853342587e+00, 2.327524649334390e-04, 1.478747570000000e-06},
{ 5.084945958505586e+00, 3.912335403634271e-04, 1.457720040000000e-06},
{ 4.232770650771586e+00, 7.271426904652134e+00, 1.347030310000000e-06},
{ 1.516439412109000e+00, 1.491274947692300e+01, 7.538179100000001e-07},
{ 3.100618466711000e+00, 1.797748798526167e-02, 8.716229700000000e-07},
{ 5.155432690964586e+00, 3.881877876517008e-01, 1.015648880000000e-06},
{ 2.250768124831000e+00,-4.940429834739583e+00, 5.090989300000000e-07},
{ 3.422251338868586e+00,-4.977013897776327e+00, 6.537077500000000e-07},
};
static const struct MarsSatTerm mars_sat_deimos_3[27] = {
{ 2.981506933511000e+00,-3.154811355556041e-04, 1.562693319959000e-02},
{ 4.557500894366586e+00, 1.829233626168517e-02, 1.321818631100000e-04},
{ 3.248124065112586e+00,-9.145934570587952e-03, 3.833652719000000e-05},
{ 1.701551338710000e+00, 9.145665986601770e-03, 2.660211842000000e-05},
{ 2.067662003165000e+00, 2.743821959085702e-02, 2.882438535000000e-05},
{ 4.805327222478586e+00, 3.158564952832469e-04, 2.713213911000000e-05},
{ 2.318788380092000e+00, 1.860776219134252e-02, 9.296324950000000e-06},
{ 3.613580709014586e+00,-1.829261290520875e-02, 4.460960290000000e-06},
{ 5.867810550672586e+00, 3.658405181871115e-02, 4.904156030000000e-06},
{ 5.496515547817586e+00,-9.461294099629817e-03, 2.366881150000000e-06},
{ 6.120362957845586e+00, 2.775361064625920e-02, 2.057552850000000e-06},
{ 3.612389208841586e+00, 8.830178627407989e-03, 2.365750140000000e-06},
{ 5.554163001934587e+00,-1.860800990265229e-02, 1.228814440000000e-06},
{ 3.561485983136586e+00, 1.797648948023003e-02, 1.215189460000000e-06},
{ 1.941975361869000e+00,-6.259843043363501e-04, 9.602856999999999e-07},
{ 5.845244776759587e+00,-2.743819348074506e-02, 1.174471770000000e-06},
{ 1.821048365911000e+00, 9.460829229838182e-03, 1.058441050000000e-06},
{ 7.587135454610000e-01, 1.682177864988152e-04, 2.383147490000000e-06},
{ 3.292891638604586e+00, 4.573365667404765e-02, 7.656303800000000e-07},
{ 3.766016108380586e+00, 3.683140215171970e-02, 4.075824300000000e-07},
{ 5.747244956219586e+00, 9.954342722363187e+00, 4.962340900000000e-07},
{ 1.724062868741000e+00,-2.774017744689255e-02, 3.003077900000000e-07},
{ 4.429747729642586e+00,-8.904754331211824e-03, 2.613416400000000e-07},
{ 8.659161909010000e-01, 2.718340176462802e-02, 2.891945200000000e-07},
{ 2.259603352363000e+00,-3.660152378331885e-02, 2.213788200000000e-07},
{ 1.108725025984000e+00, 9.935732440466172e+00, 2.449950300000000e-07},
{ 1.680391963791000e+00, 9.076040299352415e-03, 1.652416600000000e-07},
};
static const struct MarsSatBody mars_sat_bodies[2] = {
{
9.549547741038312e-11, 1.970205562831390e+01, 1.657852042683113e-10,
{
0.0000626916188000,
2.0912973926417302,
-0.0000005774003141,
0.0000006185052790,
-0.0000575018919826,
-0.0000540676493351,
},
{
{mars_sat_phobos_0, 16},
{mars_sat_phobos_1, 42},
{mars_sat_phobos_2, 27},
{mars_sat_phobos_3, 28}
}
},
{
9.549547622768120e-11, 4.977013889652300e+00, -2.331793571572226e-14,
{
0.0001568134086700,
-1.9167537810740201,
-0.0000239579419518,
0.0000257893300229,
-0.0056443721379635,
-0.0053121806978560,
},
{
{mars_sat_deimos_0, 25},
{mars_sat_deimos_1, 21},
{mars_sat_deimos_2, 15},
{mars_sat_deimos_3, 27}
}
},
};
static
void CalcMarsSatElem(double t, int body, double elem[6]) {
int j;
const struct MarsSatBody *bp = mars_sat_bodies + body;
memcpy(elem, bp->constants, 6 * sizeof(double));
for (j = 0; j < 2; j++) {
const struct MarsSatTerm *const begin = bp->lists[j].terms;
const struct MarsSatTerm *p = begin + bp->lists[j].size;
while (--p >= begin) {
const double d = p->phase + t * p->frequency;
elem[j] += p->amplitude * cos(d);
}
}
for (j = 2; j < 4; j++) {
const struct MarsSatTerm *const begin = bp->lists[j].terms;
const struct MarsSatTerm *p = begin + bp->lists[j].size;
while (--p >= begin) {
const double d = p->phase + t * p->frequency;
elem[2*j-2] += p->amplitude * cos(d);
elem[2*j-1] += p->amplitude * sin(d);
}
}
elem[1] += (bp->l + bp->acc * t) * t;
}
static
void MultMat(const double a[9], const double b[9], double c[9]) {
int i, j;
for (i = 0; i < 3; i++) {
for (j = 0; j < 3; j++) {
c[i*3+j] = a[i*3]*b[j] + a[i*3+1]*b[3+j] + a[i*3+2]*b[6+j];
}
}
}
static const double J2000_to_VSOP87[9] = {
9.999999999998848e-01,-4.799655442984222e-07, 0.000000000000000e+00,
4.403598133110236e-07, 9.174821370868568e-01, 3.977769829016507e-01,
-1.909192461077750e-07,-3.977769829016049e-01, 9.174821370869625e-01};
static const double ome0 = 47.68143;
static const double inc0 = 37.1135;
static const double dome = -0.1061;
static const double dinc = 0.0609;
static
void GenerateMarsSatToVSOP87(double t, double mat[9]) {
double ome, inc, co, so, ci, si;
double m[9];
t -= 6491.5;
ome = (ome0 + dome * t / 36525.) * (M_PI / 180.0);
inc = (inc0 + dinc * t / 36525.) * (M_PI / 180.0);
co = cos(ome);
so = sin(ome);
ci = cos(inc);
si = sin(inc);
m[0] = co; m[1] = -ci*so; m[2] = si*so;
m[3] = so; m[4] = ci*co; m[5] = -si*co;
m[6] = 0.0; m[7] = si; m[8] = ci;
MultMat(J2000_to_VSOP87, m, mat);
}
void GetMarsSatCoor(double jd, int body, double *xyz, double *xyzdot) {
double elem[6];
double x[6];
double mat[9];
const double t = jd - 2451545.0 + 6491.5;
CalcMarsSatElem(t, body, elem);
GenerateMarsSatToVSOP87(t, mat);
EllipticToRectangularA(mars_sat_bodies[body].mu, elem, 0.0, x);
xyz[0] = mat[0]*x[0] + mat[1]*x[1] + mat[2]*x[2];
xyz[1] = mat[3]*x[0] + mat[4]*x[1] + mat[5]*x[2];
xyz[2] = mat[6]*x[0] + mat[7]*x[1] + mat[8]*x[2];
if (xyzdot) {
xyzdot[0] = mat[0]*x[3] + mat[1]*x[4] + mat[2]*x[5];
xyzdot[1] = mat[3]*x[3] + mat[4]*x[4] + mat[5]*x[5];
xyzdot[2] = mat[6]*x[3] + mat[7]*x[4] + mat[8]*x[5];
}
}

81
src/marssat.h Normal file
View File

@ -0,0 +1,81 @@
/************************************************************************
The Ephemerides of the Martian satellites
(adjustement from 1877 to 2005, Version 1.0)
by Valery Lainey can be obtained from Valery Lainey:
V.Lainey (Lainey@oma.be)
ROB- 3, Avenue Circulaire, B-1180 Bruxelles (Belgium)
IMCCE - 77, Avenue Denfert-Rochereau 75014 Paris (France)
-----------------------------------------------------------------------
I (Johannes Gajdosik) have just taken Valery Laineys Fortran code,
MarsSatV1-0.f, which he kindly supplied, and rearranged it into
this piece of software.
I can neither allow nor forbid the usage of Valery Laineys
Ephemerides of the Martian satellites.
The copyright notice below covers not the work of Valery Lainey
but just my work, that is the compilation of Valery Laineys
Ephemerides of the Martian satellites into the software supplied in this file.
Copyright (c) 2006 Johannes Gajdosik
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Derived from Stellarium's MARSSAT implementation.
Modified for pg_orrery: removed static mutable state and
CalcInterpolatedElements caching for thread safety
(PostgreSQL PARALLEL SAFE). Elements are computed fresh on each call.
1) do not calculate constant terms at runtime but beforehand
2) unite terms with the same frequencies
****************************************************************/
#ifndef PG_ORRERY_MARSSAT_H
#define PG_ORRERY_MARSSAT_H
#ifdef __cplusplus
extern "C" {
#endif
#define MARS_SAT_PHOBOS 0
#define MARS_SAT_DEIMOS 1
void GetMarsSatCoor(double jd, int body, double *xyz, double *xyzdot);
/* Return the rectangular coordinates and velocity of the given satellite
and the given julian date jd expressed in dynamical time (TAI+32.184s).
The origin of the xyz-coordinates is the center of Mars.
The reference frame is "dynamical equinox and ecliptic J2000",
which is the reference frame in VSOP87 and VSOP87A.
body: 0=Phobos, 1=Deimos
xyz[3]: position (AU), relative to Mars center
xyzdot[3]: velocity (AU/day), may be NULL
*/
#ifdef __cplusplus
}
#endif
#endif

220
src/moon_funcs.c Normal file
View File

@ -0,0 +1,220 @@
/*
* moon_funcs.c -- Planetary moon observation
*
* SQL functions for observing moons of Jupiter, Saturn, Uranus, and Mars.
* Each moon's position is computed in the VSOP87 ecliptic J2000 frame
* relative to its parent planet, then combined with the parent's
* heliocentric position to get geocentric az/el.
*
* Pipeline for each moon:
* 1. Parent planet heliocentric position (VSOP87)
* 2. Moon position relative to parent (L12/TASS17/GUST86/MARSSAT)
* 3. Moon heliocentric = parent + moon_relative
* 4. Moon geocentric = moon_heliocentric - Earth_heliocentric
* 5. Ecliptic -> equatorial -> precess -> az/el
*/
#include "postgres.h"
#include "fmgr.h"
#include "funcapi.h"
#include "utils/timestamp.h"
#include "types.h"
#include "astro_math.h"
#include "vsop87.h"
#include "l12.h"
#include "tass17.h"
#include "gust86.h"
#include "marssat.h"
#include <math.h>
PG_FUNCTION_INFO_V1(galilean_observe);
PG_FUNCTION_INFO_V1(saturn_moon_observe);
PG_FUNCTION_INFO_V1(uranus_moon_observe);
PG_FUNCTION_INFO_V1(mars_moon_observe);
/*
* observe_from_geocentric() is now in astro_math.h as a static inline,
* shared by planet_funcs.c, moon_funcs.c, and de_funcs.c.
*/
/* ================================================================
* Internal: common pattern for all planetary moons
*
* Given: moon position relative to parent (VSOP87 ecliptic J2000, AU)
* parent's VSOP87 body index (0-based)
* observer, JD
*
* Computes geocentric position and returns topocentric az/el.
* ================================================================
*/
static void
observe_planetary_moon(const double moon_rel[3], int vsop_parent,
double jd, const pg_observer *obs,
pg_topocentric *result)
{
double parent_xyz[6];
double earth_xyz[6];
double geo_ecl[3];
/* Parent planet heliocentric */
GetVsop87Coor(jd, vsop_parent, parent_xyz);
/* Earth heliocentric */
GetVsop87Coor(jd, 2, earth_xyz); /* VSOP87 body 2 = Earth */
/* Moon geocentric = (parent + moon_relative) - Earth */
geo_ecl[0] = (parent_xyz[0] + moon_rel[0]) - earth_xyz[0];
geo_ecl[1] = (parent_xyz[1] + moon_rel[1]) - earth_xyz[1];
geo_ecl[2] = (parent_xyz[2] + moon_rel[2]) - earth_xyz[2];
observe_from_geocentric(geo_ecl, jd, obs, result);
}
/* ================================================================
* galilean_observe(body_id int, observer, timestamptz) -> topocentric
*
* Observe a Galilean moon of Jupiter.
* Body IDs: 0=Io, 1=Europa, 2=Ganymede, 3=Callisto
*
* Uses L1.2 theory (Lainey, Duriez & Vienne) for moon positions
* and VSOP87 for Jupiter and Earth.
* ================================================================
*/
Datum
galilean_observe(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 moon_xyz[3];
pg_topocentric *result;
if (body_id < L12_IO || body_id > L12_CALLISTO)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("galilean_observe: body_id %d must be 0-3 (Io, Europa, Ganymede, Callisto)",
body_id)));
jd = timestamptz_to_jd(ts);
/* Moon position relative to Jupiter, VSOP87 ecliptic J2000, AU */
GetL12Coor(jd, body_id, moon_xyz, NULL);
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_planetary_moon(moon_xyz, 4, jd, obs, result); /* VSOP87 body 4 = Jupiter */
PG_RETURN_POINTER(result);
}
/* ================================================================
* saturn_moon_observe(body_id int, observer, timestamptz) -> topocentric
*
* Observe a moon of Saturn.
* Body IDs: 0=Mimas, 1=Enceladus, 2=Tethys, 3=Dione,
* 4=Rhea, 5=Titan, 6=Iapetus, 7=Hyperion
*
* Uses TASS 1.7 theory (Vienne & Duriez).
* ================================================================
*/
Datum
saturn_moon_observe(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 moon_xyz[3];
pg_topocentric *result;
if (body_id < TASS17_MIMAS || body_id > TASS17_HYPERION)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("saturn_moon_observe: body_id %d must be 0-7 (Mimas-Hyperion)",
body_id)));
jd = timestamptz_to_jd(ts);
GetTass17Coor(jd, body_id, moon_xyz, NULL);
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_planetary_moon(moon_xyz, 5, jd, obs, result); /* VSOP87 body 5 = Saturn */
PG_RETURN_POINTER(result);
}
/* ================================================================
* uranus_moon_observe(body_id int, observer, timestamptz) -> topocentric
*
* Observe a moon of Uranus.
* Body IDs: 0=Miranda, 1=Ariel, 2=Umbriel, 3=Titania, 4=Oberon
*
* Uses GUST86 theory (Laskar & Jacobson).
* ================================================================
*/
Datum
uranus_moon_observe(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 moon_xyz[3];
pg_topocentric *result;
if (body_id < GUST86_MIRANDA || body_id > GUST86_OBERON)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("uranus_moon_observe: body_id %d must be 0-4 (Miranda-Oberon)",
body_id)));
jd = timestamptz_to_jd(ts);
GetGust86Coor(jd, body_id, moon_xyz, NULL);
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_planetary_moon(moon_xyz, 6, jd, obs, result); /* VSOP87 body 6 = Uranus */
PG_RETURN_POINTER(result);
}
/* ================================================================
* mars_moon_observe(body_id int, observer, timestamptz) -> topocentric
*
* Observe a moon of Mars.
* Body IDs: 0=Phobos, 1=Deimos
*
* Uses MarsSat theory (Lainey, 2007).
* ================================================================
*/
Datum
mars_moon_observe(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 moon_xyz[3];
pg_topocentric *result;
if (body_id < MARS_SAT_PHOBOS || body_id > MARS_SAT_DEIMOS)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("mars_moon_observe: body_id %d must be 0-1 (Phobos, Deimos)",
body_id)));
jd = timestamptz_to_jd(ts);
GetMarsSatCoor(jd, body_id, moon_xyz, NULL);
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_planetary_moon(moon_xyz, 3, jd, obs, result); /* VSOP87 body 3 = Mars */
PG_RETURN_POINTER(result);
}

701
src/od_funcs.c Normal file
View File

@ -0,0 +1,701 @@
/*
* od_funcs.c -- SQL bindings for TLE fitting functions
*
* Exposes od_solver.c to PostgreSQL as SQL-callable functions:
* tle_from_eci() -- fit TLE from ECI ephemeris
* tle_from_topocentric() -- fit TLE from topocentric observations
* tle_fit_residuals() -- per-observation residuals diagnostic
*
* Placeholder: full implementation in Commit 3.
*/
#include "postgres.h"
#include "fmgr.h"
#include "funcapi.h"
#include "utils/timestamp.h"
#include "utils/array.h"
#include "utils/builtins.h"
#include "catalog/pg_type.h"
#include "norad.h"
#include "types.h"
#include "od_solver.h"
#include "od_math.h"
#include <math.h>
#include <string.h>
/* For construct_array() — covariance output */
#include "utils/lsyscache.h"
PG_FUNCTION_INFO_V1(tle_from_eci);
PG_FUNCTION_INFO_V1(tle_from_topocentric);
PG_FUNCTION_INFO_V1(tle_from_topocentric_multi);
PG_FUNCTION_INFO_V1(tle_fit_residuals);
/* ================================================================
* Helper: pg_tle tle_t conversion (local copy, avoids symbol coupling)
* ================================================================
*/
static void
pg_tle_to_sat_code_od(const pg_tle *src, tle_t *dst)
{
memset(dst, 0, sizeof(tle_t));
dst->epoch = src->epoch;
dst->xincl = src->inclination;
dst->xnodeo = src->raan;
dst->eo = src->eccentricity;
dst->omegao = src->arg_perigee;
dst->xmo = src->mean_anomaly;
dst->xno = src->mean_motion;
dst->xndt2o = src->mean_motion_dot;
dst->xndd6o = src->mean_motion_ddot;
dst->bstar = src->bstar;
dst->norad_number = src->norad_id;
dst->bulletin_number = src->elset_num;
dst->revolution_number = src->rev_num;
dst->classification = src->classification;
dst->ephemeris_type = src->ephemeris_type;
memcpy(dst->intl_desig, src->intl_desig, 9);
}
static void
sat_code_to_pg_tle(const tle_t *src, pg_tle *dst)
{
memset(dst, 0, sizeof(pg_tle));
dst->epoch = src->epoch;
dst->inclination = src->xincl;
dst->raan = src->xnodeo;
dst->eccentricity = src->eo;
dst->arg_perigee = src->omegao;
dst->mean_anomaly = src->xmo;
dst->mean_motion = src->xno;
dst->mean_motion_dot = src->xndt2o;
dst->mean_motion_ddot = src->xndd6o;
dst->bstar = src->bstar;
dst->norad_id = src->norad_number;
dst->elset_num = src->bulletin_number;
dst->rev_num = src->revolution_number;
dst->classification = src->classification;
dst->ephemeris_type = src->ephemeris_type;
memcpy(dst->intl_desig, src->intl_desig, 9);
}
/* ================================================================
* tle_from_eci(eci_position[], timestamptz[], tle, boolean, int4)
* -> RECORD (fitted_tle tle, iterations int4, rms_final float8,
* rms_initial float8, status text)
*
* Fit a TLE from an array of ECI position/velocity observations.
* ================================================================
*/
Datum
tle_from_eci(PG_FUNCTION_ARGS)
{
ArrayType *pos_arr = PG_GETARG_ARRAYTYPE_P(0);
ArrayType *time_arr = PG_GETARG_ARRAYTYPE_P(1);
bool has_seed = !PG_ARGISNULL(2);
pg_tle *seed_pg = has_seed ? (pg_tle *) PG_GETARG_POINTER(2) : NULL;
bool fit_bstar = PG_ARGISNULL(3) ? false : PG_GETARG_BOOL(3);
int32 max_iter = PG_ARGISNULL(4) ? 15 : PG_GETARG_INT32(4);
int n_pos, n_times;
Datum *pos_datums, *time_datums;
bool *pos_nulls, *time_nulls;
od_observation_t *obs;
od_config_t config;
od_result_t result;
tle_t seed_sat;
int i, rc;
TupleDesc tupdesc;
Datum values[8];
bool nulls[8];
HeapTuple tuple;
/* Deconstruct arrays.
* pg_eci is 48 bytes = pass-by-reference (byval=false).
* timestamptz is int64 = pass-by-value on 64-bit. */
deconstruct_array(pos_arr, pos_arr->elemtype, sizeof(pg_eci), false,
TYPALIGN_DOUBLE, &pos_datums, &pos_nulls, &n_pos);
deconstruct_array(time_arr, TIMESTAMPTZOID, sizeof(int64), FLOAT8PASSBYVAL,
TYPALIGN_DOUBLE, &time_datums, &time_nulls, &n_times);
if (n_pos != n_times)
ereport(ERROR,
(errcode(ERRCODE_ARRAY_SUBSCRIPT_ERROR),
errmsg("positions and times arrays must have same length: "
"%d vs %d", n_pos, n_times)));
if (n_pos < 6)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("at least 6 observations required, got %d", n_pos)));
/* Build observation array */
obs = (od_observation_t *) palloc(sizeof(od_observation_t) * n_pos);
for (i = 0; i < n_pos; i++)
{
pg_eci *eci = (pg_eci *) DatumGetPointer(pos_datums[i]);
int64 ts = DatumGetInt64(time_datums[i]);
double jd = PG_EPOCH_JD + ((double)ts / (double)USECS_PER_DAY);
obs[i].jd = jd;
obs[i].data[0] = eci->x;
obs[i].data[1] = eci->y;
obs[i].data[2] = eci->z;
obs[i].data[3] = eci->vx; /* already km/s */
obs[i].data[4] = eci->vy;
obs[i].data[5] = eci->vz;
}
/* Configure solver */
memset(&config, 0, sizeof(config));
config.obs_type = OD_OBS_ECI;
config.fit_bstar = fit_bstar ? 1 : 0;
config.max_iter = max_iter;
config.observers = NULL;
config.n_observers = 0;
/* Convert seed TLE if provided */
if (has_seed)
pg_tle_to_sat_code_od(seed_pg, &seed_sat);
memset(&result, 0, sizeof(result));
/* Run solver */
rc = od_fit_tle(obs, n_pos, has_seed ? &seed_sat : NULL, &config, &result);
pfree(obs);
if (rc != 0)
ereport(ERROR,
(errcode(ERRCODE_DATA_EXCEPTION),
errmsg("TLE fitting failed: %s", result.status)));
/* Build result tuple */
if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("function returning record called in context "
"that cannot accept type record")));
tupdesc = BlessTupleDesc(tupdesc);
memset(nulls, 0, sizeof(nulls));
{
pg_tle *fitted = (pg_tle *) palloc(sizeof(pg_tle));
sat_code_to_pg_tle(&result.fitted_tle, fitted);
values[0] = PointerGetDatum(fitted);
}
values[1] = Int32GetDatum(result.iterations);
values[2] = Float8GetDatum(result.rms_final);
values[3] = Float8GetDatum(result.rms_initial);
values[4] = CStringGetTextDatum(result.status);
values[5] = Float8GetDatum(result.condition_number);
/* Covariance: float8[] or NULL */
if (result.cov_size > 0)
{
Datum *cov_datums = (Datum *) palloc(sizeof(Datum) * result.cov_size);
int ci;
for (ci = 0; ci < result.cov_size; ci++)
cov_datums[ci] = Float8GetDatum(result.covariance[ci]);
values[6] = PointerGetDatum(
construct_array(cov_datums, result.cov_size,
FLOAT8OID, sizeof(float8), FLOAT8PASSBYVAL,
TYPALIGN_DOUBLE));
}
else
{
nulls[6] = true;
}
values[7] = Int32GetDatum(result.cov_size > 0
? (result.cov_size == 28 ? 7 : 6)
: 0);
tuple = heap_form_tuple(tupdesc, values, nulls);
PG_RETURN_DATUM(HeapTupleGetDatum(tuple));
}
/* ================================================================
* tle_from_topocentric(topocentric[], timestamptz[], observer,
* tle, boolean, int4)
* -> RECORD (same as tle_from_eci)
* ================================================================
*/
Datum
tle_from_topocentric(PG_FUNCTION_ARGS)
{
ArrayType *topo_arr = PG_GETARG_ARRAYTYPE_P(0);
ArrayType *time_arr = PG_GETARG_ARRAYTYPE_P(1);
pg_observer *obs_pg = (pg_observer *) PG_GETARG_POINTER(2);
bool has_seed = !PG_ARGISNULL(3);
pg_tle *seed_pg = has_seed ? (pg_tle *) PG_GETARG_POINTER(3) : NULL;
bool fit_bstar = PG_ARGISNULL(4) ? false : PG_GETARG_BOOL(4);
int32 max_iter = PG_ARGISNULL(5) ? 15 : PG_GETARG_INT32(5);
int n_topo, n_times;
od_observation_t *obs;
od_config_t config;
od_observer_t observer;
od_result_t result;
tle_t seed_sat;
int i, rc;
TupleDesc tupdesc;
Datum values[8];
bool nulls[8];
HeapTuple tuple;
/* Build observer */
observer.lat = obs_pg->lat;
observer.lon = obs_pg->lon;
observer.alt_m = obs_pg->alt_m;
od_observer_to_ecef(observer.lat, observer.lon, observer.alt_m,
observer.ecef);
/* Deconstruct arrays */
{
Datum *topo_datums;
bool *topo_nulls;
Datum *time_datums;
bool *time_nulls;
deconstruct_array(topo_arr, topo_arr->elemtype, sizeof(pg_topocentric),
false, TYPALIGN_DOUBLE,
&topo_datums, &topo_nulls, &n_topo);
deconstruct_array(time_arr, TIMESTAMPTZOID, sizeof(int64), FLOAT8PASSBYVAL,
TYPALIGN_DOUBLE, &time_datums, &time_nulls, &n_times);
if (n_topo != n_times)
ereport(ERROR,
(errcode(ERRCODE_ARRAY_SUBSCRIPT_ERROR),
errmsg("observations and times arrays must have same length: "
"%d vs %d", n_topo, n_times)));
if (n_topo < 6)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("at least 6 observations required, got %d", n_topo)));
obs = (od_observation_t *) palloc(sizeof(od_observation_t) * n_topo);
for (i = 0; i < n_topo; i++)
{
pg_topocentric *topo = (pg_topocentric *) DatumGetPointer(topo_datums[i]);
int64 ts = DatumGetInt64(time_datums[i]);
double jd = PG_EPOCH_JD + ((double)ts / (double)USECS_PER_DAY);
obs[i].jd = jd;
obs[i].data[0] = topo->azimuth;
obs[i].data[1] = topo->elevation;
obs[i].data[2] = topo->range_km;
obs[i].observer_idx = 0; /* single observer */
}
}
/* Configure solver */
memset(&config, 0, sizeof(config));
config.obs_type = OD_OBS_TOPO;
config.fit_bstar = fit_bstar ? 1 : 0;
config.max_iter = max_iter;
config.observers = &observer;
config.n_observers = 1;
if (has_seed)
pg_tle_to_sat_code_od(seed_pg, &seed_sat);
memset(&result, 0, sizeof(result));
rc = od_fit_tle(obs, n_topo, has_seed ? &seed_sat : NULL, &config, &result);
pfree(obs);
if (rc != 0)
ereport(ERROR,
(errcode(ERRCODE_DATA_EXCEPTION),
errmsg("TLE fitting failed: %s", result.status)));
/* Build result tuple */
if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("function returning record called in context "
"that cannot accept type record")));
tupdesc = BlessTupleDesc(tupdesc);
memset(nulls, 0, sizeof(nulls));
{
pg_tle *fitted = (pg_tle *) palloc(sizeof(pg_tle));
sat_code_to_pg_tle(&result.fitted_tle, fitted);
values[0] = PointerGetDatum(fitted);
}
values[1] = Int32GetDatum(result.iterations);
values[2] = Float8GetDatum(result.rms_final);
values[3] = Float8GetDatum(result.rms_initial);
values[4] = CStringGetTextDatum(result.status);
values[5] = Float8GetDatum(result.condition_number);
if (result.cov_size > 0)
{
Datum *cov_datums = (Datum *) palloc(sizeof(Datum) * result.cov_size);
int ci;
for (ci = 0; ci < result.cov_size; ci++)
cov_datums[ci] = Float8GetDatum(result.covariance[ci]);
values[6] = PointerGetDatum(
construct_array(cov_datums, result.cov_size,
FLOAT8OID, sizeof(float8), FLOAT8PASSBYVAL,
TYPALIGN_DOUBLE));
}
else
{
nulls[6] = true;
}
values[7] = Int32GetDatum(result.cov_size > 0
? (result.cov_size == 28 ? 7 : 6)
: 0);
tuple = heap_form_tuple(tupdesc, values, nulls);
PG_RETURN_DATUM(HeapTupleGetDatum(tuple));
}
/* ================================================================
* tle_from_topocentric_multi(topocentric[], timestamptz[],
* observer[], int4[],
* tle, boolean, int4)
* -> RECORD (same as tle_from_eci)
*
* Multi-observer variant: observations from different ground stations.
* observer_ids[i] indexes into the observers[] array.
* ================================================================
*/
Datum
tle_from_topocentric_multi(PG_FUNCTION_ARGS)
{
ArrayType *topo_arr = PG_GETARG_ARRAYTYPE_P(0);
ArrayType *time_arr = PG_GETARG_ARRAYTYPE_P(1);
ArrayType *obs_arr = PG_GETARG_ARRAYTYPE_P(2);
ArrayType *id_arr = PG_GETARG_ARRAYTYPE_P(3);
bool has_seed = !PG_ARGISNULL(4);
pg_tle *seed_pg = has_seed ? (pg_tle *) PG_GETARG_POINTER(4) : NULL;
bool fit_bstar = PG_ARGISNULL(5) ? false : PG_GETARG_BOOL(5);
int32 max_iter = PG_ARGISNULL(6) ? 15 : PG_GETARG_INT32(6);
int n_topo, n_times, n_obs_sites, n_ids;
od_observation_t *obs;
od_config_t config;
od_observer_t *observers;
od_result_t result;
tle_t seed_sat;
int i, rc;
TupleDesc tupdesc;
Datum values[8];
bool nulls[8];
HeapTuple tuple;
/* Deconstruct all arrays */
Datum *topo_datums, *time_datums, *obs_datums, *id_datums;
bool *topo_nulls, *time_nulls, *obs_nulls, *id_nulls;
deconstruct_array(topo_arr, topo_arr->elemtype, sizeof(pg_topocentric),
false, TYPALIGN_DOUBLE,
&topo_datums, &topo_nulls, &n_topo);
deconstruct_array(time_arr, TIMESTAMPTZOID, sizeof(int64), FLOAT8PASSBYVAL,
TYPALIGN_DOUBLE, &time_datums, &time_nulls, &n_times);
deconstruct_array(obs_arr, obs_arr->elemtype, sizeof(pg_observer),
false, TYPALIGN_DOUBLE,
&obs_datums, &obs_nulls, &n_obs_sites);
deconstruct_array(id_arr, INT4OID, sizeof(int32), true,
TYPALIGN_INT, &id_datums, &id_nulls, &n_ids);
if (n_topo != n_times)
ereport(ERROR,
(errcode(ERRCODE_ARRAY_SUBSCRIPT_ERROR),
errmsg("observations and times arrays must have same length: "
"%d vs %d", n_topo, n_times)));
if (n_topo != n_ids)
ereport(ERROR,
(errcode(ERRCODE_ARRAY_SUBSCRIPT_ERROR),
errmsg("observations and observer_ids arrays must have same length: "
"%d vs %d", n_topo, n_ids)));
if (n_topo < 6)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("at least 6 observations required, got %d", n_topo)));
if (n_obs_sites < 1)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("at least 1 observer required")));
/* Build observer array (pre-compute ECEF for each) */
observers = (od_observer_t *) palloc(sizeof(od_observer_t) * n_obs_sites);
for (i = 0; i < n_obs_sites; i++)
{
pg_observer *op = (pg_observer *) DatumGetPointer(obs_datums[i]);
observers[i].lat = op->lat;
observers[i].lon = op->lon;
observers[i].alt_m = op->alt_m;
od_observer_to_ecef(op->lat, op->lon, op->alt_m, observers[i].ecef);
}
/* Build observation array with per-observation observer index */
obs = (od_observation_t *) palloc(sizeof(od_observation_t) * n_topo);
for (i = 0; i < n_topo; i++)
{
pg_topocentric *topo = (pg_topocentric *) DatumGetPointer(topo_datums[i]);
int64 ts = DatumGetInt64(time_datums[i]);
int32 oid = DatumGetInt32(id_datums[i]);
if (oid < 0 || oid >= n_obs_sites)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("observer_id %d out of range [0, %d)",
oid, n_obs_sites)));
obs[i].jd = PG_EPOCH_JD + ((double)ts / (double)USECS_PER_DAY);
obs[i].data[0] = topo->azimuth;
obs[i].data[1] = topo->elevation;
obs[i].data[2] = topo->range_km;
obs[i].observer_idx = oid;
}
/* Configure solver */
memset(&config, 0, sizeof(config));
config.obs_type = OD_OBS_TOPO;
config.fit_bstar = fit_bstar ? 1 : 0;
config.max_iter = max_iter;
config.observers = observers;
config.n_observers = n_obs_sites;
if (has_seed)
pg_tle_to_sat_code_od(seed_pg, &seed_sat);
memset(&result, 0, sizeof(result));
rc = od_fit_tle(obs, n_topo, has_seed ? &seed_sat : NULL, &config, &result);
pfree(obs);
pfree(observers);
if (rc != 0)
ereport(ERROR,
(errcode(ERRCODE_DATA_EXCEPTION),
errmsg("TLE fitting failed: %s", result.status)));
/* Build result tuple */
if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("function returning record called in context "
"that cannot accept type record")));
tupdesc = BlessTupleDesc(tupdesc);
memset(nulls, 0, sizeof(nulls));
{
pg_tle *fitted = (pg_tle *) palloc(sizeof(pg_tle));
sat_code_to_pg_tle(&result.fitted_tle, fitted);
values[0] = PointerGetDatum(fitted);
}
values[1] = Int32GetDatum(result.iterations);
values[2] = Float8GetDatum(result.rms_final);
values[3] = Float8GetDatum(result.rms_initial);
values[4] = CStringGetTextDatum(result.status);
values[5] = Float8GetDatum(result.condition_number);
if (result.cov_size > 0)
{
Datum *cov_datums = (Datum *) palloc(sizeof(Datum) * result.cov_size);
int ci;
for (ci = 0; ci < result.cov_size; ci++)
cov_datums[ci] = Float8GetDatum(result.covariance[ci]);
values[6] = PointerGetDatum(
construct_array(cov_datums, result.cov_size,
FLOAT8OID, sizeof(float8), FLOAT8PASSBYVAL,
TYPALIGN_DOUBLE));
}
else
{
nulls[6] = true;
}
values[7] = Int32GetDatum(result.cov_size > 0
? (result.cov_size == 28 ? 7 : 6)
: 0);
tuple = heap_form_tuple(tupdesc, values, nulls);
PG_RETURN_DATUM(HeapTupleGetDatum(tuple));
}
/* ================================================================
* tle_fit_residuals(tle, eci_position[], timestamptz[])
* -> TABLE (t timestamptz, dx_km float8, dy_km float8, dz_km float8,
* pos_err_km float8)
*
* Compute per-observation residuals between a TLE and ECI observations.
* ================================================================
*/
Datum
tle_fit_residuals(PG_FUNCTION_ARGS)
{
FuncCallContext *funcctx;
if (SRF_IS_FIRSTCALL())
{
MemoryContext oldctx;
pg_tle *tle;
ArrayType *pos_arr;
ArrayType *time_arr;
int n_pos, n_times;
Datum *pos_datums;
bool *pos_nulls;
Datum *time_datums;
bool *time_nulls;
TupleDesc tupdesc;
/* Context for residual data (persists across calls) */
typedef struct {
tle_t sat;
double *params;
int is_deep;
int n_obs;
double *jds; /* Julian dates */
double *obs_pos; /* observed positions (3 * n_obs) */
} residual_ctx;
residual_ctx *ctx;
int i;
funcctx = SRF_FIRSTCALL_INIT();
oldctx = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
tle = (pg_tle *) PG_GETARG_POINTER(0);
pos_arr = PG_GETARG_ARRAYTYPE_P(1);
time_arr = PG_GETARG_ARRAYTYPE_P(2);
deconstruct_array(pos_arr, pos_arr->elemtype, sizeof(pg_eci), false,
TYPALIGN_DOUBLE, &pos_datums, &pos_nulls, &n_pos);
deconstruct_array(time_arr, TIMESTAMPTZOID, sizeof(int64), FLOAT8PASSBYVAL,
TYPALIGN_DOUBLE, &time_datums, &time_nulls, &n_times);
if (n_pos != n_times)
ereport(ERROR,
(errcode(ERRCODE_ARRAY_SUBSCRIPT_ERROR),
errmsg("positions and times arrays must have same length")));
ctx = (residual_ctx *) palloc0(sizeof(residual_ctx));
pg_tle_to_sat_code_od(tle, &ctx->sat);
ctx->is_deep = select_ephemeris(&ctx->sat);
if (ctx->is_deep < 0)
ereport(ERROR,
(errcode(ERRCODE_DATA_EXCEPTION),
errmsg("invalid TLE for residual computation")));
ctx->params = (double *) palloc(sizeof(double) * N_SAT_PARAMS);
if (ctx->is_deep)
SDP4_init(ctx->params, &ctx->sat);
else
SGP4_init(ctx->params, &ctx->sat);
ctx->n_obs = n_pos;
ctx->jds = (double *) palloc(sizeof(double) * n_pos);
ctx->obs_pos = (double *) palloc(sizeof(double) * 3 * n_pos);
for (i = 0; i < n_pos; i++)
{
pg_eci *eci = (pg_eci *) DatumGetPointer(pos_datums[i]);
int64 ts = DatumGetInt64(time_datums[i]);
ctx->jds[i] = PG_EPOCH_JD + ((double)ts / (double)USECS_PER_DAY);
ctx->obs_pos[i * 3 + 0] = eci->x;
ctx->obs_pos[i * 3 + 1] = eci->y;
ctx->obs_pos[i * 3 + 2] = eci->z;
}
funcctx->max_calls = n_pos;
funcctx->user_fctx = ctx;
tupdesc = CreateTemplateTupleDesc(5);
TupleDescInitEntry(tupdesc, 1, "t", TIMESTAMPTZOID, -1, 0);
TupleDescInitEntry(tupdesc, 2, "dx_km", FLOAT8OID, -1, 0);
TupleDescInitEntry(tupdesc, 3, "dy_km", FLOAT8OID, -1, 0);
TupleDescInitEntry(tupdesc, 4, "dz_km", FLOAT8OID, -1, 0);
TupleDescInitEntry(tupdesc, 5, "pos_err_km", FLOAT8OID, -1, 0);
funcctx->tuple_desc = BlessTupleDesc(tupdesc);
MemoryContextSwitchTo(oldctx);
}
funcctx = SRF_PERCALL_SETUP();
if (funcctx->call_cntr < funcctx->max_calls)
{
typedef struct {
tle_t sat;
double *params;
int is_deep;
int n_obs;
double *jds;
double *obs_pos;
} residual_ctx;
residual_ctx *ctx = (residual_ctx *) funcctx->user_fctx;
int idx = funcctx->call_cntr;
double tsince, pos[3], vel[3];
double dx, dy, dz, pos_err;
int err;
int64 ts;
Datum values[5];
bool nulls[5];
HeapTuple tuple;
tsince = (ctx->jds[idx] - ctx->sat.epoch) * 1440.0;
if (ctx->is_deep)
err = SDP4(tsince, &ctx->sat, ctx->params, pos, vel);
else
err = SGP4(tsince, &ctx->sat, ctx->params, pos, vel);
dx = ctx->obs_pos[idx * 3 + 0] - pos[0];
dy = ctx->obs_pos[idx * 3 + 1] - pos[1];
dz = ctx->obs_pos[idx * 3 + 2] - pos[2];
pos_err = sqrt(dx * dx + dy * dy + dz * dz);
ts = (int64)((ctx->jds[idx] - PG_EPOCH_JD) * (double)USECS_PER_DAY);
memset(nulls, 0, sizeof(nulls));
values[0] = Int64GetDatum(ts);
values[1] = Float8GetDatum(dx);
values[2] = Float8GetDatum(dy);
values[3] = Float8GetDatum(dz);
values[4] = Float8GetDatum(pos_err);
(void)err;
tuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple));
}
SRF_RETURN_DONE(funcctx);
}

Some files were not shown because too many files have changed in this diff Show More