Bug: inner_consistent used sma_low for footprint calculation, but ground footprint grows with altitude. High-SMA bins (GTO, HEO) need sma_high to compute the maximum footprint — using sma_low caused 453 false negatives at high-latitude observers (Tromsoe). Fix: use sma_high (not sma_low) in L1 inclination pruning. Added regression test: GTO-debris (inc 5 deg, e=0.73) at Tromsoe must return identical results from seqscan and index scan. Benchmark on 65,886-object catalog (full Space-Track including decayed): 80-92% pruning, zero false negatives across 7 query patterns. SP-GiST beats seqscan for high-latitude observers.
815 lines
22 KiB
C
815 lines
22 KiB
C
/*
|
|
* 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 <math.h>
|
|
#include <float.h>
|
|
|
|
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));
|
|
}
|