Tracking Satellites
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
Section titled “How you do it today”Most satellite tracking workflows follow the same pattern:
- Download TLEs from Space-Track or CelesTrak into a file or database table.
- Propagate each TLE in Python (python-sgp4, Skyfield) or C++ (libsgp4) to get position/velocity at a given time.
- Transform ECI coordinates to observer-relative look angles (azimuth, elevation, range).
- Predict passes by stepping through time and finding horizon crossings.
- 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
Section titled “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 JOINwithpredict_passes(). - Conjunction screening uses a GiST index on the
tlecolumn, 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
Section titled “What pg_orbit does not replace”- 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
Section titled “Try it”Set up a satellite catalog
Section titled “Set up a satellite catalog”Create a table that stores TLEs alongside metadata. This mirrors what you would have if you ingest CelesTrak data:
CREATE TABLE satellites ( norad_id integer PRIMARY KEY, name text NOT NULL, tle tle NOT NULL, owner text, purpose text);
-- ISSINSERT INTO satellites VALUES ( 25544, 'ISS (ZARYA)', '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 90252 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001', 'ISS', 'Space Station');
-- Hubble Space TelescopeINSERT INTO satellites VALUES ( 20580, 'HST', '1 20580U 90037B 24001.50000000 .00000790 00000+0 39573-4 0 99922 20580 28.4705 61.4398 0002797 317.3115 42.7577 15.09395228 00008', 'NASA', 'Telescope');
-- GPS IIR-MINSERT INTO satellites VALUES ( 28874, 'GPS BIIR-3 (PRN 29)', '1 28874U 05038A 24001.50000000 .00000012 00000+0 00000+0 0 99932 28874 55.4408 300.3467 0117034 51.6543 309.5420 2.00557079 00006', 'USSF', 'Navigation');Batch observation
Section titled “Batch observation”Observe every satellite in the catalog from a single observer at a single time:
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_kmFROM satellites s, observe_safe(s.tle, '40.0N 105.3W 1655m'::observer, '2024-01-01 12:00:00+00') obsWHERE topo_elevation(obs) > 0ORDER 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
Section titled “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:
-- Hypothetical frequency tableCREATE 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 elFROM satellites sJOIN sat_frequencies f USING (norad_id), observe_safe(s.tle, '40.0N 105.3W 1655m'::observer, now()) obsWHERE f.downlink_mhz BETWEEN 144.0 AND 148.0 AND topo_elevation(obs) > 10ORDER 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
Section titled “Pass prediction”Predict passes for a single satellite over the next 24 hours:
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 durationFROM satellites s, predict_passes(s.tle, '40.0N 105.3W 1655m'::observer, now(), now() + interval '24 hours', 10.0) pWHERE 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
Section titled “Predict passes for many satellites”Use LATERAL JOIN to predict passes for every satellite in a subset:
SELECT s.name, pass_aos_time(p) AS rise, round(pass_max_elevation(p)::numeric, 1) AS max_el, pass_duration(p) AS durationFROM satellites s, LATERAL predict_passes(s.tle, '40.0N 105.3W 1655m'::observer, now(), now() + interval '24 hours', 20.0) pWHERE 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
Section titled “Ground tracks”Trace the ISS ground track over one orbit (approximately 93 minutes):
SELECT t, round(lat::numeric, 2) AS latitude, round(lon::numeric, 2) AS longitude, round(alt::numeric, 0) AS altitude_kmFROM 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
Section titled “Subsatellite point”The subsatellite point is the nadir location directly below the satellite:
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_kmFROM satellites sWHERE s.norad_id = 25544;Distance between satellites
Section titled “Distance between satellites”Compute the Euclidean distance between any two TLEs at a given time:
SELECT round(tle_distance(a.tle, b.tle, '2024-01-01 12:00:00+00')::numeric, 0) AS dist_kmFROM satellites a, satellites bWHERE a.norad_id = 25544 -- ISS AND b.norad_id = 20580; -- HubbleConjunction screening with GiST
Section titled “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:
-
Create the index:
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.
-
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.-- Find all satellites in the same orbital shell as the ISSSELECT 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_degFROM satellites a, satellites bWHERE a.norad_id = 25544AND a.norad_id != b.norad_idAND a.tle && b.tleORDER 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.
-
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:-- Find the 10 satellites with the closest altitude band to the ISSSELECT b.name,round((a.tle <-> b.tle)::numeric, 0) AS alt_separation_kmFROM satellites a, satellites bWHERE a.norad_id = 25544AND a.norad_id != b.norad_idORDER BY a.tle <-> b.tleLIMIT 10; -
Full conjunction check on candidates:
The GiST filter narrows the catalog to a handful of candidates. Then verify with actual propagation:
-- 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.tleFROM satellites a, satellites bWHERE a.norad_id = 25544AND a.norad_id != b.norad_idAND 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_kmFROM candidates c, iss,generate_series('2024-01-01 00:00:00+00'::timestamptz,'2024-01-02 00:00:00+00'::timestamptz,interval '5 minutes') tWHERE tle_distance(iss.tle, c.tle, t) < 50.0ORDER 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.
TLE metadata accessors
Section titled “TLE metadata accessors”Every TLE exposes its orbital elements as accessor functions:
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_daysFROM satellitesORDER 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.