Evolved from the original KTrie custom AM proposal (preserved as KTRIE-SPEC-ORIGINAL.md). Key design decisions: 2-level trie (SMA + inclination) instead of 5, SP-GiST framework instead of custom AM, query-time RAAN filter instead of trie level, propagation-aware cost estimation via traversalValue.
482 lines
26 KiB
Markdown
482 lines
26 KiB
Markdown
# KTrie: Keplerian Patricia Trie — PostgreSQL Index Access Method
|
||
|
||
## Purpose
|
||
|
||
KTrie is a custom PostgreSQL index access method designed for spatiotemporal satellite queries. It indexes Two-Line Element (TLE) sets by decomposing Keplerian orbital element space into a hierarchical trie with Patricia path compression and adaptive branching. The goal is to prune the satellite catalog analytically before invoking the expensive SGP4/SDP4 orbital propagator, which is the dominant cost in any satellite query.
|
||
|
||
This extension is part of a larger spatiotemporal PostgreSQL extension that implements the SGP4 algorithm and related orbital mechanics directly inside the database. The KTrie index eliminates 90%+ of the catalog from propagation consideration using only the orbital elements stored in the TLE, before any numerical propagation occurs.
|
||
|
||
---
|
||
|
||
## Architecture Overview
|
||
|
||
### Design Principles
|
||
|
||
1. **Fixed level semantics, adaptive branching.** Each trie level always represents the same orbital element (semi-major axis at level 0, inclination at level 1, etc.), but the number of children at each node adapts to the local population density. This lets the PostgreSQL query planner push down predicates intelligently (it knows level 1 is always inclination) while still getting fine granularity where the objects actually cluster.
|
||
|
||
2. **Patricia path compression.** When a subtree path has single-child nodes (very common in LEO where eccentricity and argument of perigee are near-uniform), the path is compressed into a single node that stores the skipped levels' bounds in its header. This reduces page reads by up to 40% for typical LEO queries.
|
||
|
||
3. **Page-aligned to PostgreSQL's 8kB pages.** Every trie node fits in one 8kB page. The node header, entry format, and capacity are designed around this constraint. Split and merge operations maintain page fill targets.
|
||
|
||
4. **Population-aware for cost estimation.** Every internal node entry carries a `population` count of objects in its subtree. This feeds directly into PostgreSQL's query planner so it can estimate how many SGP4 propagations a query plan will require — the actual expensive operation.
|
||
|
||
5. **WGS-72 internal, WGS-84 external.** All orbital elements stored in the index use WGS-72 constants (because TLEs are fitted with WGS-72). Observer-facing query functions transform through TEME → ITRF using WGS-84. The extension enforces this pipeline so users cannot accidentally mix datums.
|
||
|
||
### Trie Level Hierarchy
|
||
|
||
```
|
||
Level 0: Semi-Major Axis (a) — km, primary discriminator
|
||
Level 1: Inclination (i) — radians, most stable element
|
||
Level 2: RAAN (Ω) — radians, precesses rapidly (J2)
|
||
Level 3: Eccentricity (e) — dimensionless, 0–1
|
||
Level 4: Arg. of Perigee (ω) — radians, precesses due to J2
|
||
↓
|
||
Leaf nodes — TLE references with cached elements
|
||
```
|
||
|
||
### SGP4 vs SDP4 Routing
|
||
|
||
Satellites with orbital period ≥ 225 minutes (semi-major axis threshold corresponding to the `.15625` day fraction in the original FORTRAN) are classified as deep-space and routed to SDP4 instead of SGP4. The index flags these with `HAS_RESONANT` on internal entries and `DEEP_SPACE` on leaf entries so the propagator dispatch is transparent to the user — they call a single function and the extension routes internally.
|
||
|
||
---
|
||
|
||
## Data Structures
|
||
|
||
### Node Types
|
||
|
||
```c
|
||
typedef enum KTrieNodeType {
|
||
KTRIE_INTERNAL = 0, /* splits orbital element space into child ranges */
|
||
KTRIE_LEAF = 1, /* holds TLE references at bottom of trie */
|
||
KTRIE_COMPRESSED = 2 /* Patricia path-compressed: skips single-child levels */
|
||
} KTrieNodeType;
|
||
```
|
||
|
||
### Node Header (72 bytes)
|
||
|
||
Every trie node (one per 8kB page) begins with this header:
|
||
|
||
```c
|
||
typedef struct KTrieNodeHeader {
|
||
uint8 type; /* KTRIE_INTERNAL | KTRIE_LEAF | KTRIE_COMPRESSED */
|
||
uint8 level; /* which orbital element this level splits (0-4) */
|
||
uint16 num_entries; /* current number of entries in this node */
|
||
uint16 flags; /* DIRTY | NEEDS_SPLIT | RESONANT */
|
||
uint16 padding; /* alignment */
|
||
float8 range_low; /* this node covers [range_low, range_high) */
|
||
float8 range_high; /* in units of the current level's orbital element */
|
||
uint8 compressed_depth; /* Patricia: number of levels skipped (0 = not compressed) */
|
||
uint8 pad[7]; /* alignment to 8-byte boundary */
|
||
float8 skip_bounds[5]; /* bounds for each compressed/skipped level (unused slots = NaN) */
|
||
} KTrieNodeHeader; /* total: 72 bytes */
|
||
```
|
||
|
||
The `compressed_depth` and `skip_bounds` fields are the Patricia compression mechanism. When a node compresses levels, `compressed_depth` indicates how many levels were skipped, and `skip_bounds` stores the [low, high) range for each skipped level so the query engine can still check predicates against compressed levels without decompressing the path.
|
||
|
||
### Internal Node Entry (24 bytes)
|
||
|
||
```c
|
||
typedef struct KTrieChildEntry {
|
||
float8 lower_bound; /* element range start for this child */
|
||
float8 upper_bound; /* element range end for this child */
|
||
BlockNumber child; /* PostgreSQL block number → child page */
|
||
uint16 population; /* total objects in this subtree (for planner cost estimation) */
|
||
uint16 flags; /* SPARSE | DENSE | HAS_RESONANT */
|
||
} KTrieChildEntry; /* total: 24 bytes */
|
||
```
|
||
|
||
**Capacity:** (8192 - 24 PG header - 72 node header) / 24 = **337 max children per internal node.** Adaptive branching uses anywhere from 4 to 337 children depending on population density.
|
||
|
||
### Leaf Node Entry (68 bytes)
|
||
|
||
```c
|
||
typedef struct KTrieLeafEntry {
|
||
int32 norad_id; /* NORAD catalog number */
|
||
float8 epoch; /* TLE epoch as Julian date */
|
||
float8 sma; /* semi-major axis in km */
|
||
float8 inc; /* inclination in radians */
|
||
float8 raan; /* right ascension of ascending node in radians */
|
||
float8 ecc; /* eccentricity (dimensionless) */
|
||
float8 argp; /* argument of perigee in radians */
|
||
float8 mean_anomaly; /* mean anomaly in radians */
|
||
ItemPointerData tle_tid; /* 6-byte pointer → heap tuple containing full TLE */
|
||
uint16 flags; /* DECAYING | MANEUVERING | DEEP_SPACE */
|
||
} KTrieLeafEntry; /* total: 68 bytes */
|
||
```
|
||
|
||
**Capacity:** (8192 - 24 - 72) / 68 = **119 max TLE entries per leaf page.**
|
||
|
||
The leaf caches all six Keplerian elements so that coarse spatial filtering can happen without touching the heap. The `tle_tid` is a standard PostgreSQL tuple pointer to the heap row containing the full TLE text (both lines), bstar drag term, and any metadata needed by the SGP4 propagator.
|
||
|
||
### Leaf Entry Flags
|
||
|
||
```c
|
||
#define KTRIE_FLAG_DECAYING 0x0001 /* object in orbital decay, shorter TLE validity */
|
||
#define KTRIE_FLAG_MANEUVERING 0x0002 /* recent maneuver detected, TLE may be stale */
|
||
#define KTRIE_FLAG_DEEP_SPACE 0x0004 /* period >= 225 min, route to SDP4 */
|
||
```
|
||
|
||
---
|
||
|
||
## Page Layout
|
||
|
||
All nodes are page-aligned to PostgreSQL's 8kB (8192 byte) page size.
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ PageHeaderData (PostgreSQL standard) 24B │
|
||
├─────────────────────────────────────────────┤
|
||
│ KTrieNodeHeader 72B │
|
||
│ type, level, num_entries, flags │
|
||
│ range_low, range_high │
|
||
│ compressed_depth, skip_bounds[5] │
|
||
├─────────────────────────────────────────────┤
|
||
│ Entries[] (KTrieChildEntry or │
|
||
│ KTrieLeafEntry × N) │
|
||
│ │
|
||
│ Internal: up to 337 × 24B = 8,088B │
|
||
│ Leaf: up to 119 × 68B = 8,092B │
|
||
│ │
|
||
├─────────────────────────────────────────────┤
|
||
│ Special: free_offset, checksum ~4B │
|
||
└─────────────────────────────────────────────┘
|
||
Total: 8,192 bytes
|
||
```
|
||
|
||
---
|
||
|
||
## Level Semantics
|
||
|
||
### Level 0 — Semi-Major Axis (a)
|
||
|
||
The primary discriminator. Orbital altitude is the strongest differentiator between satellite regimes.
|
||
|
||
- **Regime boundaries:** sub-LEO (<6,678 km / <300 km alt), LEO (6,678–8,378 km), MEO (8,378–41,378 km), GEO (41,378–42,578 km), super-GEO (>42,578 km). Note: all values are geocentric distance, not altitude. Altitude = SMA - 6,378 km (Earth's equatorial radius under WGS-72).
|
||
- **Adaptive fan-out:** 5–20 bins typical. LEO subdivides heavily because ~75% of the tracked catalog lives there.
|
||
- **Split strategy:** Equal-population splits, not equal-range. The altitude band 300–600 km might get 8 bins while 600–2000 km gets 3.
|
||
- **Query predicate mapping:** Altitude/SMA range queries prune instantly at this level.
|
||
|
||
### Level 1 — Inclination (i)
|
||
|
||
The most stable orbital element — it rarely changes except under powered thrust. Second-best discriminator after SMA.
|
||
|
||
- **Key population clusters:** 0° (equatorial/GEO), 28.5° (Cape Canaveral launches), 51.6° (ISS orbit), 55° (GPS constellation), 63.4° (Molniya critical inclination), 97–98° (sun-synchronous), retrograde orbits up to 180°.
|
||
- **Adaptive fan-out:** 4–32 bins. Fine-grained near sun-synchronous (huge population at 97–98°), coarse at high retrograde.
|
||
- **Special handling:** Nodes containing 63.4° ± 0.5° are flagged `HAS_RESONANT` because the critical inclination causes singularities in the Brouwer mean element theory that SGP4 is based on.
|
||
|
||
### Level 2 — RAAN (Ω) — Right Ascension of the Ascending Node
|
||
|
||
- **Range:** 0°–360°, wraps around (circular topology).
|
||
- **Caution:** RAAN precesses rapidly due to J2 perturbation (~0.5–7°/day depending on altitude and inclination). Fine subdivision is pointless because RAAN drifts significantly between TLE updates (which arrive every few hours to days).
|
||
- **Adaptive fan-out:** 4–8 coarse bins only.
|
||
- **Reindex trigger:** When mean RAAN drift since last index build exceeds the bin width, the level should be rebuilt. This can be estimated analytically from J2 precession rates.
|
||
- **Patricia compression:** This level is frequently compressed away for near-circular LEO orbits where RAAN discrimination adds little query value.
|
||
|
||
### Level 3 — Eccentricity (e)
|
||
|
||
- **Range:** 0.0 (perfectly circular) to ~0.95 (extreme HEO like Molniya).
|
||
- **Distribution:** Massively skewed. 90%+ of LEO objects have e < 0.02. The distribution is essentially a spike at zero with a long thin tail.
|
||
- **Adaptive fan-out:** 2–4 bins. Usually just "near-circular" (e < 0.02), "moderately eccentric" (0.02–0.3), and "highly elliptical" (> 0.3).
|
||
- **Patricia compression:** Almost always compressed away in LEO branches where everything is near-circular. Decompresses on split only when a GEO-transfer or HEO object enters the branch.
|
||
- **Query relevance:** Eccentricity matters for pass prediction because eccentric orbits have variable ground speed and altitude.
|
||
|
||
### Level 4 — Argument of Perigee (ω)
|
||
|
||
- **Range:** 0°–360° (circular topology, like RAAN).
|
||
- **Stability:** Precesses due to J2 perturbation. Rate depends on inclination and eccentricity.
|
||
- **Adaptive fan-out:** 2–6 bins. Most aggressively compressed level.
|
||
- **Patricia compression:** Compressed away in most branches. Only discriminates within dense clusters where all higher-level elements are similar (e.g., differentiating individual Starlink shells that share the same a, i, Ω, and near-zero e).
|
||
- **Primary use case:** Breaking ties in mega-constellation clusters.
|
||
|
||
---
|
||
|
||
## Patricia Path Compression
|
||
|
||
When a subtree path contains single-child nodes (one child at a given level because all objects in that branch fall in the same range), the path is compressed.
|
||
|
||
### Before compression (5 page reads):
|
||
|
||
```
|
||
L0(a=6798) → L1(i=51.6°) → L2(Ω=134°) → L3(e=0.0001) → L4(ω=22°) → leaf
|
||
↓ ↓ ↓ ↓ ↓
|
||
page read page read page read page read page read
|
||
```
|
||
|
||
### After compression (3 page reads):
|
||
|
||
```
|
||
L0(a=6798) → L1(i=51.6°) → COMPRESSED[Ω∈(90°,180°), e∈(0,0.02), ω∈(0°,360°)] → leaf
|
||
↓ ↓ ↓
|
||
page read page read page read
|
||
```
|
||
|
||
The compressed node's `skip_bounds` array stores the bounds for levels 2, 3, and 4. The query engine checks incoming predicates against these bounds — if a query specifies `e > 0.5`, it can reject this compressed branch without decompressing. If the compressed branch needs to be split (because new objects with different characteristics arrive), the compressed node is expanded back into individual levels.
|
||
|
||
### Compression triggers
|
||
|
||
- On insert: if a new entry would create a single-child level, compress instead.
|
||
- On bulk load: after initial trie construction, a bottom-up compression pass identifies and compresses all single-child chains.
|
||
|
||
### Decompression triggers
|
||
|
||
- On insert: if a new entry falls outside the `skip_bounds` of a compressed node, decompress the path and insert normally.
|
||
- Decompression creates new intermediate pages as needed.
|
||
|
||
---
|
||
|
||
## Split and Merge Operations
|
||
|
||
### Split
|
||
|
||
When a node exceeds 85% fill factor:
|
||
|
||
1. Choose the split point along the current level's orbital element dimension.
|
||
2. **Split strategy:** Equal-population split, not equal-range. Find the element value that divides the entries into two roughly equal groups. This keeps leaf occupancy balanced and query latency predictable.
|
||
3. Allocate a new page for the right half.
|
||
4. Update the parent's child entries (replace one entry with two).
|
||
5. If the parent overflows, split it recursively.
|
||
|
||
### Merge
|
||
|
||
When a node drops below 25% fill factor:
|
||
|
||
1. Check if the node can merge with a sibling (adjacent range at the same level under the same parent).
|
||
2. If combined population < 70% of capacity, merge into one page and free the other.
|
||
3. Update the parent's child entries (replace two entries with one).
|
||
4. If the parent underflows, check for merge recursively.
|
||
|
||
### Fill factor targets
|
||
|
||
| Node Type | Split Threshold | Merge Threshold | Target Fill |
|
||
|-----------|----------------|-----------------|-------------|
|
||
| Internal | 85% (286 children) | 25% (84 children) | ~60% |
|
||
| Leaf | 85% (101 entries) | 25% (30 entries) | ~60% |
|
||
|
||
---
|
||
|
||
## Query Traversal
|
||
|
||
### Example: Pass prediction from Eagle, Idaho
|
||
|
||
```sql
|
||
SELECT s.norad_id, s.name,
|
||
sgp4_passes(s.tle, observer(43.6955, -116.3530, 760), now(), interval '2 hours')
|
||
FROM satellites s
|
||
WHERE ktrie_passes_possible(s.tle, observer(43.6955, -116.3530, 760), now(), interval '2 hours');
|
||
```
|
||
|
||
### Traversal steps
|
||
|
||
1. **L0 (Semi-Major Axis):** Observer at ~43.7° latitude can only see satellites up to a certain altitude based on minimum elevation angle. For a 10° minimum elevation, the maximum visible altitude is roughly 2,500 km for a directly-overhead pass. Prune all branches with SMA > ~8,878 km (LEO/low-MEO only). This eliminates MEO, GEO, and beyond. **~75% of catalog remains** (LEO is dense), but all non-LEO branches are gone.
|
||
|
||
2. **L1 (Inclination):** A ground station at 43.7° latitude can only see satellites with inclination ≥ ~33.7° (latitude minus max off-track angle). Prune all equatorial and low-inclination branches. **~60% of LEO eliminated** (equatorial and sub-40° inclination objects gone).
|
||
|
||
3. **L2 (RAAN):** Based on current sidereal time and the 2-hour query window, only certain RAAN ranges produce ground tracks passing over Idaho. Coarse prune. **~50% of remaining eliminated.**
|
||
|
||
4. **L3–L4:** Usually compressed in LEO. If not compressed, minor additional pruning on eccentricity (very eccentric objects have different visibility windows).
|
||
|
||
5. **Leaf scan:** Remaining ~2,000–3,000 entries. For each, run SGP4 propagation at coarse time steps (e.g., 60-second intervals over 2 hours = 120 propagations per satellite). Check topocentric elevation from observer. Return passes with elevation > threshold.
|
||
|
||
**Net result:** ~92% of the catalog pruned before any SGP4 propagation. The propagator — which is O(1) per time step but has a large constant factor due to the perturbation model — only runs on the candidates that survive the trie traversal.
|
||
|
||
---
|
||
|
||
## PostgreSQL Integration
|
||
|
||
### Index Access Method Registration
|
||
|
||
KTrie registers as a custom index access method using PostgreSQL's `IndexAmRoutine`:
|
||
|
||
```c
|
||
IndexAmRoutine *ktrie_handler(void) {
|
||
IndexAmRoutine *amroutine = makeNode(IndexAmRoutine);
|
||
|
||
amroutine->amstrategies = 6; /* see operator strategies below */
|
||
amroutine->amsupport = 3;
|
||
amroutine->amcanorder = false;
|
||
amroutine->amcanbackward = false;
|
||
amroutine->amcanunique = false;
|
||
amroutine->amcanmulticol = true; /* indexes full TLE composite type */
|
||
amroutine->amoptionalkey = true;
|
||
amroutine->amsearcharray = false;
|
||
amroutine->amsearchnulls = false;
|
||
amroutine->amstorage = false;
|
||
amroutine->amclusterable = false;
|
||
amroutine->ampredlocks = false;
|
||
amroutine->amcanparallel = true; /* parallel scan supported */
|
||
amroutine->amcaninclude = false;
|
||
|
||
amroutine->ambuild = ktrie_build;
|
||
amroutine->ambuildempty = ktrie_buildempty;
|
||
amroutine->aminsert = ktrie_insert;
|
||
amroutine->ambulkdelete = ktrie_bulkdelete;
|
||
amroutine->amvacuumcleanup = ktrie_vacuumcleanup;
|
||
amroutine->amcostestimate = ktrie_costestimate;
|
||
amroutine->amoptions = ktrie_options;
|
||
amroutine->amvalidate = ktrie_validate;
|
||
amroutine->ambeginscan = ktrie_beginscan;
|
||
amroutine->amrescan = ktrie_rescan;
|
||
amroutine->amgettuple = ktrie_gettuple;
|
||
amroutine->amendscan = ktrie_endscan;
|
||
|
||
return amroutine;
|
||
}
|
||
```
|
||
|
||
### Operator Strategies
|
||
|
||
```sql
|
||
-- Strategy 1: Orbital regime containment (SMA range)
|
||
CREATE OPERATOR @> (LEFTARG = orbital_regime, RIGHTARG = tle, PROCEDURE = ktrie_regime_contains);
|
||
|
||
-- Strategy 2: Inclination band overlap
|
||
CREATE OPERATOR && (LEFTARG = inclination_band, RIGHTARG = tle, PROCEDURE = ktrie_incl_overlaps);
|
||
|
||
-- Strategy 3: Visibility cone intersection (observer + time window → candidate TLEs)
|
||
CREATE OPERATOR &? (LEFTARG = observer_window, RIGHTARG = tle, PROCEDURE = ktrie_visibility_possible);
|
||
|
||
-- Strategy 4: Proximity search (find objects near a given orbital state)
|
||
CREATE OPERATOR <-> (LEFTARG = orbital_state, RIGHTARG = tle, PROCEDURE = ktrie_orbital_distance);
|
||
|
||
-- Strategy 5: Conjunction screening (two TLEs, check if orbits intersect)
|
||
CREATE OPERATOR &= (LEFTARG = tle, RIGHTARG = tle, PROCEDURE = ktrie_conjunction_possible);
|
||
|
||
-- Strategy 6: Ground track intersection (does orbit cross a geographic region?)
|
||
CREATE OPERATOR &@ (LEFTARG = geographic_region, RIGHTARG = tle, PROCEDURE = ktrie_groundtrack_intersects);
|
||
```
|
||
|
||
### Cost Estimation
|
||
|
||
The cost estimator uses the `population` fields in the trie to predict:
|
||
|
||
1. **Index scan cost:** Number of pages traversed (tree depth × pages per level). Patricia compression reduces this.
|
||
2. **Propagation cost:** Number of leaf entries surviving the trie prune × cost per SGP4 propagation × number of time steps in the query window. This is the dominant cost and what makes the population counts critical for the planner.
|
||
3. **Heap fetch cost:** Number of TLEs that need full data (beyond what's cached in the leaf entry).
|
||
|
||
```c
|
||
void ktrie_costestimate(PlannerInfo *root, IndexPath *path,
|
||
double loop_count, Cost *indexStartupCost,
|
||
Cost *indexTotalCost, Selectivity *indexSelectivity,
|
||
double *indexCorrelation, double *indexPages) {
|
||
|
||
/* Estimate surviving population from trie structure */
|
||
double surviving_pop = estimate_surviving_population(root, path);
|
||
|
||
/* SGP4 propagation cost dominates */
|
||
double propagation_steps = extract_time_window(path) / SGP4_STEP_INTERVAL;
|
||
double sgp4_cost_per_step = 0.05; /* calibrate empirically */
|
||
|
||
*indexTotalCost = (tree_depth * PAGE_READ_COST)
|
||
+ (surviving_pop * propagation_steps * sgp4_cost_per_step)
|
||
+ (surviving_pop * HEAP_FETCH_COST);
|
||
|
||
*indexSelectivity = surviving_pop / total_catalog_size;
|
||
}
|
||
```
|
||
|
||
### Index Creation
|
||
|
||
```sql
|
||
-- Create the KTrie index on a TLE table
|
||
CREATE INDEX idx_satellites_ktrie ON satellites USING ktrie (tle_data)
|
||
WITH (fill_factor = 60, compression_threshold = 1, reindex_raan_drift = 5.0);
|
||
```
|
||
|
||
**Storage parameters:**
|
||
- `fill_factor` (default 60): Target page fill percentage after splits.
|
||
- `compression_threshold` (default 1): Minimum single-child chain length before Patricia compression activates.
|
||
- `reindex_raan_drift` (default 5.0): Maximum mean RAAN drift in degrees before Level 2 triggers a rebuild.
|
||
|
||
---
|
||
|
||
## Bulk Loading
|
||
|
||
For initial index construction (e.g., loading the full Space-Track catalog of ~30,000+ tracked objects):
|
||
|
||
1. **Sort** all TLEs by semi-major axis (primary), then inclination (secondary).
|
||
2. **Bottom-up construction:** Build leaf pages first, then construct internal nodes from the leaf level up. This avoids the overhead of top-down insertions and splits.
|
||
3. **Compression pass:** After construction, walk the tree bottom-up and compress all single-child chains.
|
||
4. **Population propagation:** Sum leaf counts upward through internal nodes to populate all `population` fields.
|
||
|
||
This is analogous to how GiST and SP-GiST handle bulk loading, but the sort order is domain-specific (Keplerian element priority).
|
||
|
||
---
|
||
|
||
## TLE Freshness and Index Maintenance
|
||
|
||
TLEs have a limited validity window. A TLE for a LEO satellite is typically accurate for 1–3 days; deep-space objects may be valid for weeks. The index must handle TLE updates gracefully:
|
||
|
||
1. **Update-in-place:** If the new TLE's orbital elements fall within the same leaf node's ranges, update the leaf entry and heap tuple without restructuring the trie.
|
||
2. **Move:** If the new TLE's elements have drifted enough to belong in a different branch (e.g., post-maneuver), delete from the old leaf and insert into the correct branch.
|
||
3. **Staleness flag:** If a TLE exceeds its expected validity window without an update, flag the leaf entry as `MANEUVERING` (possible unreported maneuver) so the propagator can apply wider uncertainty bounds.
|
||
4. **Decay handling:** Objects in orbital decay (decreasing SMA over successive TLEs) are flagged `DECAYING`. These may need to move between Level 0 bins as their altitude drops.
|
||
|
||
---
|
||
|
||
## Constants
|
||
|
||
All orbital mechanics constants used in the index and propagator must match the WGS-72 values that TLEs are fitted against:
|
||
|
||
```c
|
||
#define KTRIE_GM 398600.8 /* km³/s², WGS-72 gravitational parameter */
|
||
#define KTRIE_RE 6378.135 /* km, WGS-72 Earth equatorial radius */
|
||
#define KTRIE_J2 0.001082616 /* WGS-72 second zonal harmonic */
|
||
#define KTRIE_XKE 0.0743669161 /* sqrt(GM) in earth-radii³/min² units */
|
||
#define KTRIE_DEEP_THRESHOLD 0.15625 /* 225/1440: orbital period threshold for SDP4 */
|
||
#define KTRIE_MINUTES_PER_DAY 1440.0
|
||
```
|
||
|
||
Never use WGS-84 values inside the propagator or index. WGS-84 is used only for the final TEME → ITRF → geodetic transformation when computing observer-relative positions.
|
||
|
||
---
|
||
|
||
## File Organization
|
||
|
||
```
|
||
pg_ktrie/
|
||
├── ktrie.h # Core data structures (this spec)
|
||
├── ktrie_handler.c # Index AM registration and routing
|
||
├── ktrie_build.c # Index construction and bulk loading
|
||
├── ktrie_insert.c # Single-tuple insertion, split logic
|
||
├── ktrie_delete.c # Deletion, merge logic, vacuum
|
||
├── ktrie_scan.c # Index scan (beginscan, gettuple, endscan)
|
||
├── ktrie_compress.c # Patricia path compression/decompression
|
||
├── ktrie_cost.c # Query planner cost estimation
|
||
├── ktrie_operators.c # SQL operator implementations
|
||
├── ktrie_utils.c # Keplerian element conversions, J2 precession rates
|
||
├── sgp4/
|
||
│ ├── sgp4_propagator.c # SGP4 near-earth propagation (from STR#3 / Vallado Rev-1)
|
||
│ ├── sdp4_propagator.c # SDP4 deep-space propagation
|
||
│ ├── deep.c # DEEP subroutine (resonance integrator)
|
||
│ ├── tle_parser.c # TLE line 1 + line 2 parser
|
||
│ ├── coord_transforms.c # TEME → ITRF → geodetic/topocentric
|
||
│ └── sgp4.h # SGP4 constants, structs, WGS-72 values
|
||
├── sql/
|
||
│ ├── ktrie--1.0.sql # Extension SQL: types, operators, index AM
|
||
│ └── ktrie.control # PostgreSQL extension control file
|
||
├── test/
|
||
│ ├── test_str3_vectors.sql # Spacetrack Report #3 test cases (25 vectors)
|
||
│ ├── test_vallado.sql # Vallado Rev-1 test cases (518 vectors)
|
||
│ └── test_ktrie_ops.sql # Index operation tests
|
||
└── Makefile # PGXS build
|
||
```
|
||
|
||
---
|
||
|
||
## Validation
|
||
|
||
The SGP4 implementation must pass both standard test vector sets before the index is considered operational:
|
||
|
||
1. **Spacetrack Report #3, Chapter 13:** 25 test cases covering near-earth and deep-space objects. Sub-meter accuracy for near-earth. These test internal consistency — that the implementation matches the canonical FORTRAN.
|
||
|
||
2. **Vallado Rev-1, Appendix D/E:** 518 verification test cases. Machine-epsilon agreement with the reference C++ implementation. These test cross-implementation correctness.
|
||
|
||
3. **Kelso 2007 (optional but recommended):** SGP4 output compared against GPS precision ephemerides. ~1 km accuracy at epoch with 1–3 km/day growth. This validates that SGP4 itself (not just our implementation of it) is producing physically meaningful results.
|
||
|
||
The index structure itself should be validated with:
|
||
|
||
- Round-trip tests: insert TLEs, query them back, verify all orbital elements match.
|
||
- Population count invariant: sum of all leaf entries = sum of root's children's populations.
|
||
- Compression invariant: decompressing a compressed node and recompressing produces identical skip_bounds.
|
||
- Split/merge cycle: splitting a node and immediately merging produces the original node.
|