Starlight docs site for pg_orbit v0.2.0

34 MDX pages covering all 57 functions across 7 domains:
satellites (SGP4/SDP4), planets (VSOP87), Moon (ELP2000-82B),
19 planetary moons (L1.2/TASS17/GUST86/MarsSat), stars,
comets, Jupiter radio bursts, and Lambert transfers.

Site structure:
- Getting Started: overview, installation, 5-query quick start
- Guides: 8 domain-specific walkthroughs with workflow translation
- Workflow Translation: side-by-side comparisons with Skyfield,
  JPL Horizons, GMAT, Radio Jupiter Pro, plus SQL patterns
- Reference: all types, functions, operators, body IDs, constants
- Architecture: Hamilton's principles, constant chain of custody,
  observation pipeline, theory-to-code mapping, thread safety
- Performance: verified benchmarks with reproduction methodology

Stack: Astro 5.17 + Starlight 0.37.6, KaTeX math, Mermaid
diagrams, Pagefind search, Caddy production Docker image.
This commit is contained in:
Ryan Malloy 2026-02-16 03:12:41 -07:00
parent 70420c3b4f
commit 12292415ab
45 changed files with 17987 additions and 0 deletions

5
.gitignore vendored
View File

@ -17,3 +17,8 @@ log/
*~ *~
.vscode/ .vscode/
.idea/ .idea/
# Docs site
docs/node_modules/
docs/dist/
docs/.astro/

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"]

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

@ -0,0 +1,121 @@
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";
export default defineConfig({
site: "https://pg-orbit.supported.systems",
integrations: [
mermaid(),
starlight({
title: "pg_orbit",
description:
"Solar system computation for PostgreSQL",
favicon: "/favicon.svg",
logo: {
src: "./src/assets/pg-orbit-logo.svg",
replacesTitle: true,
},
social: [
{
icon: "github",
label: "Gitea",
href: "https://git.supported.systems/warehack.ing/pg_orbit",
},
],
customCss: [
"./src/styles/custom.css",
"./src/styles/katex-fixes.css",
"katex/dist/katex.min.css",
],
head: [
{
tag: "meta",
attrs: {
name: "theme-color",
content: "#0a0e17",
},
},
],
sidebar: [
{
label: "Getting Started",
items: [
{ label: "What is pg_orbit?", slug: "getting-started/what-is-pg-orbit" },
{ 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: "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: "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" },
],
},
],
}),
],
markdown: {
remarkPlugins: [remarkMath],
rehypePlugins: [rehypeKatex],
},
vite: {
plugins: [tailwindcss()],
},
telemetry: false,
devToolbar: { enabled: false },
});

9509
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
docs/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "pg-orbit-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",
"astro": "^5.17.2",
"astro-mermaid": "^1.3.1",
"katex": "^0.16.28",
"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,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_orbit" 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_orbit</text>
</svg>

After

Width:  |  Height:  |  Size: 727 B

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,143 @@
---
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_orbit. 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 sat_code's `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_orbit and sat_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 sat_code defines them in `norad_in.h`. This is intentional.
`types.h` is the single header for all pg_orbit C sources. `norad_in.h` is an internal sat_code header 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_orbit 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.
## 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,181 @@
---
title: Design Principles
sidebar:
order: 1
---
import { Aside, Tabs, TabItem, Steps } from "@astrojs/starlight/components";
pg_orbit 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_orbit, 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_orbit 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
sat_code returns six distinct error codes. pg_orbit 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_orbit 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_orbit achieves this through four structural guarantees.
### Zero global mutable state
There are no file-scope variables, no static locals, no caches. Every function computes from its arguments alone. This is not a style preference --- it is required for PostgreSQL's `PARALLEL SAFE` declaration. All 57 pg_orbit functions carry this declaration, meaning the query planner can distribute work across multiple CPU cores without coordination.
### Fixed-size types
All seven pg_orbit 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_orbit 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_orbit, 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) | `sat_code/sgp4.cpp`, `sdp4.cpp` |
| 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_orbit 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_orbit 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_orbit 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_orbit 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_orbit 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.

View File

@ -0,0 +1,186 @@
---
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_orbit 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);
```
## Zero global mutable state
There are no file-scope variables, no static locals that accumulate state, no caches. Every function computes from its arguments alone.
This guarantee has three consequences:
### PARALLEL SAFE
All 57 pg_orbit 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_orbit avoids this entirely --- no function call leaves any trace in the process state.
### Deterministic computation
Given the same TLE and timestamp, pg_orbit 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.
## sat_code's memory model
sat_code itself 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_orbit copies from `pg_tle`) |
| `params[N_SAT_PARAMS]` | ~736 bytes | Initialized propagator coefficients | Caller (pg_orbit `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_orbit 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 sat_code's `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_orbit 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_orbit 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_orbit uses only `palloc` and has no global state, there is nothing to clean up beyond what PostgreSQL's context system handles automatically. No file handles, no sockets, no mutex locks, no C++ destructors. The extension is always in a consistent state after error recovery.

View File

@ -0,0 +1,191 @@
---
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_orbit 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_orbit 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/planet_funcs.c:observe_from_geocentric()`
</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="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. Sub-arcsecond work should use SPICE or Skyfield with DE441.
**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_orbit 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_orbit 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 |
| New planet ephemeris | 1 (new theory replaces VSOP87) | 2-7 (Earth still VSOP87) |
| 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,218 @@
---
title: SGP4 Integration
sidebar:
order: 6
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
pg_orbit wraps Bill Gray's `sat_code` library (MIT license, Project Pluto) for SGP4/SDP4 propagation. 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 linkage.** All public functions are declared `extern "C"` in `norad.h`. The library compiles as C++ but exposes 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>
## The C/C++ boundary
sat_code is compiled as C++ but pg_orbit is a C extension. The integration works because sat_code's public API is `extern "C"`:
```
src/*.c --[gcc]--> .o --|
lib/sat_code/*.cpp --[g++]--> .o --|--> pg_orbit.so
-lstdc++ -lm
```
The Makefile compiles sat_code's `.cpp` files with `g++` and links them alongside pg_orbit's `.c` files with `-lstdc++` for the C++ runtime. This is the same pattern PostGIS uses for GEOS integration.
### Build rules
```makefile
# sat_code C++ sources
SAT_CODE_DIR = lib/sat_code
SAT_CODE_SRCS = $(SAT_CODE_DIR)/sgp4.cpp $(SAT_CODE_DIR)/sdp4.cpp \
$(SAT_CODE_DIR)/deep.cpp $(SAT_CODE_DIR)/common.cpp \
$(SAT_CODE_DIR)/basics.cpp $(SAT_CODE_DIR)/get_el.cpp \
$(SAT_CODE_DIR)/tle_out.cpp
SAT_CODE_OBJS = $(SAT_CODE_SRCS:.cpp=.o)
# Include sat_code headers for our C sources
PG_CPPFLAGS = -I$(SAT_CODE_DIR)
# C++ runtime for sat_code
SHLIB_LINK += -lstdc++ -lm
# Compile C++ with position-independent code for shared library
$(SAT_CODE_DIR)/%.o: $(SAT_CODE_DIR)/%.cpp
$(CXX) $(CXXFLAGS) -fPIC -I$(SAT_CODE_DIR) -c -o $@ $<
```
The `-fPIC` flag is required because the compiled objects become part of a shared library (`.so`). Without it, the linker would reject the C++ objects.
### Header inclusion
pg_orbit's C files include `norad.h` directly:
```c
#include "norad.h" /* sat_code public API */
#include "types.h" /* pg_orbit types and WGS-72/84 constants */
```
The `PG_CPPFLAGS = -I$(SAT_CODE_DIR)` flag makes `norad.h` available without a path prefix.
## The sat_code API surface
pg_orbit 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_orbit 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_orbit 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_orbit 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_orbit 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_orbit 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_orbit classifies them by physical meaning and responds accordingly.
| Code | sat_code constant | Physical meaning | pg_orbit 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.
## The git submodule
sat_code is included as a git submodule at `lib/sat_code/`. This provides:
- **Pinned version.** The submodule pointer records the exact commit. Upstream changes do not affect pg_orbit until the submodule is explicitly updated.
- **Clear provenance.** `git submodule status` shows the upstream repository (github.com/Bill-Gray/sat_code) and commit hash.
- **Easy updates.** `git submodule update --remote` pulls the latest upstream, which can then be tested against the Vallado 518 vectors before committing the update.
### Files used from sat_code
| File | Purpose |
|------|---------|
| `sgp4.cpp` | SGP4 near-earth propagator |
| `sdp4.cpp` | SDP4 deep-space propagator |
| `deep.cpp` | Lunar/solar perturbation routines for SDP4 |
| `common.cpp` | Shared initialization code for SGP4/SDP4 |
| `basics.cpp` | Utility functions (angle normalization, etc.) |
| `get_el.cpp` | TLE parsing (`parse_elements()`) |
| `tle_out.cpp` | TLE text reconstruction |
| `norad.h` | Public API declarations, `tle_t` struct, constants |
| `norad_in.h` | Internal constants (WGS-72 values) |
Other sat_code files (obs_eph.cpp, sat_id.cpp, etc.) are not compiled. pg_orbit uses sat_code strictly as a propagation library, not as a satellite identification or observation planning tool.

View File

@ -0,0 +1,161 @@
---
title: Theory-to-Code Mapping
sidebar:
order: 4
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Every equation in pg_orbit 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.
| 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 | `sat_code/common.cpp: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$ | `sat_code/common.cpp:sxpx_common_init()` |
| Atmospheric drag | Hoots & Roehrich STR#3 | $B^*$ formulation of drag; $C_1$, $C_2$, $C_4$ coefficients; perigee-dependent $s$ parameter | `sat_code/common.cpp:sxpx_common_init()`; `sat_code/sgp4.cpp: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()` |
| Kepler equation | Classical | Newton-Raphson with second-order correction, bounded first step | `sat_code/common.cpp:sxpx_posn_vel()` |
| Deep-space resonance | Hujsak (1979) | Lunar/solar gravitational perturbations; geopotential resonance for 12-hour and 24-hour orbits | `sat_code/deep.cpp: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()` |
| Deep-space propagation | Hoots & Roehrich STR#3 | SDP4: SGP4 core + deep-space secular/periodic corrections | `sat_code/sdp4.cpp:SDP4()` |
| Near/deep selection | Hoots & Roehrich STR#3 | Period threshold: 225 minutes ($n < 2\pi/225$ rad/min) | `sat_code/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/planet_funcs.c:ecliptic_to_equatorial()` |
### 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` |
### VSOP87
Bretagnon, P. & Francou, G. (1988). "Planetary Theories in Rectangular and Spherical Variables. VSOP87 Solutions." *Astronomy & Astrophysics*, 202, 309-315.
pg_orbit 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_orbit 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_orbit 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` | Observation pipeline | ~250 |
| `src/radio_funcs.c` | Jupiter radio emission | ~200 |
| `sat_code/sgp4.cpp` | SGP4 near-earth propagator | ~300 |
| `sat_code/sdp4.cpp` | SDP4 deep-space propagator | ~200 |
| `sat_code/deep.cpp` | Deep-space perturbations | ~800 |
| `sat_code/common.cpp` | Shared SGP4/SDP4 initialization | ~250 |

View File

@ -0,0 +1,116 @@
---
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_orbit running. The Docker image ships PostgreSQL 17 with pg_orbit pre-compiled.
<Steps>
1. Pull the image:
```bash
docker pull git.supported.systems/warehack.ing/pg_orbit:pg17
```
2. Start the container:
```bash
docker run -d --name pg_orbit \
-e POSTGRES_PASSWORD=orbit \
-p 5499:5432 \
git.supported.systems/warehack.ing/pg_orbit:pg17
```
3. Connect and enable the extension:
```bash
psql -h localhost -p 5499 -U postgres -c "CREATE EXTENSION pg_orbit;"
```
</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/C++ toolchain.
<Steps>
1. Clone the repository:
```bash
git clone https://git.supported.systems/warehack.ing/pg_orbit.git
cd pg_orbit
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_orbit;
```
4. Verify installation:
```sql
SELECT planet_observe(5, '40.0N 105.3W 1655m'::observer, now());
```
</Steps>
<Aside type="note">
The build compiles both C source (pg_orbit) and C++ source (sat_code library for SGP4/SDP4). The C++ runtime (`-lstdc++`) is linked automatically.
</Aside>
</TabItem>
<TabItem label="Docker Compose">
For integration with existing PostgreSQL-based applications.
```yaml
services:
db:
image: git.supported.systems/warehack.ing/pg_orbit: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_orbit;"
```
</TabItem>
</Tabs>
## Running the test suite
If building from source, the regression tests verify all 57 functions across 11 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, and Lambert transfers.
## Upgrading from v0.1.0
If you have pg_orbit 0.1.0 installed (satellite-only), upgrade to 0.2.0:
```sql
ALTER EXTENSION pg_orbit UPDATE TO '0.2.0';
```
This adds all solar system functions (planets, moons, stars, comets, radio, Lambert transfers) while preserving your existing TLE data and satellite functions.

View File

@ -0,0 +1,115 @@
---
title: Quick Start
sidebar:
order: 3
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Five queries that show what pg_orbit 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_orbit installed and `CREATE EXTENSION pg_orbit;` 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_orbit 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
- **[The SQL Advantage](/workflow/sql-advantage/)** — why doing this in SQL changes what's possible

View File

@ -0,0 +1,63 @@
---
title: What is pg_orbit?
sidebar:
order: 1
---
import { Card, CardGrid, Aside } from "@astrojs/starlight/components";
pg_orbit is a PostgreSQL extension that moves orbital mechanics computation inside your database. Instead of computing satellite positions in Python, planet coordinates in C++, or transfer orbits in MATLAB and then importing the results — the computation happens where your data already lives.
## The "PostGIS for space" analogy
PostGIS added spatial awareness to PostgreSQL — suddenly your database understood geometry, distance, and containment. pg_orbit 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 |
## 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_orbit is NOT
<Aside type="caution" title="Honest limitations">
pg_orbit 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_orbit returns numbers. Use Stellarium, GPredict, or STK for visualization. Use any plotting library to render its output.
**Not sub-arcsecond.** VSOP87 is accurate to about 1 arcsecond — sufficient for observation planning and visual astronomy, but not for dish pointing at GHz frequencies or precision astrometry. For that, use SPICE or Skyfield with DE441 ephemerides.
**Not a TLE source.** Bring your own TLEs from Space-Track, CelesTrak, or any other provider. pg_orbit 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. pg_orbit trades those last few milliarcseconds of accuracy for the ability to run computations at SQL speed, in parallel, joined with your other data.
**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_orbit 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_orbit 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_orbit
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_orbit 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_orbit returns position only. Comet brightness depends on heliocentric distance, geocentric distance, and a magnitude slope parameter that varies per comet.
- **No orbit determination.** pg_orbit 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_orbit 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_orbit 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_orbit
pg_orbit 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_orbit 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_orbit 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_orbit 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,271 @@
---
title: Interplanetary Trajectories
sidebar:
order: 7
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
pg_orbit 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_orbit
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_orbit 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_orbit 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_orbit 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_orbit, 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_orbit
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_orbit 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_orbit 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_orbit 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_orbit 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_orbit
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_orbit 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 pointing a dish at GHz frequencies or precision astrometry, use SPICE.
- **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_orbit 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_orbit 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_orbit 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_orbit
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_orbit 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_orbit 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_orbit 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_orbit 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_orbit
`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_orbit does not replace
<Aside type="caution" title="Precision boundaries">
Star observation in pg_orbit 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_orbit 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_orbit 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_orbit
pg_orbit 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_orbit does not replace
<Aside type="caution" title="Know the boundaries">
pg_orbit 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_orbit returns numbers. Use any visualization tool to render its output.
- **No rotator control.** Hamlib drives antenna rotators. pg_orbit 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_orbit parses and propagates them.
- **No orbit determination.** pg_orbit 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_orbit'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,67 @@
---
title: pg_orbit Documentation
description: Solar system computation for PostgreSQL
template: splash
hero:
tagline: Track satellites, compute planet positions, observe 19 planetary moons, predict Jupiter radio bursts, and plan interplanetary trajectories — all from standard SQL.
actions:
- text: Get Started
link: /getting-started/what-is-pg-orbit/
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_orbit 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, 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_orbit 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_orbit'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_orbit 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_orbit 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_orbit;
-- 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_orbit'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_orbit 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,198 @@
---
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_orbit. All constants are compiled from their original sources and embedded at compile time -- no runtime configuration files, no external data dependencies.
---
## 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_orbit 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_orbit 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.
### 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,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_orbit.
---
## 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,228 @@
---
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).
---
## 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_orbit 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_orbit 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_orbit'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_orbit 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_orbit (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_orbit (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_orbit'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_orbit'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_orbit 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_orbit 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_orbit returns raw transfer parameters without optimization.
**Attitude modeling.** GMAT tracks spacecraft orientation — important for solar panel pointing, antenna alignment, and sensor geometry. pg_orbit has no concept of spacecraft attitude.
**Visualization.** GMAT includes 3D trajectory visualization. pg_orbit returns numbers.
## Where pg_orbit 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_orbit's Lambert solver requires knowing one SQL function and its six output columns.
## The intended workflow
pg_orbit's Lambert solver fits into a specific phase of mission planning:
<Steps>
1. **Screen with pg_orbit.** 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_orbit 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,339 @@
---
title: From JPL Horizons to SQL
sidebar:
order: 2
description: Side-by-side comparison of JPL Horizons API workflows and equivalent pg_orbit 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_orbit 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_orbit (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_orbit (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_orbit 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_orbit (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_orbit 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_orbit (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_orbit 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 many use cases">
JPL Horizons uses the DE441 ephemeris (the current long-span planetary ephemeris) and applies the full suite of corrections. For precision work, it's irreplaceable.
</Aside>
**Accuracy.** DE441 provides sub-milliarcsecond planetary positions. pg_orbit's VSOP87 is accurate to about 1 arcsecond — a factor of 1000 less precise. For spacecraft navigation, radar astrometry, or occultation timing, Horizons is the correct source.
**Aberration corrections.** Horizons applies light-time iteration, stellar aberration, and gravitational deflection of light. pg_orbit uses geometric positions only.
**Physical properties.** Horizons can return visual magnitude, angular diameter, phase angle, illuminated fraction, and surface brightness. pg_orbit 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_orbit uses a WGS84 ellipsoid.
**Body catalog.** Horizons knows about every numbered asteroid, every known comet, and spacecraft past and present. pg_orbit covers the 8 planets, the Sun, the Moon, 19 planetary moons, and whatever comets/asteroids you define with Keplerian elements.
## Where pg_orbit wins
**No network dependency.** pg_orbit 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_orbit 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_orbit results are already rows in PostgreSQL — ready to JOIN, index, aggregate, or export.
**Reproducibility.** A pg_orbit 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. **Use Horizons for calibration.** Run the same computation in both systems and compare. pg_orbit should agree with Horizons to within about 1 arcsecond for planets and a few arcseconds for moons. If the difference matters for your application, use Horizons.
2. **Use pg_orbit for surveys.** Any time you need positions for many bodies, many timestamps, or many observers — parameter sweeps, scheduling optimization, catalog screening — run it locally.
3. **Use pg_orbit 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.
4. **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_orbit 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_orbit 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_orbit (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_orbit (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_orbit 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_orbit 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_orbit returns numbers — you build your own visualization.
**Audio prediction.** Radio Jupiter Pro includes models for the expected spectral characteristics of different source regions. pg_orbit 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_orbit, you compose the information yourself from separate function calls.
**Zero setup.** Install the application, enter your coordinates, and it works. pg_orbit requires PostgreSQL, the extension, and SQL knowledge.
## Where pg_orbit wins
**Platform independence.** Radio Jupiter Pro is Windows-only. pg_orbit 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_orbit 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,310 @@
---
title: From Skyfield to SQL
sidebar:
order: 1
description: Side-by-side comparison of Skyfield Python workflows and equivalent pg_orbit 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_orbit 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_orbit (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_orbit` 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_orbit (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_orbit (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_orbit 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_orbit 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_orbit (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_orbit does not replace Skyfield for all use cases. Be clear about where the trade-offs fall.
</Aside>
**Precision.** Skyfield uses the full IAU 2000A nutation model, polar motion corrections, and delta-T from IERS data. When you need sub-arcsecond accuracy — dish pointing at microwave frequencies, occultation timing, precision astrometry — Skyfield with DE441 ephemerides is the right tool.
**Rise/set finding.** `find_events()` uses numerical root-finding to pinpoint the exact moment a body crosses an elevation threshold. pg_orbit'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_orbit 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_orbit 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_orbit's body catalog is fixed at compile time.
## Where pg_orbit 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_orbit installed.
## Migrating gradually
You don't have to choose one or the other. A practical migration path:
<Steps>
1. **Keep Skyfield for precision work.** Anything requiring sub-arcsecond accuracy, aberration corrections, or custom BSP kernels stays in Python.
2. **Move batch observation to SQL.** If you're computing positions for hundreds of objects to filter or correlate with database records, pg_orbit 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.
</Steps>

View File

@ -0,0 +1,473 @@
---
title: The SQL Advantage
sidebar:
order: 5
description: SQL patterns that make pg_orbit 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_orbit 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_orbit 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_orbit 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_orbit, 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_orbit 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_orbit'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.
## 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. And pg_orbit puts the functions inside the tool.

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_orbit 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"
}