# Message 003 | Field | Value | |-------|-------| | From | craft-api | | To | pg-orbit | | 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: ```sql 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. ```python #!/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_orbit. Computes satellite position via Skyfield/sgp4 and outputs JSON for comparison against pg_orbit'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_orbit" ) 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: ```bash 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` ```bash uv run skyfield_verify.py batch --csv test_vectors.csv --output skyfield_results.json ``` Then compare `skyfield_results.json` against pg_orbit output for the same inputs. Expected agreement between Skyfield and pg_orbit: ~0.01 deg angular, ~1 km range, ~0.001 km/s range rate. Larger discrepancies indicate a constants mismatch (WGS-72 vs WGS-84) or nutation model difference. ## 5. Suggested pg_orbit Regression SQL ### 5a. Round-trip: tle_from_lines extracts correct NORAD ID ```sql -- 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 ```sql -- 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 ```sql -- 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 ```sql -- 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 ```sql -- 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 ```sql -- 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