/* * spgist_tle.c -- SP-GiST operator class for orbital trie on TLE * * 2-level space-partitioning trie: * L0: Semi-major axis (altitude regime, from Kepler's 3rd law) * L1: Inclination (ground-track latitude coverage) * * Query-time RAAN filter at leaf level projects ascending node to * the query midpoint via J2 secular precession and checks alignment * with the observer's local sidereal position. * * The &? operator answers "could this satellite be visible from this * observer during this time window?" -- a conservative superset of * the actual answer. Survivors go through SGP4 propagation. * * Equal-population splits: picksplit sorts by the level's element * and divides into floor(sqrt(n)) bins, clamped [2,16]. Dense LEO * gets finer SMA bins than sparse MEO/GEO. */ #include "postgres.h" #include "fmgr.h" #include "access/spgist.h" #include "access/htup_details.h" #include "catalog/pg_type_d.h" #include "utils/builtins.h" #include "utils/timestamp.h" #include "executor/executor.h" #include "types.h" #include "astro_math.h" #include #include PG_FUNCTION_INFO_V1(spgist_tle_config); PG_FUNCTION_INFO_V1(spgist_tle_choose); PG_FUNCTION_INFO_V1(spgist_tle_picksplit); PG_FUNCTION_INFO_V1(spgist_tle_inner_consistent); PG_FUNCTION_INFO_V1(spgist_tle_leaf_consistent); PG_FUNCTION_INFO_V1(tle_visibility_possible); /* Max trie depth: L0 (SMA) + L1 (inclination) */ #define SPGIST_TLE_MAX_LEVEL 2 /* Earth angular rotation rate in radians/day */ #define EARTH_ROT_RAD_PER_DAY (2.0 * M_PI) /* Seconds per day */ #define SECONDS_PER_DAY 86400.0 /* Minutes per day */ #define MINUTES_PER_DAY 1440.0 /* ---------------------------------------------------------------- * Helper: semi-major axis in km from mean motion * * Kepler's 3rd law with WGS-72: a = (KE / n)^(2/3) * AE * where n is in radians/minute (TLE internal units). * ---------------------------------------------------------------- */ static inline double tle_sma_km(const pg_tle *tle) { double n = tle->mean_motion; if (n <= 0.0) return 0.0; return pow(WGS72_KE / n, 2.0 / 3.0) * WGS72_AE; } /* ---------------------------------------------------------------- * Helper: perigee altitude in km above Earth's surface * ---------------------------------------------------------------- */ static inline double tle_perigee_alt_km(const pg_tle *tle) { double a = tle_sma_km(tle); return a * (1.0 - tle->eccentricity) - WGS72_AE; } /* ---------------------------------------------------------------- * Helper: apogee altitude in km above Earth's surface * ---------------------------------------------------------------- */ static inline double tle_apogee_alt_km(const pg_tle *tle) { double a = tle_sma_km(tle); return a * (1.0 + tle->eccentricity) - WGS72_AE; } /* ---------------------------------------------------------------- * Helper: maximum satellite altitude visible at a given min elevation * * At min_el degrees elevation, the observer can see a satellite at * most this far above the surface. Conservative upper bound using * the Earth-center angle geometry: * h_max = Re * (1/cos(el) - 1) roughly, but for a safe upper * bound we use the slant range limit. * * For min_el = 10 deg, practical limit is ~2500 km for LEO passes. * For min_el = 0 deg, theoretical limit extends to GEO+. * We use a generous bound: if min_el < 5, return 50000 (no filter). * Otherwise compute from geometry. * ---------------------------------------------------------------- */ static inline double max_visible_altitude_km(double min_el_deg) { double el_rad, sin_el; double rho, h_max; /* * Below 5 deg elevation the horizon geometry allows visibility to * GEO+ altitudes. Disable the altitude filter rather than compute * an impractically large bound. 50000 km exceeds GEO (35786 km). */ if (min_el_deg < 5.0) return 50000.0; el_rad = min_el_deg * DEG_TO_RAD; sin_el = sin(el_rad); /* * Maximum slant range rho where a satellite at altitude h is visible * at elevation el. From the geometry: * rho = Re * (sqrt((h/Re + 1)^2 - cos^2(el)) - sin(el)) * Invert for h given a max practical rho. We take rho_max = 5000 km * (well beyond any LEO pass) and solve for h. * * Simpler conservative bound: h_max = rho_max / sin(el) for el > 0. */ rho = 5000.0; /* max practical slant range; well beyond LEO/MEO */ h_max = sqrt(rho * rho + 2.0 * WGS72_AE * rho * sin_el + WGS72_AE * WGS72_AE) - WGS72_AE; return h_max; } /* ---------------------------------------------------------------- * Helper: angular radius of ground visibility footprint * * For a satellite at altitude h km, the half-angle of the visibility * cone (Earth-center angle) at min_el elevation is: * lambda = arccos(Re / (Re + h) * cos(el)) - el * Returns degrees. * ---------------------------------------------------------------- */ static inline double ground_footprint_deg(double sma_km, double min_el_deg) { double h_km = sma_km - WGS72_AE; double el_rad, cos_ratio, lambda; if (h_km <= 0.0) return 0.0; el_rad = min_el_deg * DEG_TO_RAD; cos_ratio = WGS72_AE / (WGS72_AE + h_km) * cos(el_rad); if (cos_ratio >= 1.0) return 0.0; lambda = acos(cos_ratio) - el_rad; return lambda * RAD_TO_DEG; } /* ---------------------------------------------------------------- * Helper: J2 secular RAAN precession rate in radians/day * * dOmega/dt = -1.5 * n * J2 * (Re/a)^2 * cos(i) * * where n is mean motion in rad/s, J2 and Re are WGS-72. * Result in rad/day (multiply rad/s by 86400). * * Uses only the J2 zonal harmonic (no J3/J4 short-period terms). * For LEO this can accumulate ~0.5 deg/day error from J3 short- * period oscillations. Acceptable because (a) the RAAN window * includes footprint + Earth rotation padding, and (b) any query * window >= ~12 hours bypasses the RAAN filter entirely. * ---------------------------------------------------------------- */ static inline double j2_raan_rate(double sma_km, double inc_rad) { double a = sma_km; double ratio, n_rad_s; if (a <= 0.0) return 0.0; ratio = WGS72_AE / a; n_rad_s = sqrt(WGS72_MU / (a * a * a)); return -1.5 * n_rad_s * WGS72_J2 * ratio * ratio * cos(inc_rad) * SECONDS_PER_DAY; } /* ---------------------------------------------------------------- * Traversal state carried down the tree during index scans. * Accumulates SMA and inclination ranges from L0 and L1. * ---------------------------------------------------------------- */ typedef struct OrbitalTraversal { double sma_low; double sma_high; double inc_low; double inc_high; } OrbitalTraversal; /* ---------------------------------------------------------------- * Query parameter extraction from observer_window composite type * * observer_window is: (obs observer, t_start timestamptz, * t_end timestamptz, min_el float8) * ---------------------------------------------------------------- */ typedef struct ObserverWindow { pg_observer obs; double jd_start; double jd_end; double jd_mid; double min_el_deg; } ObserverWindow; static void extract_observer_window(HeapTupleHeader composite, ObserverWindow *win) { bool isnull; Datum val; int64 ts_start, ts_end; /* * Field 1: obs (observer -- fixed-size 24B, pass-by-reference). * GetAttributeByNum returns a Datum that is a direct pointer to * the pg_observer bytes in the composite tuple. No varlena header. */ val = GetAttributeByNum(composite, 1, &isnull); if (isnull) ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), errmsg("observer_window.obs must not be NULL"))); memcpy(&win->obs, DatumGetPointer(val), sizeof(pg_observer)); /* Field 2: t_start (timestamptz) */ val = GetAttributeByNum(composite, 2, &isnull); if (isnull) ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), errmsg("observer_window.t_start must not be NULL"))); ts_start = DatumGetTimestampTz(val); win->jd_start = timestamptz_to_jd(ts_start); /* Field 3: t_end (timestamptz) */ val = GetAttributeByNum(composite, 3, &isnull); if (isnull) ereport(ERROR, (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED), errmsg("observer_window.t_end must not be NULL"))); ts_end = DatumGetTimestampTz(val); win->jd_end = timestamptz_to_jd(ts_end); /* Validate time window ordering */ if (win->jd_end < win->jd_start) ereport(ERROR, (errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg("observer_window.t_end must not precede t_start"))); win->jd_mid = (win->jd_start + win->jd_end) / 2.0; /* Field 4: min_el (float8), clamped to [0, 90] */ val = GetAttributeByNum(composite, 4, &isnull); if (isnull) win->min_el_deg = 10.0; /* default */ else { win->min_el_deg = DatumGetFloat8(val); if (win->min_el_deg < 0.0) win->min_el_deg = 0.0; if (win->min_el_deg > 90.0) win->min_el_deg = 90.0; } } /* ---------------------------------------------------------------- * Comparison function for picksplit qsort. * Sorts by extracted key value (SMA or inclination). * ---------------------------------------------------------------- */ typedef struct { int orig_index; double key; } PicksplitEntry; static int picksplit_entry_cmp(const void *a, const void *b) { double ka = ((const PicksplitEntry *) a)->key; double kb = ((const PicksplitEntry *) b)->key; if (ka < kb) return -1; if (ka > kb) return 1; return 0; } /* ================================================================ * SP-GiST support functions (5 required) * ================================================================ */ /* * spgist_tle_config -- declare trie type system * * No prefix data (VOIDOID): bin ranges encoded entirely in node labels. * Labels are float8 (bin boundary values, sorted ascending). * Leaves store full TLE unchanged. */ Datum spgist_tle_config(PG_FUNCTION_ARGS) { spgConfigIn *in = (spgConfigIn *) PG_GETARG_POINTER(0); spgConfigOut *out = (spgConfigOut *) PG_GETARG_POINTER(1); out->prefixType = VOIDOID; out->labelType = FLOAT8OID; out->leafType = in->attType; /* tle type */ out->canReturnData = true; out->longValuesOK = false; PG_RETURN_VOID(); } /* * spgist_tle_choose -- route a new TLE to the correct child node * * L0: route by SMA. L1: route by inclination. * restDatum = leafDatum (full TLE unchanged), matching the quad-tree * precedent where the tree terminates by depth, not value exhaustion. * * At level >= 2, all nodes are allTheSame (no further partitioning). */ Datum spgist_tle_choose(PG_FUNCTION_ARGS) { spgChooseIn *in = (spgChooseIn *) PG_GETARG_POINTER(0); spgChooseOut *out = (spgChooseOut *) PG_GETARG_POINTER(1); pg_tle *tle = (pg_tle *) DatumGetPointer(in->leafDatum); int level = in->level; double val; /* Extract the routing key for this level */ if (level == 0) val = tle_sma_km(tle); else val = tle->inclination; if (in->allTheSame || level >= SPGIST_TLE_MAX_LEVEL) { out->resultType = spgMatchNode; out->result.matchNode.nodeN = 0; out->result.matchNode.levelAdd = 1; out->result.matchNode.restDatum = in->leafDatum; PG_RETURN_VOID(); } /* * Find the bin: labels are sorted ascending, each label marks the * lower bound of a bin. We want the last bin whose label <= val. */ { int best = 0; int i; for (i = 1; i < in->nNodes; i++) { if (DatumGetFloat8(in->nodeLabels[i]) <= val) best = i; else break; } out->resultType = spgMatchNode; out->result.matchNode.nodeN = best; out->result.matchNode.levelAdd = 1; out->result.matchNode.restDatum = in->leafDatum; } PG_RETURN_VOID(); } /* * spgist_tle_picksplit -- split a leaf page using equal-population strategy * * Sort by the current level's element (SMA at L0, inclination at L1), * divide into floor(sqrt(nTuples)) bins clamped to [2, 16]. * At level >= 2, emit a single allTheSame node. */ Datum spgist_tle_picksplit(PG_FUNCTION_ARGS) { spgPickSplitIn *in = (spgPickSplitIn *) PG_GETARG_POINTER(0); spgPickSplitOut *out = (spgPickSplitOut *) PG_GETARG_POINTER(1); int level = in->level; int nTuples = in->nTuples; int nBins, perBin, remainder; int i, bin, pos; PicksplitEntry *entries; /* * At level >= 2 we have no further partitioning dimension. * Emit a single allTheSame node that accepts everything. */ if (level >= SPGIST_TLE_MAX_LEVEL) { out->nNodes = 1; out->hasPrefix = false; out->prefixDatum = (Datum) 0; out->nodeLabels = (Datum *) palloc(sizeof(Datum)); out->nodeLabels[0] = Float8GetDatum(0.0); out->mapTuplesToNodes = (int *) palloc(sizeof(int) * nTuples); out->leafTupleDatums = (Datum *) palloc(sizeof(Datum) * nTuples); for (i = 0; i < nTuples; i++) { out->mapTuplesToNodes[i] = 0; out->leafTupleDatums[i] = in->datums[i]; } PG_RETURN_VOID(); } /* Extract and sort by the current level's element */ entries = (PicksplitEntry *) palloc(sizeof(PicksplitEntry) * nTuples); for (i = 0; i < nTuples; i++) { pg_tle *tle = (pg_tle *) DatumGetPointer(in->datums[i]); entries[i].orig_index = i; if (level == 0) entries[i].key = tle_sma_km(tle); else entries[i].key = tle->inclination; } qsort(entries, nTuples, sizeof(PicksplitEntry), picksplit_entry_cmp); /* Equal-population split: floor(sqrt(n)) bins, clamped [2, 16] */ nBins = (int) floor(sqrt((double) nTuples)); if (nBins < 2) nBins = 2; if (nBins > 16) nBins = 16; /* Prevent over-read: never more bins than tuples */ if (nBins > nTuples) nBins = nTuples; perBin = nTuples / nBins; remainder = nTuples % nBins; out->nNodes = nBins; out->hasPrefix = false; out->prefixDatum = (Datum) 0; out->nodeLabels = (Datum *) palloc(sizeof(Datum) * nBins); out->mapTuplesToNodes = (int *) palloc(sizeof(int) * nTuples); out->leafTupleDatums = (Datum *) palloc(sizeof(Datum) * nTuples); pos = 0; for (bin = 0; bin < nBins; bin++) { int count = perBin + (bin < remainder ? 1 : 0); /* Node label = key value of the first entry in this bin */ out->nodeLabels[bin] = Float8GetDatum(entries[pos].key); for (i = 0; i < count; i++) { int orig = entries[pos + i].orig_index; out->mapTuplesToNodes[orig] = bin; out->leafTupleDatums[orig] = in->datums[orig]; } pos += count; } pfree(entries); PG_RETURN_VOID(); } /* * spgist_tle_inner_consistent -- prune child nodes during index scan * * L0: skip bins whose perigee altitude exceeds max visible altitude. * The bin's SMA range is [label[i], label[i+1]). * L1: skip bins whose inclination is too low to reach observer latitude. * A satellite with inclination i has ground track bounded by [-i, +i]. * Observer at latitude phi needs i + footprint >= |phi|. * * Propagates OrbitalTraversal state to surviving children via * traversalMemoryContext for the RAAN filter at leaf level. */ Datum spgist_tle_inner_consistent(PG_FUNCTION_ARGS) { spgInnerConsistentIn *in = (spgInnerConsistentIn *) PG_GETARG_POINTER(0); spgInnerConsistentOut *out = (spgInnerConsistentOut *) PG_GETARG_POINTER(1); int level = in->level; int nkeys = in->nkeys; int i; ObserverWindow win; bool have_query = false; /* Extract query from scankeys -- we need the &? operator's arg */ for (i = 0; i < nkeys; i++) { if (in->scankeys[i].sk_strategy == 1) { HeapTupleHeader composite; composite = DatumGetHeapTupleHeader(in->scankeys[i].sk_argument); extract_observer_window(composite, &win); have_query = true; break; } } /* Allocate output arrays */ out->nodeNumbers = (int *) palloc(sizeof(int) * in->nNodes); out->levelAdds = (int *) palloc(sizeof(int) * in->nNodes); out->reconstructedValues = NULL; out->traversalValues = (void **) palloc(sizeof(void *) * in->nNodes); out->nNodes = 0; for (i = 0; i < in->nNodes; i++) { OrbitalTraversal *parent_trav; OrbitalTraversal *child_trav; double bin_low, bin_high; bool dominated = false; /* Decode bin range from labels */ bin_low = DatumGetFloat8(in->nodeLabels[i]); if (i < in->nNodes - 1) bin_high = DatumGetFloat8(in->nodeLabels[i + 1]); else bin_high = INFINITY; /* Inherit parent traversal state or initialize */ if (in->traversalValue) parent_trav = (OrbitalTraversal *) in->traversalValue; else parent_trav = NULL; /* Pruning logic per level */ if (have_query && level == 0) { /* * L0: SMA range narrowing only — no altitude pruning. * * We cannot prune SMA bins by altitude because eccentricity * is not available at the inner node level. A satellite * at SMA 70,000 km with e=0.88 has perigee ~2,000 km — * well within typical max_alt. Without knowing e, any SMA * bin could contain satellites with perigee near Earth's * surface. * * L0 still helps by narrowing the SMA range passed to L1 * for computing a tighter ground footprint. */ } else if (have_query && level == 1) { /* * L1: Inclination pruning. * bin_high is the upper bound on inclination in this bin. * A satellite with inclination i has ground track [-i, +i]. * The observer at latitude phi can see it if: * i + footprint >= |phi| * * Use the parent SMA range to compute a conservative footprint. * The largest footprint comes from the HIGHEST altitude (footprint * grows with altitude: GEO sees 71+ degrees, LEO sees ~7 degrees). * Use sma_high for conservatism — never prune objects that the * leaf filter would accept. */ double obs_lat = fabs(win.obs.lat); double sma_for_footprint; double footprint; if (parent_trav) sma_for_footprint = parent_trav->sma_high; else sma_for_footprint = 50000.0; /* above GEO — maximum footprint */ footprint = ground_footprint_deg(sma_for_footprint, win.min_el_deg) * DEG_TO_RAD; if (bin_high + footprint < obs_lat) dominated = true; } if (!dominated) { int idx = out->nNodes; /* Build child traversal state */ child_trav = (OrbitalTraversal *) MemoryContextAlloc(in->traversalMemoryContext, sizeof(OrbitalTraversal)); if (parent_trav) memcpy(child_trav, parent_trav, sizeof(OrbitalTraversal)); else { child_trav->sma_low = 0.0; child_trav->sma_high = INFINITY; child_trav->inc_low = 0.0; child_trav->inc_high = M_PI; } /* Narrow bounds based on current level */ if (level == 0) { child_trav->sma_low = bin_low; child_trav->sma_high = bin_high; } else if (level == 1) { child_trav->inc_low = bin_low; child_trav->inc_high = bin_high; } out->nodeNumbers[idx] = i; out->levelAdds[idx] = 1; out->traversalValues[idx] = child_trav; out->nNodes++; } } PG_RETURN_VOID(); } /* ---------------------------------------------------------------- * Shared filter: three-stage visibility check on a single TLE. * * 1. Perigee altitude check (with eccentricity) * 2. Inclination + ground footprint vs observer latitude * 3. RAAN query-time filter (J2 precession to query midpoint) * * Called from both leaf_consistent (index scan) and * tle_visibility_possible (sequential scan / standalone operator). * ---------------------------------------------------------------- */ static bool tle_passes_visibility_filter(const pg_tle *tle, const ObserverWindow *win) { double sma, perigee_alt, max_alt; double obs_lat_abs, footprint_rad; double dt_days, raan_projected, lst; double earth_rot_rad, raan_window_half, raan_diff; /* Reject degenerate TLEs (decay, error data) */ if (tle->mean_motion <= 0.0) return false; sma = tle_sma_km(tle); /* Filter 1: perigee altitude */ perigee_alt = sma * (1.0 - tle->eccentricity) - WGS72_AE; max_alt = max_visible_altitude_km(win->min_el_deg); if (perigee_alt > max_alt) return false; /* Filter 2: inclination + footprint vs observer latitude */ obs_lat_abs = fabs(win->obs.lat); footprint_rad = ground_footprint_deg(sma, win->min_el_deg) * DEG_TO_RAD; if (tle->inclination + footprint_rad < obs_lat_abs) return false; /* Filter 3: RAAN alignment via J2 secular precession */ dt_days = win->jd_mid - tle->epoch; raan_projected = tle->raan + j2_raan_rate(sma, tle->inclination) * dt_days; raan_projected = fmod(raan_projected, 2.0 * M_PI); if (raan_projected < 0.0) raan_projected += 2.0 * M_PI; /* Observer LST at query midpoint */ lst = gmst_from_jd(win->jd_mid) + win->obs.lon; lst = fmod(lst, 2.0 * M_PI); if (lst < 0.0) lst += 2.0 * M_PI; /* RAAN window: Earth rotation during query + footprint pad */ earth_rot_rad = (win->jd_end - win->jd_start) * EARTH_ROT_RAD_PER_DAY; raan_window_half = earth_rot_rad / 2.0 + ground_footprint_deg(sma, win->min_el_deg) * DEG_TO_RAD; if (raan_window_half >= M_PI) return true; /* full rotation -- pass everything */ raan_diff = fabs(raan_projected - lst); if (raan_diff > M_PI) raan_diff = 2.0 * M_PI - raan_diff; return (raan_diff <= raan_window_half); } /* * spgist_tle_leaf_consistent -- final check on a leaf TLE * * Delegates to tle_passes_visibility_filter() for the &? operator. * recheck = false: the &? operator IS the superset filter. * The user runs predict_passes() on survivors for SGP4 ground truth. */ Datum spgist_tle_leaf_consistent(PG_FUNCTION_ARGS) { spgLeafConsistentIn *in = (spgLeafConsistentIn *) PG_GETARG_POINTER(0); spgLeafConsistentOut *out = (spgLeafConsistentOut *) PG_GETARG_POINTER(1); pg_tle *tle; int i; bool result = true; tle = (pg_tle *) DatumGetPointer(in->leafDatum); out->leafValue = in->leafDatum; out->recheck = false; for (i = 0; i < in->nkeys; i++) { if (in->scankeys[i].sk_strategy == 1) { ObserverWindow win; HeapTupleHeader composite; composite = DatumGetHeapTupleHeader(in->scankeys[i].sk_argument); extract_observer_window(composite, &win); if (!tle_passes_visibility_filter(tle, &win)) { result = false; break; } } } PG_RETURN_BOOL(result); } /* ================================================================ * Operator function: &? (visibility cone check) * ================================================================ */ /* * tle_visibility_possible(tle, observer_window) -> bool * * Standalone operator: can the satellite possibly be visible from * this observer during this time window? Combines altitude check, * latitude/inclination check, and RAAN filter. * * This is the same logic as leaf_consistent, callable directly * as a SQL operator for sequential scans or WHERE clauses. * * The indexed column (tle) MUST be the left argument so that * PostgreSQL can form a ScanKey and pass it to inner_consistent * for tree-level pruning. See skey.h:23-26. */ Datum tle_visibility_possible(PG_FUNCTION_ARGS) { pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0); HeapTupleHeader composite = PG_GETARG_HEAPTUPLEHEADER(1); ObserverWindow win; extract_observer_window(composite, &win); PG_RETURN_BOOL(tle_passes_visibility_filter(tle, &win)); }