Skip to content

Satellite Pass Prediction

Satellite pass prediction answers a deceptively simple question: “which satellites will fly over me tonight?” The brute-force approach — propagating every object in a 30,000-satellite catalog with SGP4 at 10-second intervals over a 24-hour window — requires billions of floating-point operations. pg_orrery solves this with the &? visibility cone operator, which applies three geometric filters (altitude, inclination, RAAN) to eliminate 80-90% of candidates without any SGP4 propagation. An optional SP-GiST index provides tree-level pruning for large catalogs.

Ground station operators and amateur observers use several approaches:

  • Heavens-Above / N2YO: Web tools that compute passes for a single observer. Great for casual use. Not queryable from SQL; you scrape or check manually.
  • Skyfield / PyEphem / predict: Python libraries that propagate TLEs and compute topocentric coordinates. You write a loop over the catalog and check each object. Works, but scales linearly with catalog size.
  • GPredict / Orbitron: Desktop applications for pass prediction. Local install, single observer, no database integration.
  • Custom scripts: Propagate everything, compute elevation, filter. For a full catalog this takes minutes per observer per day.

The common thread: every approach propagates every satellite. There is no pre-filtering. A 30,000-object catalog takes the same time whether 2 satellites or 2,000 are visible from your location.

pg_orrery attacks the problem in two stages:

Stage 1: SP-GiST index eliminates impossible candidates. The &? operator checks whether a satellite could be visible from a ground observer during a time window, using three geometric filters applied without SGP4 propagation:

FilterWhat it checksWhat it eliminates
AltitudePerigee too high for the observer’s minimum elevation angleMEO/GEO satellites for high-elevation queries
InclinationInclination + footprint angle must reach the observer’s latitudeEquatorial satellites from high-latitude observers
RAANRight Ascension of Ascending Node alignment with observer’s local sidereal timeSatellites whose orbital plane isn’t overhead during the query window

Stage 2: SGP4 propagation on survivors. The handful of candidates that pass the geometric filter are propagated with predict_passes() to find exact AOS/LOS times and maximum elevation.

The key type for queries is observer_window:

FieldTypeMeaning
obsobserverGround location (lat, lon, altitude)
t_starttimestamptzStart of observation window
t_endtimestamptzEnd of observation window
min_elfloat8Minimum elevation angle in degrees
  • Not a pass schedule. The &? operator does not compute AOS, LOS, or maximum elevation. Use predict_passes() on the candidates for precise timing.
  • No optical visibility. The filter considers only geometric visibility (above the horizon at the required elevation). It does not check whether the satellite is sunlit, whether the observer is in darkness, or whether the pass is bright enough to see. Use pass_visible() to check illumination.
  • J2-only RAAN. The RAAN filter projects the ascending node using only the J2 zonal harmonic. For short query windows (< 4 hours) the error is small. For long windows (> 12 hours) the RAAN filter automatically disables itself (full Earth rotation makes it meaningless).
CREATE TABLE catalog (
norad_id integer PRIMARY KEY,
name text NOT NULL,
tle tle NOT NULL
);
-- Insert your TLEs (from CelesTrak, Space-Track, or any provider)
-- ISS example:
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');
-- Create the SP-GiST orbital trie index
CREATE INDEX catalog_spgist ON catalog USING spgist (tle tle_spgist_ops);

Query: which satellites might be visible tonight?

Section titled “Query: which satellites might be visible tonight?”
-- Eagle, Idaho: 43.7N 116.4W, 760m elevation
-- Tonight's 6-hour window, minimum 10 deg elevation
SELECT name
FROM catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 01:00:00+00'::timestamptz,
'2024-01-01 07:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;

This query runs the three geometric filters (altitude, inclination, RAAN) on every TLE in the catalog. With the SP-GiST index, PostgreSQL prunes entire subtrees of the index without examining individual TLEs.

  1. Filter candidates with &?:

    CREATE TEMPORARY TABLE candidates AS
    SELECT norad_id, name, tle
    FROM catalog
    WHERE tle &? ROW(
    observer('43.6977N 116.3535W 760m'),
    '2024-01-01 01:00:00+00'::timestamptz,
    '2024-01-01 07:00:00+00'::timestamptz,
    10.0
    )::observer_window;

    For a 30,000-object catalog, this typically returns a few hundred candidates.

  2. Compute actual passes on survivors:

    SELECT c.name,
    (p).aos_time, (p).los_time,
    round((p).max_elevation::numeric, 1) AS max_el,
    round((p).aos_azimuth::numeric, 1) AS aos_az
    FROM candidates c,
    LATERAL predict_passes(
    c.tle,
    observer('43.6977N 116.3535W 760m'),
    '2024-01-01 01:00:00+00'::timestamptz,
    '2024-01-01 07:00:00+00'::timestamptz,
    10.0
    ) AS p
    ORDER BY (p).aos_time;

    Only the geometric survivors go through full SGP4 propagation.

  3. Filter for optical visibility (optional):

    SELECT c.name,
    (p).aos_time, (p).los_time,
    round((p).max_elevation::numeric, 1) AS max_el
    FROM candidates c,
    LATERAL predict_passes(
    c.tle,
    observer('43.6977N 116.3535W 760m'),
    '2024-01-01 01:00:00+00'::timestamptz,
    '2024-01-01 07:00:00+00'::timestamptz,
    10.0
    ) AS p
    WHERE pass_visible(c.tle, observer('43.6977N 116.3535W 760m'), (p).aos_time)
    ORDER BY (p).max_elevation DESC;

    This checks whether the satellite is sunlit while the observer is in darkness — the condition for naked-eye visibility.

-- Short window: RAAN filter is most aggressive
-- Only satellites whose orbital plane is currently
-- aligned with the observer's meridian pass through
SELECT count(*) AS candidates
FROM catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 02:00:00+00'::timestamptz,
'2024-01-01 04:00:00+00'::timestamptz,
10.0
)::observer_window;

The two index types serve different purposes and coexist on the same table:

-- SP-GiST: "which satellites could I see tonight?"
CREATE INDEX catalog_spgist ON catalog USING spgist (tle tle_spgist_ops);
-- GiST: "which satellites share an orbital shell?"
CREATE INDEX catalog_gist ON catalog USING gist (tle tle_ops);
-- Both queries use their respective indexes
SELECT name FROM catalog WHERE tle &? ROW(...)::observer_window; -- SP-GiST
SELECT name FROM catalog WHERE tle && other_tle; -- GiST