pg_orrery/src/spgist_tle.c
Ryan Malloy 747b7ae60a Fix L1 inclination pruning for HEO orbits, add 66k benchmark
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.
2026-02-17 23:05:49 -07:00

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));
}