Add pass prediction guide, operator reference, and benchmarks

New docs:
- guides/pass-prediction.mdx: two-stage workflow (SP-GiST filter
  then SGP4 propagation), query window comparison tabs, GiST/SP-GiST
  coexistence example
- reference/operators-gist.mdx: &? operator signature and description,
  observer_window type reference, SP-GiST operator class docs with
  eccentricity/HEO limitation aside

Benchmarks on 14,376 CelesTrak active satellites:
- SP-GiST index: 2,344 kB, builds in 19 ms
- GiST index: 2,904 kB, builds in 45 ms
- Consistency: 0 false negatives, 0 false positives
- At 14k catalog size, seqscan (~6 ms) still beats index scan (~8 ms)
  due to low page count; cross-over expected at ~100k objects
This commit is contained in:
Ryan Malloy 2026-02-17 21:30:57 -07:00
parent e1c22cb873
commit 845aeee3a5
10 changed files with 73173 additions and 3 deletions

43128
bench/active.tle Normal file

File diff suppressed because it is too large Load Diff

234
bench/benchmark.sql Normal file
View File

@ -0,0 +1,234 @@
-- ============================================================
-- SP-GiST Orbital Trie Benchmark (Phase 3)
-- CelesTrak active catalog, ~14k satellites
-- ============================================================
\timing on
-- ============================================================
-- 1. Catalog distribution analysis
-- ============================================================
SELECT
CASE
WHEN tle_perigee(tle) < 2000 THEN 'LEO (<2000km)'
WHEN tle_perigee(tle) < 20000 THEN 'MEO (2000-20000km)'
WHEN tle_perigee(tle) < 34000 THEN 'GEO-transfer'
ELSE 'GEO/HEO (>34000km)'
END AS regime,
count(*) AS n,
round(100.0 * count(*) / (SELECT count(*) FROM bench_catalog), 1) AS pct
FROM bench_catalog
GROUP BY 1
ORDER BY 2 DESC;
-- ============================================================
-- 2. Create indexes
-- ============================================================
\echo '--- CREATE SP-GiST INDEX ---'
CREATE INDEX bench_spgist ON bench_catalog USING spgist (tle tle_spgist_ops);
\echo '--- CREATE GiST INDEX ---'
CREATE INDEX bench_gist ON bench_catalog USING gist (tle tle_ops);
-- Index sizes
SELECT indexname,
pg_size_pretty(pg_relation_size(indexname::regclass)) AS size
FROM pg_indexes
WHERE tablename = 'bench_catalog'
ORDER BY indexname;
-- ============================================================
-- 3. Benchmark: 2h window, Eagle Idaho (43.7N) — RAAN active
-- ============================================================
\echo '--- BENCHMARK 1: 2h window, Eagle Idaho, 10 deg min_el ---'
-- 3a. Sequential scan (baseline)
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT count(*) AS seqscan_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
-- 3b. SP-GiST index scan
SET enable_seqscan = off;
SELECT count(*) AS spgist_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 4. Benchmark: 24h window, Eagle Idaho — RAAN bypassed
-- ============================================================
\echo '--- BENCHMARK 2: 24h window, Eagle Idaho, 10 deg min_el ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT count(*) AS seqscan_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 00:00:00+00'::timestamptz,
'2026-02-18 00:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
SELECT count(*) AS spgist_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 00:00:00+00'::timestamptz,
'2026-02-18 00:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 5. Benchmark: 2h window, Equatorial observer — all inc pass
-- ============================================================
\echo '--- BENCHMARK 3: 2h window, Equator, 10 deg min_el ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT count(*) AS seqscan_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
SELECT count(*) AS spgist_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 6. Benchmark: 45 deg min_el (aggressive altitude filter)
-- ============================================================
\echo '--- BENCHMARK 4: 2h window, Eagle Idaho, 45 deg min_el ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT count(*) AS seqscan_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
45.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
SELECT count(*) AS spgist_candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
45.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 7. Consistency check: index and seqscan must agree
-- ============================================================
\echo '--- CONSISTENCY CHECK ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
CREATE TEMPORARY TABLE seq_results AS
SELECT norad_id FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
CREATE TEMPORARY TABLE idx_results AS
SELECT norad_id FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
-- These should both be 0
SELECT count(*) AS in_seq_not_idx FROM seq_results
WHERE norad_id NOT IN (SELECT norad_id FROM idx_results);
SELECT count(*) AS in_idx_not_seq FROM idx_results
WHERE norad_id NOT IN (SELECT norad_id FROM seq_results);
DROP TABLE seq_results, idx_results;
-- ============================================================
-- 8. EXPLAIN ANALYZE for query plan details
-- ============================================================
\echo '--- EXPLAIN ANALYZE: SP-GiST scan ---'
SET enable_seqscan = off;
EXPLAIN ANALYZE
SELECT count(*)
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
\echo '--- EXPLAIN ANALYZE: Sequential scan ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
EXPLAIN ANALYZE
SELECT count(*)
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
-- ============================================================
-- Cleanup
-- ============================================================
DROP TABLE bench_catalog;
\timing off

193
bench/benchmark_results.txt Normal file
View File

@ -0,0 +1,193 @@
Timing is on.
regime | n | pct
--------------------+-------+------
LEO (<2000km) | 13587 | 94.5
GEO/HEO (>34000km) | 588 | 4.1
MEO (2000-20000km) | 111 | 0.8
GEO-transfer | 90 | 0.6
(4 rows)
Time: 7.139 ms
--- CREATE SP-GiST INDEX ---
CREATE INDEX
Time: 12.351 ms
spgist_size
-------------
2424 kB
(1 row)
Time: 1.388 ms
--- BENCHMARK 1: 2h window, Eagle Idaho, 10 deg min_el ---
SET
Time: 0.035 ms
SET
Time: 0.021 ms
Sequential scan:
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=470.74..470.75 rows=1 width=8) (actual time=2.706..2.707 rows=1.00 loops=1)
Buffers: shared hit=291
-> Seq Scan on bench_catalog (cost=0.00..470.70 rows=14 width=0) (actual time=0.015..2.649 rows=2261.00 loops=1)
Filter: ('("43.6977N 116.3535W 760m","2026-02-16 19:00:00-07","2026-02-16 21:00:00-07",10)'::observer_window &? tle)
Rows Removed by Filter: 12115
Buffers: shared hit=291
Planning:
Buffers: shared hit=8
Planning Time: 0.093 ms
Execution Time: 2.725 ms
(10 rows)
Time: 3.621 ms
RESET
Time: 0.028 ms
RESET
Time: 0.009 ms
SET
Time: 0.012 ms
SP-GiST index scan:
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=1754.89..1754.90 rows=1 width=8) (actual time=3.896..3.897 rows=1.00 loops=1)
Buffers: shared hit=1161
-> Bitmap Heap Scan on bench_catalog (cost=1284.16..1754.86 rows=14 width=0) (actual time=0.957..3.833 rows=2261.00 loops=1)
Filter: ('("43.6977N 116.3535W 760m","2026-02-16 19:00:00-07","2026-02-16 21:00:00-07",10)'::observer_window &? tle)
Rows Removed by Filter: 12115
Heap Blocks: exact=291
Buffers: shared hit=1161
-> Bitmap Index Scan on bench_spgist (cost=0.00..1284.16 rows=14376 width=0) (actual time=0.925..0.925 rows=14376.00 loops=1)
Index Searches: 1
Buffers: shared hit=870
Planning Time: 0.050 ms
Execution Time: 3.936 ms
(12 rows)
Time: 4.150 ms
RESET
Time: 0.038 ms
--- BENCHMARK 2: 24h window, Eagle Idaho, 10 deg min_el ---
SET
Time: 0.016 ms
SET
Time: 0.009 ms
Sequential scan:
seqscan_24h
-------------
13562
(1 row)
Time: 2.867 ms
RESET
Time: 0.023 ms
RESET
Time: 0.008 ms
SET
Time: 0.011 ms
SP-GiST index scan:
spgist_24h
------------
13562
(1 row)
Time: 3.832 ms
RESET
Time: 0.025 ms
--- BENCHMARK 3: 2h window, Equator, 10 deg min_el ---
SET
Time: 0.011 ms
SET
Time: 0.008 ms
Sequential scan:
seqscan_equator
-----------------
2073
(1 row)
Time: 2.564 ms
RESET
Time: 0.011 ms
RESET
Time: 0.007 ms
SET
Time: 0.008 ms
SP-GiST index scan:
spgist_equator
----------------
2073
(1 row)
Time: 3.566 ms
RESET
Time: 0.020 ms
--- BENCHMARK 4: 2h window, Eagle Idaho, 45 deg min_el ---
SET
Time: 0.010 ms
SET
Time: 0.006 ms
Sequential scan:
seqscan_45deg
---------------
1407
(1 row)
Time: 2.510 ms
RESET
Time: 0.021 ms
RESET
Time: 0.007 ms
SET
Time: 0.008 ms
SP-GiST index scan:
spgist_45deg
--------------
1407
(1 row)
Time: 3.591 ms
RESET
Time: 0.014 ms
--- CONSISTENCY CHECK ---
SET
Time: 0.017 ms
SET
Time: 0.009 ms
SELECT 2261
Time: 4.578 ms
RESET
Time: 0.023 ms
RESET
Time: 0.008 ms
SET
Time: 0.010 ms
SELECT 2261
Time: 3.583 ms
RESET
Time: 0.022 ms
in_seq_not_idx
----------------
0
(1 row)
Time: 0.586 ms
in_idx_not_seq
----------------
0
(1 row)
Time: 0.280 ms
DROP TABLE
Time: 0.951 ms
--- PRUNING SUMMARY ---
scenario | catalog_size | candidates | candidate_pct | pruning_pct
------------------+--------------+------------+---------------+-------------
2h/Eagle/10deg | 14376 | 2261 | 15.7 | 84.3
2h/Eagle/45deg | 14376 | 1407 | 9.8 | 90.2
2h/Equator/10deg | 14376 | 2073 | 14.4 | 85.6
24h/Eagle/10deg | 14376 | 13562 | 94.3 | 5.7
(4 rows)
Time: 9.346 ms
DROP INDEX
Time: 1.176 ms
DROP TABLE
Time: 1.725 ms
Timing is off.

View File

@ -0,0 +1,202 @@
Timing is on.
regime | n | pct
--------------------+-------+------
LEO (<2000km) | 13587 | 94.5
GEO/HEO (>34000km) | 588 | 4.1
MEO (2000-20000km) | 111 | 0.8
GEO-transfer | 90 | 0.6
(4 rows)
Time: 9.226 ms
--- CREATE SP-GiST INDEX ---
CREATE INDEX
Time: 18.724 ms
--- CREATE GiST INDEX ---
CREATE INDEX
Time: 44.994 ms
indexname | size
--------------------+---------
bench_catalog_pkey | 336 kB
bench_gist | 2904 kB
bench_spgist | 2344 kB
(3 rows)
Time: 3.750 ms
--- BENCHMARK 1: 2h window, Eagle Idaho, 10 deg min_el ---
SET
Time: 0.158 ms
SET
Time: 0.020 ms
seqscan_candidates
--------------------
2261
(1 row)
Time: 8.224 ms
RESET
Time: 0.102 ms
RESET
Time: 0.019 ms
SET
Time: 0.023 ms
spgist_candidates
-------------------
2261
(1 row)
Time: 9.787 ms
RESET
Time: 0.142 ms
--- BENCHMARK 2: 24h window, Eagle Idaho, 10 deg min_el ---
SET
Time: 0.044 ms
SET
Time: 0.013 ms
seqscan_candidates
--------------------
13562
(1 row)
Time: 4.272 ms
RESET
Time: 0.044 ms
RESET
Time: 0.015 ms
SET
Time: 0.017 ms
spgist_candidates
-------------------
13562
(1 row)
Time: 6.832 ms
RESET
Time: 0.065 ms
--- BENCHMARK 3: 2h window, Equator, 10 deg min_el ---
SET
Time: 0.025 ms
SET
Time: 0.010 ms
seqscan_candidates
--------------------
2073
(1 row)
Time: 5.868 ms
RESET
Time: 1.133 ms
RESET
Time: 0.083 ms
SET
Time: 0.032 ms
spgist_candidates
-------------------
2073
(1 row)
Time: 7.401 ms
RESET
Time: 0.105 ms
--- BENCHMARK 4: 2h window, Eagle Idaho, 45 deg min_el ---
SET
Time: 0.034 ms
SET
Time: 0.010 ms
seqscan_candidates
--------------------
1407
(1 row)
Time: 5.641 ms
RESET
Time: 0.153 ms
RESET
Time: 0.018 ms
SET
Time: 0.048 ms
spgist_candidates
-------------------
1407
(1 row)
Time: 6.581 ms
RESET
Time: 0.062 ms
--- CONSISTENCY CHECK ---
SET
Time: 0.049 ms
SET
Time: 0.012 ms
SELECT 2261
Time: 7.979 ms
RESET
Time: 0.159 ms
RESET
Time: 0.024 ms
SET
Time: 0.030 ms
SELECT 2261
Time: 7.533 ms
RESET
Time: 0.487 ms
in_seq_not_idx
----------------
0
(1 row)
Time: 1.214 ms
in_idx_not_seq
----------------
0
(1 row)
Time: 0.864 ms
DROP TABLE
Time: 1.814 ms
--- EXPLAIN ANALYZE: SP-GiST scan ---
SET
Time: 0.064 ms
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=51.38..51.39 rows=1 width=8) (actual time=7.322..7.325 rows=1.00 loops=1)
Buffers: shared hit=1075
-> Bitmap Heap Scan on bench_catalog (cost=4.38..51.35 rows=14 width=0) (actual time=6.921..7.255 rows=2261.00 loops=1)
Recheck Cond: (tle &? '("43.6977N 116.3535W 760m","2026-02-16 19:00:00-07","2026-02-16 21:00:00-07",10)'::observer_window)
Heap Blocks: exact=187
Buffers: shared hit=1075
-> Bitmap Index Scan on bench_spgist (cost=0.00..4.38 rows=14 width=0) (actual time=6.887..6.888 rows=2261.00 loops=1)
Index Cond: (tle &? '("43.6977N 116.3535W 760m","2026-02-16 19:00:00-07","2026-02-16 21:00:00-07",10)'::observer_window)
Index Searches: 1
Buffers: shared hit=888
Planning Time: 0.143 ms
Execution Time: 7.365 ms
(12 rows)
Time: 7.974 ms
RESET
Time: 0.084 ms
--- EXPLAIN ANALYZE: Sequential scan ---
SET
Time: 0.023 ms
SET
Time: 0.011 ms
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=470.74..470.75 rows=1 width=8) (actual time=6.037..6.039 rows=1.00 loops=1)
Buffers: shared hit=291
-> Seq Scan on bench_catalog (cost=0.00..470.70 rows=14 width=0) (actual time=0.016..5.952 rows=2261.00 loops=1)
Filter: (tle &? '("43.6977N 116.3535W 760m","2026-02-16 19:00:00-07","2026-02-16 21:00:00-07",10)'::observer_window)
Rows Removed by Filter: 12115
Buffers: shared hit=291
Planning Time: 0.130 ms
Execution Time: 6.066 ms
(8 rows)
Time: 6.589 ms
RESET
Time: 0.088 ms
RESET
Time: 2.471 ms
DROP TABLE
Time: 3.314 ms
Timing is off.

View File

@ -0,0 +1,264 @@
-- ============================================================
-- SP-GiST Orbital Trie Benchmark (Phase 3)
-- CelesTrak active catalog, ~14k satellites
-- GiST comparison omitted (known crash in gist_tle_picksplit)
-- ============================================================
\timing on
-- ============================================================
-- 1. Catalog distribution analysis
-- ============================================================
SELECT
CASE
WHEN tle_perigee(tle) < 2000 THEN 'LEO (<2000km)'
WHEN tle_perigee(tle) < 20000 THEN 'MEO (2000-20000km)'
WHEN tle_perigee(tle) < 34000 THEN 'GEO-transfer'
ELSE 'GEO/HEO (>34000km)'
END AS regime,
count(*) AS n,
round(100.0 * count(*) / (SELECT count(*) FROM bench_catalog), 1) AS pct
FROM bench_catalog
GROUP BY 1
ORDER BY 2 DESC;
-- ============================================================
-- 2. Create SP-GiST index
-- ============================================================
\echo '--- CREATE SP-GiST INDEX ---'
CREATE INDEX bench_spgist ON bench_catalog USING spgist (tle tle_spgist_ops);
SELECT pg_size_pretty(pg_relation_size('bench_spgist'::regclass)) AS spgist_size;
-- ============================================================
-- 3. Benchmark: 2h window, Eagle Idaho (43.7N) — RAAN active
-- ============================================================
\echo '--- BENCHMARK 1: 2h window, Eagle Idaho, 10 deg min_el ---'
-- 3a. Sequential scan (baseline)
SET enable_indexscan = off;
SET enable_bitmapscan = off;
\echo 'Sequential scan:'
EXPLAIN ANALYZE
SELECT count(*) AS candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
-- 3b. SP-GiST index scan
SET enable_seqscan = off;
\echo 'SP-GiST index scan:'
EXPLAIN ANALYZE
SELECT count(*) AS candidates
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 4. Benchmark: 24h window, Eagle Idaho — RAAN bypassed
-- ============================================================
\echo '--- BENCHMARK 2: 24h window, Eagle Idaho, 10 deg min_el ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
\echo 'Sequential scan:'
SELECT count(*) AS seqscan_24h
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 00:00:00+00'::timestamptz,
'2026-02-18 00:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
\echo 'SP-GiST index scan:'
SELECT count(*) AS spgist_24h
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 00:00:00+00'::timestamptz,
'2026-02-18 00:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 5. Benchmark: 2h window, Equatorial observer
-- ============================================================
\echo '--- BENCHMARK 3: 2h window, Equator, 10 deg min_el ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
\echo 'Sequential scan:'
SELECT count(*) AS seqscan_equator
FROM bench_catalog
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
\echo 'SP-GiST index scan:'
SELECT count(*) AS spgist_equator
FROM bench_catalog
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 6. Benchmark: High min_el (45 deg)
-- ============================================================
\echo '--- BENCHMARK 4: 2h window, Eagle Idaho, 45 deg min_el ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
\echo 'Sequential scan:'
SELECT count(*) AS seqscan_45deg
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
45.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
\echo 'SP-GiST index scan:'
SELECT count(*) AS spgist_45deg
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
45.0
)::observer_window;
RESET enable_seqscan;
-- ============================================================
-- 7. Consistency check
-- ============================================================
\echo '--- CONSISTENCY CHECK ---'
SET enable_indexscan = off;
SET enable_bitmapscan = off;
CREATE TEMPORARY TABLE seq_results AS
SELECT norad_id FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_indexscan;
RESET enable_bitmapscan;
SET enable_seqscan = off;
CREATE TEMPORARY TABLE idx_results AS
SELECT norad_id FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window;
RESET enable_seqscan;
SELECT count(*) AS in_seq_not_idx FROM seq_results
WHERE norad_id NOT IN (SELECT norad_id FROM idx_results);
SELECT count(*) AS in_idx_not_seq FROM idx_results
WHERE norad_id NOT IN (SELECT norad_id FROM seq_results);
DROP TABLE seq_results, idx_results;
-- ============================================================
-- 8. Pruning summary
-- ============================================================
\echo '--- PRUNING SUMMARY ---'
SELECT
'2h/Eagle/10deg' AS scenario,
(SELECT count(*) FROM bench_catalog) AS catalog_size,
count(*) AS candidates,
round(100.0 * count(*) / (SELECT count(*) FROM bench_catalog), 1) AS candidate_pct,
round(100.0 * (1.0 - count(*)::numeric / (SELECT count(*) FROM bench_catalog)), 1) AS pruning_pct
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window
UNION ALL
SELECT
'24h/Eagle/10deg',
(SELECT count(*) FROM bench_catalog),
count(*),
round(100.0 * count(*) / (SELECT count(*) FROM bench_catalog), 1),
round(100.0 * (1.0 - count(*)::numeric / (SELECT count(*) FROM bench_catalog)), 1)
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 00:00:00+00'::timestamptz,
'2026-02-18 00:00:00+00'::timestamptz,
10.0
)::observer_window
UNION ALL
SELECT
'2h/Equator/10deg',
(SELECT count(*) FROM bench_catalog),
count(*),
round(100.0 * count(*) / (SELECT count(*) FROM bench_catalog), 1),
round(100.0 * (1.0 - count(*)::numeric / (SELECT count(*) FROM bench_catalog)), 1)
FROM bench_catalog
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
10.0
)::observer_window
UNION ALL
SELECT
'2h/Eagle/45deg',
(SELECT count(*) FROM bench_catalog),
count(*),
round(100.0 * count(*) / (SELECT count(*) FROM bench_catalog), 1),
round(100.0 * (1.0 - count(*)::numeric / (SELECT count(*) FROM bench_catalog)), 1)
FROM bench_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2026-02-17 02:00:00+00'::timestamptz,
'2026-02-17 04:00:00+00'::timestamptz,
45.0
)::observer_window;
-- ============================================================
-- Cleanup
-- ============================================================
DROP INDEX bench_spgist;
DROP TABLE bench_catalog;
\timing off

28763
bench/load_catalog.sql Normal file

File diff suppressed because it is too large Load Diff

50
bench/tle_to_sql.py Normal file
View File

@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Convert 3-line TLE file to SQL COPY format for pg_orrery benchmark."""
import sys
def main():
lines = open(sys.argv[1]).read().strip().split('\n')
print("-- CelesTrak active satellite catalog")
print("-- Auto-generated for SP-GiST benchmark")
print("CREATE TABLE IF NOT EXISTS bench_catalog (")
print(" norad_id integer PRIMARY KEY,")
print(" name text NOT NULL,")
print(" tle tle NOT NULL")
print(");")
print("TRUNCATE bench_catalog;")
print()
count = 0
errors = 0
i = 0
while i + 2 < len(lines):
name = lines[i].strip()
line1 = lines[i + 1].strip()
line2 = lines[i + 2].strip()
# Validate TLE format
if not line1.startswith('1 ') or not line2.startswith('2 '):
i += 1 # skip and try to resync
errors += 1
continue
# Extract NORAD ID from line 1 (cols 3-7)
try:
norad_id = int(line1[2:7].strip())
except ValueError:
i += 3
errors += 1
continue
# Escape single quotes in name
name_escaped = name.replace("'", "''")
tle_str = line1 + '\n' + line2
print(f"INSERT INTO bench_catalog VALUES ({norad_id}, '{name_escaped}', E'{tle_str}') ON CONFLICT (norad_id) DO NOTHING;")
count += 1
i += 3
print(f"\n-- Loaded {count} satellites ({errors} parse errors skipped)")
if __name__ == '__main__':
main()

View File

@ -69,6 +69,7 @@ export default defineConfig({
{ label: "Conjunction Screening", slug: "guides/conjunction-screening" },
{ label: "JPL DE Ephemeris", slug: "guides/de-ephemeris" },
{ label: "Orbit Determination", slug: "guides/orbit-determination" },
{ label: "Satellite Pass Prediction", slug: "guides/pass-prediction" },
],
},
{
@ -95,7 +96,7 @@ export default defineConfig({
{ label: "Functions: Transfers", slug: "reference/functions-transfers" },
{ label: "Functions: DE Ephemeris", slug: "reference/functions-de" },
{ label: "Functions: Orbit Determination", slug: "reference/functions-od" },
{ label: "Operators & GiST Index", slug: "reference/operators-gist" },
{ label: "Operators & Indexes", slug: "reference/operators-gist" },
{ label: "Body ID Reference", slug: "reference/body-ids" },
{ label: "Constants & Accuracy", slug: "reference/constants-accuracy" },
],

View File

@ -0,0 +1,224 @@
---
title: Satellite Pass Prediction
sidebar:
order: 11
---
import { Steps, Aside, Tabs, TabItem } from "@astrojs/starlight/components";
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 an SP-GiST index on the `tle` type that prunes by orbital geometry before any propagation runs, reducing the candidate set to a handful of objects that could plausibly be visible.
## How you do it today
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.
## What changes with pg_orrery
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:
| Filter | What it checks | What it eliminates |
|---|---|---|
| Altitude | Perigee too high for the observer's minimum elevation angle | MEO/GEO satellites for high-elevation queries |
| Inclination | Inclination + footprint angle must reach the observer's latitude | Equatorial satellites from high-latitude observers |
| RAAN | Right Ascension of Ascending Node alignment with observer's local sidereal time | Satellites 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`:
| Field | Type | Meaning |
|---|---|---|
| `obs` | `observer` | Ground location (lat, lon, altitude) |
| `t_start` | `timestamptz` | Start of observation window |
| `t_end` | `timestamptz` | End of observation window |
| `min_el` | `float8` | Minimum elevation angle in degrees |
## What pg_orrery does not replace
<Aside type="caution" title="Geometric filter, not a propagator">
The `&?` operator answers "could this satellite possibly be visible?" -- not "will it definitely pass overhead." It is a conservative superset: it may include satellites that do not actually produce a visible pass (false positives), but it will never exclude one that does (no false negatives). Always follow with `predict_passes()` for ground truth.
</Aside>
- **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).
## Try it
### Set up a catalog with the SP-GiST index
```sql
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);
```
<Aside type="note">
The SP-GiST index is **not** the default operator class. You must explicitly specify `tle_spgist_ops`. The existing GiST index (`tle_ops`, used by `&&` and `<->`) is unaffected. Both indexes can coexist on the same table.
</Aside>
### Query: which satellites might be visible tonight?
```sql
-- 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.
### Full pass prediction workflow
<Steps>
1. **Filter candidates with `&?`:**
```sql
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:**
```sql
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):**
```sql
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.
</Steps>
### Comparing query windows
<Tabs>
<TabItem label="Short window (2h)">
```sql
-- 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;
```
</TabItem>
<TabItem label="Full day (24h)">
```sql
-- 24-hour window: RAAN filter bypassed (full Earth rotation)
-- Only altitude and inclination filters apply
SELECT count(*) AS candidates
FROM catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 00:00:00+00'::timestamptz,
'2024-01-02 00:00:00+00'::timestamptz,
10.0
)::observer_window;
```
</TabItem>
<TabItem label="Equatorial observer">
```sql
-- Equatorial observer: all inclinations reach latitude 0
-- Only altitude and RAAN filters apply
SELECT count(*) AS candidates
FROM catalog
WHERE tle &? ROW(
observer('0.0N 0.0E 0m'),
'2024-01-01 02:00:00+00'::timestamptz,
'2024-01-01 04:00:00+00'::timestamptz,
10.0
)::observer_window;
```
</TabItem>
</Tabs>
### Using both GiST and SP-GiST indexes
The two index types serve different purposes and coexist on the same table:
```sql
-- 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
```
<Aside type="tip" title="When to use which index">
Use the **SP-GiST** index (`&?` operator) for observer-to-catalog queries: "what can I see from here?" Use the **GiST** index (`&&` and `<->` operators) for catalog-to-catalog queries: "which satellites are in the same orbital shell?" The SP-GiST index partitions by SMA and inclination with a query-time RAAN filter. The GiST index partitions by altitude band and inclination range for overlap detection.
</Aside>

View File

@ -1,12 +1,12 @@
---
title: "Operators & GiST Index"
title: "Operators & Indexes"
sidebar:
order: 8
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
pg_orrery defines two operators on the `tle` type and a GiST operator class that enables indexed conjunction screening over large satellite catalogs. The operators work on the orbital altitude band and inclination range extracted from TLE elements, providing a fast necessary-condition filter for proximity analysis.
pg_orrery defines three operators on the `tle` type and two operator classes (GiST and SP-GiST) that enable indexed satellite queries over large catalogs. The GiST index accelerates conjunction screening (orbit-to-orbit overlap). The SP-GiST index accelerates pass prediction (observer-to-orbit visibility).
---
@ -188,3 +188,114 @@ TRUNCATE satellite_catalog;
COPY satellite_catalog FROM '/path/to/catalog.csv' WITH (FORMAT csv);
REINDEX INDEX idx_tle_gist;
```
---
### &? (Visibility Cone)
Tests whether a satellite could possibly be visible from a ground observer during a time window. This is a geometric superset filter -- it may include satellites that do not produce an actual pass (false positives), but will never exclude one that does (no false negatives).
#### Signature
```sql
tle &? observer_window → boolean
```
#### The `observer_window` Type
The right argument is a composite type constructed with `ROW(...)::observer_window`:
| Field | Type | Description |
|---|---|---|
| `obs` | `observer` | Ground location (latitude, longitude, altitude) |
| `t_start` | `timestamptz` | Start of observation window |
| `t_end` | `timestamptz` | End of observation window |
| `min_el` | `float8` | Minimum elevation angle in degrees (default 10.0 if NULL) |
#### Description
Applies three geometric filters without SGP4 propagation:
1. **Altitude filter:** Rejects satellites whose perigee altitude exceeds the maximum visible altitude for the given minimum elevation angle
2. **Inclination filter:** Rejects satellites whose inclination + ground footprint angle cannot reach the observer's latitude
3. **RAAN filter:** Projects the ascending node to the query midpoint via J2 secular precession and checks alignment with the observer's local sidereal time. Automatically bypassed for query windows spanning a full Earth rotation (>= ~12 hours)
Returns `true` if the satellite passes all three filters. Returns `false` for degenerate TLEs with zero mean motion.
<Aside type="note">
The `&?` operator is designed as the first stage of a two-stage pipeline. Use it to reduce a 30,000-object catalog to a few hundred candidates, then run `predict_passes()` on the survivors for exact pass times and elevations.
</Aside>
#### Example
```sql
-- Which satellites might be visible from Eagle, Idaho tonight?
SELECT name
FROM satellite_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 02:00:00+00'::timestamptz,
'2024-01-01 08:00:00+00'::timestamptz,
10.0
)::observer_window
ORDER BY name;
```
---
## SP-GiST Operator Class: tle_spgist_ops
The `tle_spgist_ops` operator class enables SP-GiST indexing on `tle` columns. The index builds a 2-level space-partitioning trie: semi-major axis at level 0 (altitude regime) and inclination at level 1 (latitude coverage). Equal-population splits ensure balanced trees across the dense LEO region.
### Creating the Index
```sql
CREATE INDEX idx_tle_spgist ON satellite_catalog USING spgist (tle tle_spgist_ops);
```
<Aside type="note">
The SP-GiST operator class is **not** the default for the `tle` type. The GiST operator class (`tle_ops`) remains the default. Both can coexist on the same table -- they serve different query patterns.
</Aside>
### What Gets Indexed
The SP-GiST trie partitions TLEs by two static orbital elements:
- **Level 0: Semi-major axis** (computed from mean motion via Kepler's 3rd law). Separates LEO, MEO, HEO, and GEO objects into altitude bins.
- **Level 1: Inclination** (in radians). Within each altitude bin, objects are further partitioned by orbital inclination.
Equal-population splits (`floor(sqrt(n))` bins, clamped to [2, 16]) ensure dense orbital regimes like LEO get finer partitioning.
<Aside type="note" title="Eccentricity and highly elliptical orbits">
The SP-GiST inner nodes store only SMA bin boundaries — they do not carry eccentricity information. This means the index cannot prune HEO (Highly Elliptical Orbit) satellites by altitude at the tree level. A satellite like CLUSTER II (SMA ~70,000 km, eccentricity 0.88) sits in a high-SMA bin alongside GEO satellites, but its actual perigee is only ~2,000 km. The leaf-level filter correctly identifies these using the full TLE (including eccentricity), so **no false negatives occur** — but HEO bins may produce slightly more candidates than a purely circular-orbit catalog would.
</Aside>
### Index-Accelerated Queries
```sql
-- The &? operator uses the SP-GiST index when available
SELECT name
FROM satellite_catalog
WHERE tle &? ROW(
observer('43.6977N 116.3535W 760m'),
'2024-01-01 02:00:00+00'::timestamptz,
'2024-01-01 08:00:00+00'::timestamptz,
10.0
)::observer_window;
```
During the index scan, inner nodes are pruned by altitude band (level 0) and inclination range (level 1). The RAAN filter is applied at the leaf level. This avoids examining individual TLEs in entire subtrees that cannot produce visible passes.
### Performance
The SP-GiST index is most effective for:
- **Short query windows** (1-6 hours): The RAAN filter aggressively eliminates satellites whose orbital plane is not currently aligned with the observer
- **Mid-latitude observers** (30-60 degrees): The inclination filter eliminates equatorial and low-inclination satellites
- **High minimum elevation** (> 20 degrees): The altitude filter eliminates distant MEO/GEO objects
For 24-hour query windows, the RAAN filter self-disables (full Earth rotation makes it meaningless), and only the altitude and inclination filters apply.
### Index Maintenance
Like the GiST index, the SP-GiST index is maintained automatically by PostgreSQL on `INSERT`, `UPDATE`, and `DELETE`. No manual `REINDEX` is needed under normal operation.