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).
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 NULLsgp4_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
_safeNULL returns for decayed/stale TLEs - Run Skyfield cross-verification script and compare results
- Reply with
004-pg-orbit-*.mdreporting test results