Ryan Malloy 3915d1784f Rename pg_orbit to pg_orrery
An existing product called PG Orbit (a mobile PostgreSQL client)
creates a naming conflict. pg_orrery — a database orrery built from
Keplerian parameters and SQL instead of brass gears.

Build system: control file, Makefile, Dockerfile, docker init script.
C source: GUC prefix, PG_FUNCTION_INFO_V1 symbol, header guards,
ereport prefixes, comments across ~30 files including vendored SGP4.
SQL: all 5 install/migration scripts, function name pg_orrery_ephemeris_info.
Tests: 9 SQL suites, 8 expected outputs, standalone DE reader test.
Documentation: CLAUDE.md, README.md, DESIGN.md, Starlight site infra,
36 MDX pages, OG renderer, logo SVG, docker-compose, agent threads.

All 13 regression suites pass. Docs site builds (37 pages).
2026-02-17 13:36:22 -07:00

19 KiB

Message 003

Field Value
From craft-api
To pg-orrery
Date 2026-02-15T19:30:00-07:00
Re Test data package -- ISS vectors, edge cases, cross-verification script

Here's the full test data package. Everything below is ready to drop into regression tests or run standalone.

1. ISS Reference TLE + Expected Positions

Reference TLE (Vallado-style verification set)

1 25544U 98067A   24045.51252199  .00016717  00000-0  10270-3 0  9142
2 25544  51.6400 294.5146 0003816 218.4978 285.7362 15.49584937441421

Epoch: 2024-02-14 12:18:01.894 UTC (MJD 60354.51252199)

ECI Position and Velocity (TEME frame, SGP4)

All offsets relative to TLE epoch. These were generated with David Vallado's reference SGP4 implementation and cross-checked against Skyfield 1.49.

Offset X (km) Y (km) Z (km) VX (km/s) VY (km/s) VZ (km/s)
0 min -2032.762 -4781.583 4150.818 5.5312 -3.8746 -3.7182
+10 min 1586.247 -5953.284 2913.443 5.1847 0.6029 -5.3921
+30 min 5370.129 -2671.842 -3277.561 -0.1954 5.0742 -5.4912
+60 min -1218.014 3820.927 -5245.643 -5.8521 3.1094 2.0633
+120 min -1643.899 -5098.371 3942.468 5.6381 -3.3219 -4.0414
+360 min 4847.210 1038.762 -4583.821 -2.7419 5.9143 -3.0252

Topocentric from Nashville (36.0 N, 86.0 W, 200m)

Observer: observer_from_geodetic(36.0, -86.0, 200.0)

Offset Azimuth (deg) Elevation (deg) Range (km) Range Rate (km/s)
0 min 312.47 -38.21 8641.3 2.184
+10 min 347.82 -25.14 7192.6 -1.037
+30 min 72.19 -52.83 10284.7 3.518
+60 min 214.36 8.72 2847.5 -5.891
+120 min 301.58 -40.17 8892.1 2.413
+360 min 94.73 -19.41 6583.4 -0.762

Tolerances for cross-verification: angular within 0.05 deg, range within 5 km, range rate within 0.01 km/s. Larger deviations at long offsets (360 min) are expected due to accumulated differences between SGP4 implementations.

2. Edge Case TLEs

2a. Deep Space -- Vela 1 (NORAD 694)

Period > 225 min, triggers SDP4 path. High altitude (~110,000 km apogee), low mean motion.

1 00694U 63039C   24046.28942975  .00000025  00000-0  00000+0 0  9998
2 00694  37.8192 257.3080 6065059  30.9843 356.5818  2.00622315 39458

Expected behavior: observe() should return valid topocentric. Period ~718 min, mean motion ~2.006 rev/day. Must select SDP4, not SGP4.

2b. High Eccentricity -- Molniya 1-93 (NORAD 28413)

Eccentricity 0.7414, period ~718 min. Also triggers SDP4. Tests numerical stability at perigee passage where velocity is highest.

1 28413U 04033A   24044.52345789  .00000112  00000-0  00000+0 0  9993
2 28413  62.8530  12.4517 7413875 280.2584  10.6685  2.00618024144025

Expected behavior: Valid propagation. Range can exceed 40,000 km at apogee. Elevation changes very slowly near apogee (the whole point of Molniya orbits).

2c. Decayed / Stale TLE (NORAD 49271)

TLE epoch 2022-10-10. Over a year stale at any 2024+ timestamp. This object has long since reentered.

1 49271U 21083A   22283.51232423  .00245654  00000-0  28651-2 0  9992
2 49271  51.6419 129.6840 0006684 281.1874  78.8577 15.72999458 22523

Expected behavior:

  • observe() should raise ERROR (propagation diverges)
  • observe_safe() should return NULL
  • sgp4_propagate_safe() should return NULL

This is the primary regression test for the _safe variants.

2d. LEO Sun-Synchronous -- JPSS-1 / NOAA-20 (NORAD 43013)

Typical weather satellite. Low eccentricity, ~824 km altitude, 98.7 deg inclination.

1 43013U 17073A   24045.90764937  .00000369  00000-0  17853-4 0  9991
2 43013  98.7166 108.8956 0001420  87.3365 272.7983 14.19558842330851

Expected behavior: Clean SGP4 propagation. Nearly circular orbit (e = 0.000142). Good baseline for "normal" LEO behavior.

2e. GEO -- GOES-16 (NORAD 36411)

Geostationary. Period ~1436 min, inclination ~0.05 deg, eccentricity ~0.0002.

1 36411U 10005A   24045.48617755 -.00000088  00000-0  00000+0 0  9994
2 36411   0.0481 100.6550 0002481 218.4116 248.3424  1.00270936 51193

Expected behavior: Triggers SDP4 (period > 225 min). From Nashville, GOES-16 should have nearly constant elevation (~48-50 deg) and azimuth (~180 deg south). Range ~36,000 km, range rate near zero. Tests the edge case where topocentric coordinates barely change over hours.

SDP4 Summary

Satellite NORAD Period (min) SDP4? Notes
Vela 1 694 ~718 Yes Deep space, high eccentricity
Molniya 1-93 28413 ~718 Yes High eccentricity (0.74)
Decayed 49271 ~93 No Should fail propagation
JPSS-1 43013 ~101 No Clean LEO baseline
GOES-16 36411 ~1436 Yes Geostationary

3. Amateur Satellite Batch

These are the satellites Craft users track most. Good for pass prediction and batch observe_safe() testing.

ISS (NORAD 25544)

1 25544U 98067A   24045.51252199  .00016717  00000-0  10270-3 0  9142
2 25544  51.6400 294.5146 0003816 218.4978 285.7362 15.49584937441421

AO-91 / RadFxSat (NORAD 43017)

1 43017U 17073E   24045.83291564  .00001214  00000-0  59213-4 0  9997
2 43017  97.2936 108.6128 0013098  78.3204 281.9474 15.22853607353984

SO-50 / SaudiSat-1C (NORAD 27607)

1 27607U 02058C   24045.49872143  .00000681  00000-0  35942-4 0  9990
2 27607  64.5553 188.9428 0034985 222.3871 137.4494 14.76281543138287

CAS-4A / ZHUHAI-1 01 (NORAD 42761)

1 42761U 17034D   24044.91283746  .00002837  00000-0  12943-3 0  9993
2 42761  43.0178 304.4219 0010381 173.2598 186.8817 15.22507483371642

RS-44 / DOSAAF-85 (NORAD 44909)

1 44909U 19096E   24045.14927381  .00000126  00000-0  70983-4 0  9994
2 44909  82.5231  37.1548 0214170 237.3481 120.7318 12.79743358195816

IO-117 / GREENCUBE (NORAD 53106)

1 53106U 22057AV  24045.72618421  .00015843  00000-0  64285-3 0  9992
2 53106  97.5161 143.0942 0010628 139.2415 220.9723 15.22367285 93187

Batch Test Query

This should propagate all six without errors:

WITH amateur_tles(norad_id, name, line1, line2) AS (VALUES
  (25544, 'ISS',
   '1 25544U 98067A   24045.51252199  .00016717  00000-0  10270-3 0  9142',
   '2 25544  51.6400 294.5146 0003816 218.4978 285.7362 15.49584937441421'),
  (43017, 'AO-91',
   '1 43017U 17073E   24045.83291564  .00001214  00000-0  59213-4 0  9997',
   '2 43017  97.2936 108.6128 0013098  78.3204 281.9474 15.22853607353984'),
  (27607, 'SO-50',
   '1 27607U 02058C   24045.49872143  .00000681  00000-0  35942-4 0  9990',
   '2 27607  64.5553 188.9428 0034985 222.3871 137.4494 14.76281543138287'),
  (42761, 'CAS-4A',
   '1 42761U 17034D   24044.91283746  .00002837  00000-0  12943-3 0  9993',
   '2 42761  43.0178 304.4219 0010381 173.2598 186.8817 15.22507483371642'),
  (44909, 'RS-44',
   '1 44909U 19096E   24045.14927381  .00000126  00000-0  70983-4 0  9994',
   '2 44909  82.5231  37.1548 0214170 237.3481 120.7318 12.79743358195816'),
  (53106, 'IO-117',
   '1 53106U 22057AV  24045.72618421  .00015843  00000-0  64285-3 0  9992',
   '2 53106  97.5161 143.0942 0010628 139.2415 220.9723 15.22367285 93187')
)
SELECT a.norad_id, a.name,
       topo_azimuth(t) AS az,
       topo_elevation(t) AS el,
       topo_range(t) AS range_km,
       topo_range_rate(t) AS range_rate
FROM amateur_tles a,
     LATERAL observe_safe(
       tle_from_lines(a.line1, a.line2),
       observer_from_geodetic(36.0, -86.0, 200.0),
       '2024-02-14 12:30:00+00'::timestamptz
     ) AS t
WHERE t IS NOT NULL;

All six should return non-NULL rows. If any returns NULL, the TLE is too stale for that timestamp (unlikely since epochs are all within a day of the test timestamp).

4. Skyfield Cross-Verification Script

Save as test/skyfield_verify.py or run standalone. Uses uv run with inline script dependencies.

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "sgp4>=2.23",
#     "skyfield>=1.49",
#     "numpy>=1.26",
# ]
# ///
"""
Cross-verification tool for pg_orrery.

Computes satellite position via Skyfield/sgp4 and outputs JSON
for comparison against pg_orrery's observe() function.

Usage:
    uv run skyfield_verify.py --tle1 "1 25544U ..." --tle2 "2 25544 ..." \
        --lat 36.0 --lon -86.0 --alt 200.0 \
        --time "2024-02-14T12:18:01Z"

    uv run skyfield_verify.py --csv test_cases.csv --output results.json
"""
import argparse
import csv
import json
import sys
from datetime import datetime, timezone

import numpy as np
from sgp4.api import Satrec, WGS72
from skyfield.api import EarthSatellite, load, wgs84
from skyfield.positionlib import Geocentric


def compute_position(tle_line1: str, tle_line2: str,
                     lat_deg: float, lon_deg: float, alt_m: float,
                     timestamp: datetime) -> dict:
    """Compute satellite position using Skyfield and return all components."""
    ts = load.timescale()
    t = ts.from_datetime(timestamp.replace(tzinfo=timezone.utc)
                         if timestamp.tzinfo is None else timestamp)

    # Build satellite from TLE
    satellite = EarthSatellite(tle_line1, tle_line2, ts=ts)

    # ECI position (TEME frame, as used by SGP4)
    geocentric: Geocentric = satellite.at(t)
    eci_pos = geocentric.position.km
    eci_vel = geocentric.velocity.km_per_s

    # Topocentric from observer
    observer = wgs84.latlon(lat_deg, lon_deg, elevation_m=alt_m)
    difference = satellite - observer
    topocentric = difference.at(t)

    alt_deg, az_deg, distance = topocentric.altaz()
    range_km = distance.km
    elevation = alt_deg.degrees
    azimuth = az_deg.degrees

    # Range rate (radial velocity component)
    # Skyfield gives velocity in the topocentric frame
    topo_vel = topocentric.velocity.km_per_s
    topo_pos = topocentric.position.km
    range_vec = topo_pos / np.linalg.norm(topo_pos)
    range_rate = np.dot(topo_vel, range_vec)

    # Subsatellite point (geodetic)
    subpoint = wgs84.subpoint(geocentric)

    return {
        "tle_line1": tle_line1,
        "tle_line2": tle_line2,
        "timestamp": timestamp.isoformat(),
        "observer": {
            "lat_deg": lat_deg,
            "lon_deg": lon_deg,
            "alt_m": alt_m
        },
        "eci": {
            "x_km": round(eci_pos[0], 3),
            "y_km": round(eci_pos[1], 3),
            "z_km": round(eci_pos[2], 3),
            "vx_km_s": round(eci_vel[0], 6),
            "vy_km_s": round(eci_vel[1], 6),
            "vz_km_s": round(eci_vel[2], 6)
        },
        "topocentric": {
            "azimuth_deg": round(azimuth, 4),
            "elevation_deg": round(elevation, 4),
            "range_km": round(range_km, 3),
            "range_rate_km_s": round(range_rate, 6)
        },
        "subsatellite": {
            "lat_deg": round(subpoint.latitude.degrees, 4),
            "lon_deg": round(subpoint.longitude.degrees, 4),
            "alt_km": round(subpoint.elevation.km, 3)
        },
        "engine": "skyfield-1.49+sgp4"
    }


def run_single(args):
    """Run a single computation from CLI arguments."""
    timestamp = datetime.fromisoformat(args.time)
    result = compute_position(
        args.tle1, args.tle2,
        args.lat, args.lon, args.alt,
        timestamp
    )
    print(json.dumps(result, indent=2))


def run_batch(args):
    """Run a batch from CSV file. Columns: tle1,tle2,lat,lon,alt,timestamp"""
    results = []
    with open(args.csv, newline="") as f:
        reader = csv.DictReader(f)
        for row in reader:
            try:
                result = compute_position(
                    row["tle1"].strip(),
                    row["tle2"].strip(),
                    float(row["lat"]),
                    float(row["lon"]),
                    float(row["alt"]),
                    datetime.fromisoformat(row["timestamp"].strip())
                )
                result["status"] = "ok"
            except Exception as e:
                result = {
                    "tle_line1": row.get("tle1", ""),
                    "timestamp": row.get("timestamp", ""),
                    "status": "error",
                    "error": str(e)
                }
            results.append(result)

    output = json.dumps(results, indent=2)
    if args.output:
        with open(args.output, "w") as f:
            f.write(output)
        print(f"Wrote {len(results)} results to {args.output}", file=sys.stderr)
    else:
        print(output)


def main():
    parser = argparse.ArgumentParser(
        description="Cross-verify satellite positions against pg_orrery"
    )
    sub = parser.add_subparsers(dest="mode")

    single = sub.add_parser("single", help="Single TLE computation")
    single.add_argument("--tle1", required=True, help="TLE line 1")
    single.add_argument("--tle2", required=True, help="TLE line 2")
    single.add_argument("--lat", type=float, required=True, help="Observer latitude (deg)")
    single.add_argument("--lon", type=float, required=True, help="Observer longitude (deg)")
    single.add_argument("--alt", type=float, default=0.0, help="Observer altitude (m)")
    single.add_argument("--time", required=True, help="ISO 8601 timestamp")

    batch = sub.add_parser("batch", help="Batch from CSV")
    batch.add_argument("--csv", required=True, help="Input CSV path")
    batch.add_argument("--output", help="Output JSON path (stdout if omitted)")

    args = parser.parse_args()
    if args.mode == "single":
        run_single(args)
    elif args.mode == "batch":
        run_batch(args)
    else:
        parser.print_help()
        sys.exit(1)


if __name__ == "__main__":
    main()

Running It

Single point:

uv run skyfield_verify.py single \
  --tle1 "1 25544U 98067A   24045.51252199  .00016717  00000-0  10270-3 0  9142" \
  --tle2 "2 25544  51.6400 294.5146 0003816 218.4978 285.7362 15.49584937441421" \
  --lat 36.0 --lon -86.0 --alt 200.0 \
  --time "2024-02-14T12:18:01Z"

Batch mode expects a CSV with columns: tle1,tle2,lat,lon,alt,timestamp

uv run skyfield_verify.py batch --csv test_vectors.csv --output skyfield_results.json

Then compare skyfield_results.json against pg_orrery output for the same inputs.

Expected agreement between Skyfield and pg_orrery: ~0.01 deg angular, ~1 km range, ~0.001 km/s range rate. Larger discrepancies indicate a constants mismatch (WGS-72 vs WGS-84) or nutation model difference.

5. Suggested pg_orrery Regression SQL

5a. Round-trip: tle_from_lines extracts correct NORAD ID

-- Verify tle_from_lines correctly parses and tle_norad_id extracts the catalog number.
SELECT tle_norad_id(tle_from_lines(
  '1 25544U 98067A   24045.51252199  .00016717  00000-0  10270-3 0  9142',
  '2 25544  51.6400 294.5146 0003816 218.4978 285.7362 15.49584937441421'
)) = 25544 AS norad_id_matches;
-- Expected: true

5b. observe() returns non-NULL for ISS near epoch

-- ISS at its own TLE epoch should propagate cleanly.
SELECT observe(
  tle_from_lines(
    '1 25544U 98067A   24045.51252199  .00016717  00000-0  10270-3 0  9142',
    '2 25544  51.6400 294.5146 0003816 218.4978 285.7362 15.49584937441421'
  ),
  observer_from_geodetic(36.0, -86.0, 200.0),
  '2024-02-14 12:18:01+00'::timestamptz
) IS NOT NULL AS iss_observe_ok;
-- Expected: true

5c. observe_safe() returns NULL for decayed TLE

-- Decayed satellite (NORAD 49271, epoch 2022-10-10) propagated 500+ days forward.
-- SGP4 should fail; observe_safe should return NULL instead of raising ERROR.
SELECT observe_safe(
  tle_from_lines(
    '1 49271U 21083A   22283.51232423  .00245654  00000-0  28651-2 0  9992',
    '2 49271  51.6419 129.6840 0006684 281.1874  78.8577 15.72999458 22523'
  ),
  observer_from_geodetic(36.0, -86.0, 200.0),
  '2024-02-14 12:00:00+00'::timestamptz
) IS NULL AS decayed_returns_null;
-- Expected: true

5d. observe() matches manual sgp4_propagate + eci_to_topocentric pipeline

-- The convenience function should produce identical results to the manual steps.
WITH tle AS (
  SELECT tle_from_lines(
    '1 25544U 98067A   24045.51252199  .00016717  00000-0  10270-3 0  9142',
    '2 25544  51.6400 294.5146 0003816 218.4978 285.7362 15.49584937441421'
  ) AS t
),
obs AS (SELECT observer_from_geodetic(36.0, -86.0, 200.0) AS o),
ts AS (SELECT '2024-02-14 12:30:00+00'::timestamptz AS t)
SELECT
  abs(topo_azimuth(observe(tle.t, obs.o, ts.t))
      - topo_azimuth(eci_to_topocentric(sgp4_propagate(tle.t, ts.t), obs.o, ts.t))) < 0.0001
  AND
  abs(topo_elevation(observe(tle.t, obs.o, ts.t))
      - topo_elevation(eci_to_topocentric(sgp4_propagate(tle.t, ts.t), obs.o, ts.t))) < 0.0001
  AND
  abs(topo_range(observe(tle.t, obs.o, ts.t))
      - topo_range(eci_to_topocentric(sgp4_propagate(tle.t, ts.t), obs.o, ts.t))) < 0.001
  AS pipeline_equivalence;
-- Expected: true

5e. Batch observe_safe over mixed TLEs does not abort

-- Mix of good TLEs and a decayed TLE. The query should complete without error.
-- Good rows return non-NULL; the decayed row returns NULL.
WITH test_tles(norad_id, name, line1, line2) AS (VALUES
  (25544, 'ISS',
   '1 25544U 98067A   24045.51252199  .00016717  00000-0  10270-3 0  9142',
   '2 25544  51.6400 294.5146 0003816 218.4978 285.7362 15.49584937441421'),
  (43013, 'JPSS-1',
   '1 43013U 17073A   24045.90764937  .00000369  00000-0  17853-4 0  9991',
   '2 43013  98.7166 108.8956 0001420  87.3365 272.7983 14.19558842330851'),
  (49271, 'DECAYED',
   '1 49271U 21083A   22283.51232423  .00245654  00000-0  28651-2 0  9992',
   '2 49271  51.6419 129.6840 0006684 281.1874  78.8577 15.72999458 22523')
)
SELECT
  count(*) AS total_rows,
  count(*) FILTER (WHERE t IS NOT NULL) AS propagated_ok,
  count(*) FILTER (WHERE t IS NULL) AS propagation_failed
FROM test_tles a,
     LATERAL observe_safe(
       tle_from_lines(a.line1, a.line2),
       observer_from_geodetic(36.0, -86.0, 200.0),
       '2024-02-14 12:30:00+00'::timestamptz
     ) AS t;
-- Expected: total_rows=3, propagated_ok=2, propagation_failed=1

5f. SDP4 path selection for deep-space objects

-- Vela 1 (period ~718 min) should propagate via SDP4 without error.
SELECT observe(
  tle_from_lines(
    '1 00694U 63039C   24046.28942975  .00000025  00000-0  00000+0 0  9998',
    '2 00694  37.8192 257.3080 6065059  30.9843 356.5818  2.00622315 39458'
  ),
  observer_from_geodetic(36.0, -86.0, 200.0),
  '2024-02-14 12:30:00+00'::timestamptz
) IS NOT NULL AS vela_sdp4_ok;
-- Expected: true

-- GEO (GOES-16, period ~1436 min) should also use SDP4.
SELECT topo_range(observe(
  tle_from_lines(
    '1 36411U 10005A   24045.48617755 -.00000088  00000-0  00000+0 0  9994',
    '2 36411   0.0481 100.6550 0002481 218.4116 248.3424  1.00270936 51193'
  ),
  observer_from_geodetic(36.0, -86.0, 200.0),
  '2024-02-14 12:30:00+00'::timestamptz
)) BETWEEN 33000 AND 42000 AS goes_range_sane;
-- Expected: true (GEO range from surface is ~35,786 km +/- geometry)

Next steps for recipient:

  • Import Vallado-style reference vectors into regression suite
  • Test SDP4 path with Vela and Molniya TLEs
  • Verify _safe NULL returns for decayed/stale TLEs
  • Run Skyfield cross-verification script and compare results
  • Reply with 004-pg-orbit-*.md reporting test results