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:
parent
e1c22cb873
commit
845aeee3a5
43128
bench/active.tle
Normal file
43128
bench/active.tle
Normal file
File diff suppressed because it is too large
Load Diff
234
bench/benchmark.sql
Normal file
234
bench/benchmark.sql
Normal 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
193
bench/benchmark_results.txt
Normal 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.
|
||||
202
bench/benchmark_results_v2.txt
Normal file
202
bench/benchmark_results_v2.txt
Normal 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.
|
||||
264
bench/benchmark_spgist_only.sql
Normal file
264
bench/benchmark_spgist_only.sql
Normal 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
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
50
bench/tle_to_sql.py
Normal 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()
|
||||
@ -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" },
|
||||
],
|
||||
|
||||
224
docs/src/content/docs/guides/pass-prediction.mdx
Normal file
224
docs/src/content/docs/guides/pass-prediction.mdx
Normal 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>
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user