Skip to content

Conjunction Screening

Conjunction screening identifies pairs of satellites that might approach each other closely enough to pose a collision risk. The brute-force approach — computing pairwise distances for all objects in the catalog at every time step — scales as O(n^2) and is impractical for large catalogs. pg_orrery solves this with a GiST index on the tle type that enables spatial filtering by altitude band and orbital inclination, reducing the candidate set before running full propagation.

Operational conjunction screening uses several established tools and data sources:

  • STK/SOCRATES (AGI): Commercial tool that monitors the catalog and generates close-approach reports. Industry standard for satellite operators. Expensive.
  • Space-Track CDMs: The 18th Space Defense Squadron publishes Conjunction Data Messages (CDMs) for predicted close approaches. Free but requires registration and covers only US-tracked objects.
  • CelesTrak SOCRATES: Dr. Kelso’s web-based close-approach listing. Updated regularly, covers the full public catalog. Not queryable; you read reports.
  • Python scripts: Propagate the catalog in a loop, compute pairwise distances, filter by threshold. Works for small catalogs. Does not scale.

The fundamental challenge: a catalog of 66,000+ tracked objects produces over 2 billion unique pairs. Even checking each pair at a single epoch takes significant time. Checking over a 7-day window at 1-minute resolution is computationally prohibitive without pre-filtering.

pg_orrery attacks the problem in two stages:

Stage 1: GiST index reduces candidates. The GiST index on the tle column stores a 2-D key for each TLE: altitude band (perigee to apogee) and inclination range. The && operator tests whether two TLEs occupy overlapping regions in this 2-D space. Only TLEs that share an altitude shell AND a similar inclination can possibly conjunct. This typically reduces 300 million pairs to a few thousand candidates.

Stage 2: Full propagation verifies candidates. For the remaining candidates, tle_distance() computes the actual Euclidean distance between two TLEs at a given time using full SGP4/SDP4 propagation. Step through time at the required resolution and filter to close approaches.

The two operators:

OperatorTypeWhat it checks
tle && tlebooleanAltitude band AND inclination range overlap
tle <-> tlefloat82-D orbital distance in km (altitude + inclination)

The && operator is used for overlap queries (find all objects in the same shell). The <-> operator is used for nearest-neighbor queries (find the N closest objects by orbital distance, combining altitude gap with inclination gap converted to km).

  • Not a probability of collision. pg_orrery does not compute Pc (probability of collision). It identifies objects in overlapping orbital shells and computes distances at discrete time steps. For Pc calculation, use CARA (Conjunction Assessment Risk Analysis) methods.
  • No covariance propagation. SGP4 does not produce covariance matrices. The distance values have no uncertainty bounds. For operational conjunction assessment, use SP ephemerides with covariance (from CDMs or owner/operator data).
  • Orbital envelope approximation. The GiST key uses perigee-to-apogee altitude and inclination as a 2-D bounding box. The <-> distance combines both dimensions. Two TLEs can still be close in this metric and never approach because their RAANs or phases are far apart. Always follow GiST filtering with full propagation.
  • No maneuver planning. pg_orrery identifies close approaches. It does not compute avoidance maneuvers (delta-v, timing, constraints).

The workflow is: GiST narrows → tle_distance() verifies → operator/analyst decides.

Create a small catalog with satellites at different orbital regimes:

CREATE TABLE catalog (
norad_id integer PRIMARY KEY,
name text NOT NULL,
tle tle NOT NULL
);
-- ISS (LEO, ~400km, inc 51.64 deg)
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');
-- Hubble (LEO, ~540km, inc 28.47 deg)
INSERT INTO catalog VALUES (20580, 'Hubble',
'1 20580U 90037B 24001.50000000 .00000790 00000+0 39573-4 0 9992
2 20580 28.4705 61.4398 0002797 317.3115 42.7577 15.09395228 00008');
-- GPS IIR-M (MEO, ~20200km, inc 55.44 deg)
INSERT INTO catalog VALUES (28874, 'GPS-IIR',
'1 28874U 05038A 24001.50000000 .00000012 00000+0 00000+0 0 9993
2 28874 55.4408 300.3467 0117034 51.6543 309.5420 2.00557079 00006');
-- Equatorial LEO: same altitude as ISS but inc ~5 deg
INSERT INTO catalog VALUES (99901, 'Equatorial-LEO',
'1 99901U 24999A 24001.50000000 .00016717 00000-0 10270-3 0 9990
2 99901 5.0000 208.9163 0006703 30.1694 61.7520 15.50100486 00001');
CREATE INDEX catalog_orbit_gist ON catalog USING gist (tle);

The index builds in milliseconds for a small table. For a full 66,440-object catalog, build time is 93 ms (15 MB index).

Before screening, inspect the orbital characteristics of the catalog:

SELECT name,
round(tle_perigee(tle)::numeric, 0) AS perigee_km,
round(tle_apogee(tle)::numeric, 0) AS apogee_km,
round(tle_inclination(tle)::numeric, 1) AS inc_deg,
round(tle_period(tle)::numeric, 1) AS period_min
FROM catalog
ORDER BY tle_perigee(tle);

Find all pairs of satellites in overlapping orbital shells:

SELECT a.name AS sat_a,
b.name AS sat_b,
a.tle && b.tle AS overlaps
FROM catalog a, catalog b
WHERE a.norad_id < b.norad_id
ORDER BY a.name, b.name;

Key insight: ISS and Equatorial-LEO are at the same altitude but different inclinations. The && operator returns false for this pair because the 2-D key requires overlap in BOTH altitude AND inclination. Two objects at the same altitude but in very different orbital planes are unlikely to conjunct.

The <-> operator returns the 2-D orbital distance in km, combining altitude-band separation with inclination gap (converted to km via Earth radius):

SELECT a.name AS sat_a,
b.name AS sat_b,
round((a.tle <-> b.tle)::numeric, 0) AS orbital_dist_km
FROM catalog a, catalog b
WHERE a.norad_id < b.norad_id
ORDER BY a.tle <-> b.tle;

ISS and Equatorial-LEO show ~5192 km (0 km altitude gap, but 47° inclination difference × 6378 km/rad). ISS and Hubble show ~2582 km (115 km altitude gap + 23° inclination difference). ISS and GPS show ~19,456 km (altitude gap dominates).

Force the query planner to use the index and find all objects in the same shell as the ISS:

SET enable_seqscan = off;
SELECT name
FROM catalog
WHERE tle && (SELECT tle FROM catalog WHERE norad_id = 25544)
ORDER BY name;
RESET enable_seqscan;

This should return only ISS itself (and not Equatorial-LEO, which has a different inclination). The GiST index scan avoids checking every object in the catalog.

Find the 3 closest objects to the ISS by 2-D orbital distance, ordered by distance:

-- Scalar subquery probe enables GiST index-ordered scan
SELECT name,
round((tle <-> (SELECT tle FROM catalog WHERE norad_id = 25544 LIMIT 1))::numeric, 0)
AS orbital_dist_km
FROM catalog
WHERE norad_id != 25544
ORDER BY tle <-> (SELECT tle FROM catalog WHERE norad_id = 25544 LIMIT 1)
LIMIT 3;

This uses the GiST distance operator for efficient ordering. The 2-D metric means satellites at the same altitude but wildly different inclinations no longer tie at distance 0 --- Hubble (inc 28°, 115 km altitude gap) ranks ahead of an equatorial LEO object (inc 5°, 0 km altitude gap but 47° inclination difference). PostgreSQL’s KNN-GiST infrastructure traverses the tree by increasing distance without computing all distances upfront. On a 66,440-object catalog, this completes in 2.1 ms for 10 neighbors.

Every TLE overlaps with itself:

SELECT name,
tle && tle AS self_overlap
FROM catalog
ORDER BY name;

All rows should return true.

The complete two-stage workflow for a larger catalog:

  1. Build the catalog and index:

    -- Assuming your catalog table is already populated from CelesTrak or Space-Track
    CREATE INDEX IF NOT EXISTS catalog_orbit_gist ON catalog USING gist (tle);
  2. Stage 1: GiST filter to find candidates for a target satellite:

    CREATE TEMPORARY TABLE candidates AS
    SELECT c.norad_id, c.name, c.tle
    FROM catalog c
    WHERE c.tle && (SELECT tle FROM catalog WHERE norad_id = 25544)
    AND c.norad_id != 25544;

    For the ISS in a 66,440-object catalog, this returns 9 candidates (all co-orbital vehicles: visiting spacecraft, modules, and debris). The GiST index scan completes in 4.6 ms vs. 63.3 ms for a sequential scan.

  3. Stage 2: Time-resolved distance computation:

    WITH iss AS (
    SELECT tle FROM catalog WHERE norad_id = 25544
    )
    SELECT c.name,
    t AS check_time,
    round(tle_distance(iss.tle, c.tle, t)::numeric, 1) AS dist_km
    FROM candidates c, iss,
    generate_series(
    '2024-01-01 00:00:00+00'::timestamptz,
    '2024-01-02 00:00:00+00'::timestamptz,
    interval '1 minute'
    ) AS t
    WHERE tle_distance(iss.tle, c.tle, t) < 25.0
    ORDER BY dist_km;

    This propagates each candidate pair at 1-minute resolution over 24 hours and filters to approaches within 25 km. Only the GiST candidates are checked, not the full catalog.

  4. Review results and take action.

    The output lists object name, time of closest approach, and distance. An analyst or automated system decides whether to issue a CDM, plan a maneuver, or accept the risk.

Extend the workflow to screen for conjunctions between any pair of objects in a subset:

-- All pairs in the LEO catalog (tle_perigee < 2000 km) that share an orbital shell
SELECT a.name AS sat_a,
b.name AS sat_b,
round((a.tle <-> b.tle)::numeric, 0) AS alt_sep_km,
round(tle_distance(a.tle, b.tle, '2024-01-01 12:00:00+00')::numeric, 0) AS actual_dist_km
FROM catalog a, catalog b
WHERE a.norad_id < b.norad_id
AND a.tle && b.tle
AND tle_perigee(a.tle) < 2000
AND tle_perigee(b.tle) < 2000
ORDER BY actual_dist_km;

Run a conjunction check at regular intervals and store results for trend analysis:

CREATE TABLE conjunction_events (
id serial PRIMARY KEY,
sat_a integer NOT NULL,
sat_b integer NOT NULL,
event_time timestamptz NOT NULL,
dist_km float8 NOT NULL,
checked_at timestamptz DEFAULT now()
);
-- Periodic screening job (run daily or as needed)
INSERT INTO conjunction_events (sat_a, sat_b, event_time, dist_km)
WITH iss AS (
SELECT norad_id, tle FROM catalog WHERE norad_id = 25544
)
SELECT iss.norad_id, c.norad_id, t, tle_distance(iss.tle, c.tle, t)
FROM catalog c, iss,
generate_series(
now(),
now() + interval '7 days',
interval '5 minutes'
) AS t
WHERE c.tle && iss.tle
AND c.norad_id != iss.norad_id
AND tle_distance(iss.tle, c.tle, t) < 50.0;

This builds a history of close approaches that you can query, trend, and alert on. The GiST filter ensures it runs efficiently even against a full catalog.