Initial implementation of pg_orbit PostgreSQL extension
6 custom types (tle, eci_position, geodetic, topocentric, observer, pass_event), 67 SQL functions, 2 operators (&&, <->), and a GiST operator class for altitude-band indexing. Wraps Bill Gray's sat_code for SGP4/SDP4 propagation with WGS-72 constants for propagation and WGS-84 for coordinate output. All 5 regression tests pass on PG 18.
This commit is contained in:
commit
15a830dc40
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
# Build artifacts
|
||||
*.o
|
||||
*.bc
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# pg_regress output
|
||||
regression.diffs
|
||||
regression.out
|
||||
results/
|
||||
tmp_check/
|
||||
log/
|
||||
|
||||
# Editor files
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.vscode/
|
||||
.idea/
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
[submodule "lib/sat_code"]
|
||||
path = lib/sat_code
|
||||
url = https://github.com/Bill-Gray/sat_code.git
|
||||
147
CLAUDE.md
Normal file
147
CLAUDE.md
Normal file
@ -0,0 +1,147 @@
|
||||
# pg_orbit — PostgreSQL Extension for Orbital Mechanics
|
||||
|
||||
## What This Is
|
||||
A PostgreSQL extension that makes TLE/orbital data first-class types — the way PostGIS does for geographic data. Native C extension using PGXS, wrapping Bill Gray's `sat_code` SGP4/SDP4 implementation.
|
||||
|
||||
## Build System
|
||||
```bash
|
||||
make # Compile with PGXS
|
||||
make install # Install to PostgreSQL extensions dir
|
||||
make installcheck # Run regression tests
|
||||
```
|
||||
|
||||
Requires: PostgreSQL 14+ development headers (`pg_config` in PATH), GCC, Make.
|
||||
|
||||
## Project Layout
|
||||
```
|
||||
pg_orbit.control # Extension metadata
|
||||
Makefile # PGXS build
|
||||
sql/
|
||||
pg_orbit--0.1.0.sql # All type/function/operator definitions
|
||||
src/
|
||||
pg_orbit.c # PG_MODULE_MAGIC entry point
|
||||
tle_type.c # TLE custom type (input/output/binary/accessors)
|
||||
eci_type.c # ECI position type + geodetic/topocentric types
|
||||
observer_type.c # Observer location type with flexible parsing
|
||||
sgp4_funcs.c # sgp4_propagate(), sgp4_propagate_series()
|
||||
coord_funcs.c # eci_to_geodetic(), eci_to_topocentric(), subsatellite_point()
|
||||
pass_funcs.c # next_pass(), predict_passes(), pass_visible()
|
||||
gist_tle.c # GiST operator class for altitude-band indexing
|
||||
types.h # Shared struct definitions
|
||||
lib/
|
||||
sat_code/ # Bill Gray's SGP4 (MIT license, git submodule)
|
||||
test/
|
||||
sql/ # Regression test SQL
|
||||
expected/ # Expected output
|
||||
data/
|
||||
vallado_518.csv # 518 verification test vectors
|
||||
docs/
|
||||
DESIGN.md # Architecture decisions, theory-to-code mappings
|
||||
```
|
||||
|
||||
## Type System
|
||||
|
||||
### Core Types (all varlena or fixed-size, stored in tuples)
|
||||
|
||||
| Type | Storage | Description |
|
||||
|------|---------|-------------|
|
||||
| `tle` | ~160 bytes fixed | Parsed mean elements (not raw text) |
|
||||
| `eci_position` | 48 bytes | x,y,z + vx,vy,vz (km, km/s) in TEME |
|
||||
| `geodetic` | 24 bytes | lat, lon (radians), alt (km) above WGS-84 |
|
||||
| `topocentric` | 32 bytes | azimuth, elevation, range, range_rate |
|
||||
| `observer` | 24 bytes | lat, lon (radians), alt_m (meters) |
|
||||
| `pass_event` | 56 bytes | AOS/MAX/LOS times + max_el + AOS/LOS az |
|
||||
|
||||
### TLE Internal Struct
|
||||
Stores all parsed mean elements from the two-line format:
|
||||
- epoch (Julian date, float64)
|
||||
- inclination, eccentricity, RAAN, arg_perigee, mean_anomaly (radians, float64)
|
||||
- mean_motion (rev/day, float64), mean_motion_dot, mean_motion_ddot
|
||||
- bstar (drag coefficient, float64)
|
||||
- norad_id (int32), elset_num (int32), rev_num (int32)
|
||||
- classification (char), intl_designator (8 chars)
|
||||
- ephemeris_type (int8)
|
||||
|
||||
## Constant Chain of Custody
|
||||
|
||||
**This is the most critical design constraint.**
|
||||
|
||||
TLEs are mean elements fitted using WGS-72 constants. The elements absorb geodetic model biases — using WGS-84 constants for propagation silently corrupts position accuracy by kilometers.
|
||||
|
||||
### Rules
|
||||
1. **SGP4 propagation**: WGS-72 constants ONLY (mu, ae, J2, J3, J4, ke)
|
||||
2. **Coordinate output** (geodetic, topocentric): Convert to WGS-84 (a=6378.137km, f=1/298.257223563)
|
||||
3. **TEME frame**: Use only 4 of 106 IAU-80 nutation terms (matching SGP4's internal model)
|
||||
4. **Never mix**: WGS-72 propagation + WGS-84 output. No other combination.
|
||||
|
||||
### WGS-72 Constants (from Hoots & Roehrich STR#3)
|
||||
```c
|
||||
#define WGS72_MU 398600.8 /* km^3/s^2 */
|
||||
#define WGS72_AE 6378.135 /* km */
|
||||
#define WGS72_J2 0.001082616
|
||||
#define WGS72_J3 -0.00000253881
|
||||
#define WGS72_J4 -0.00000165597
|
||||
#define WGS72_KE 0.0743669161 /* (min)^(-1), = sqrt(mu) * 60 / ae^(3/2) */
|
||||
#define WGS72_XPDOTP 1440.0 / (2.0 * M_PI) /* min/rev */
|
||||
```
|
||||
|
||||
### WGS-84 Constants (for output only)
|
||||
```c
|
||||
#define WGS84_A 6378.137 /* km */
|
||||
#define WGS84_F (1.0 / 298.257223563)
|
||||
#define WGS84_E2 (WGS84_F * (2.0 - WGS84_F))
|
||||
```
|
||||
|
||||
## sat_code Submodule
|
||||
|
||||
Bill Gray's SGP4 implementation: https://github.com/Bill-Gray/sat_code
|
||||
|
||||
Key files we use:
|
||||
- `sgp4.c` / `sgp4.h` — SGP4/SDP4 propagator
|
||||
- `norad.h` — TLE struct definitions and constants
|
||||
|
||||
The submodule lives at `lib/sat_code/`. To initialize:
|
||||
```bash
|
||||
git submodule update --init
|
||||
```
|
||||
|
||||
### Integration Pattern
|
||||
```c
|
||||
#include "lib/sat_code/norad.h"
|
||||
|
||||
// Parse TLE lines into sat_code's tle_t struct
|
||||
// Call SGP4_init() once per TLE
|
||||
// Call SGP4() with minutes-since-epoch for each propagation
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Vallado 518 Test Vectors
|
||||
The definitive SGP4 verification dataset. Each row: NORAD ID, minutes since epoch, expected x,y,z,vx,vy,vz. All 518 must pass to machine epsilon before any other work proceeds.
|
||||
|
||||
### Regression Tests
|
||||
Standard PostgreSQL `make installcheck` framework:
|
||||
- `test/sql/*.sql` — test queries
|
||||
- `test/expected/*.out` — expected output
|
||||
- Tests run against a temporary database
|
||||
|
||||
### Test Categories
|
||||
1. **tle_parse** — TLE input/output round-trip, malformed input rejection
|
||||
2. **sgp4_propagate** — Vallado vectors, edge cases (deep space, high eccentricity)
|
||||
3. **coord_transforms** — TEME->geodetic, TEME->topocentric accuracy
|
||||
4. **pass_prediction** — Known ISS passes, edge cases (polar, retrograde)
|
||||
5. **gist_index** — Index scan vs sequential scan equivalence
|
||||
|
||||
## Coding Style
|
||||
- Standard PostgreSQL extension C style
|
||||
- `ereport(ERROR, ...)` for user-facing errors, never `elog(ERROR, ...)`
|
||||
- All memory allocation through `palloc`/`pfree` (PostgreSQL memory contexts)
|
||||
- Comments explain "why", not "what"
|
||||
- No global mutable state — all computation from function arguments
|
||||
- Functions that call `SGP4()` must handle the error return code
|
||||
|
||||
## Git Conventions
|
||||
- One commit per logical change
|
||||
- Branch per phase: `phase/1-tle-sgp4`, `phase/2-coordinates`, etc.
|
||||
- Tag releases: `v0.1.0`, `v0.2.0`, etc.
|
||||
- Commit messages: imperative mood, no AI attribution
|
||||
19
LICENSE
Normal file
19
LICENSE
Normal file
@ -0,0 +1,19 @@
|
||||
PostgreSQL License
|
||||
|
||||
Copyright (c) 2025, Ryan Malloy
|
||||
|
||||
Permission to use, copy, modify, and distribute this software and its
|
||||
documentation for any purpose, without fee, and without a written agreement
|
||||
is hereby granted, provided that the above copyright notice and this
|
||||
paragraph and the following two paragraphs appear in all copies.
|
||||
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT,
|
||||
SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS,
|
||||
ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF THE
|
||||
AUTHORS HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
THE AUTHORS SPECIFICALLY DISCLAIM ANY WARRANTIES, INCLUDING, BUT NOT LIMITED
|
||||
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND THE
|
||||
AUTHORS HAVE NO OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES,
|
||||
ENHANCEMENTS, OR MODIFICATIONS.
|
||||
36
Makefile
Normal file
36
Makefile
Normal file
@ -0,0 +1,36 @@
|
||||
MODULE_big = pg_orbit
|
||||
EXTENSION = pg_orbit
|
||||
DATA = sql/pg_orbit--0.1.0.sql
|
||||
|
||||
# Our extension C sources
|
||||
OBJS = src/pg_orbit.o src/tle_type.o src/eci_type.o src/observer_type.o \
|
||||
src/sgp4_funcs.o src/coord_funcs.o src/pass_funcs.o src/gist_tle.o
|
||||
|
||||
# sat_code C++ sources (compiled with g++, linked with extern "C" symbols)
|
||||
SAT_CODE_DIR = lib/sat_code
|
||||
SAT_CODE_SRCS = $(SAT_CODE_DIR)/sgp4.cpp $(SAT_CODE_DIR)/sdp4.cpp \
|
||||
$(SAT_CODE_DIR)/deep.cpp $(SAT_CODE_DIR)/common.cpp \
|
||||
$(SAT_CODE_DIR)/basics.cpp $(SAT_CODE_DIR)/get_el.cpp \
|
||||
$(SAT_CODE_DIR)/tle_out.cpp
|
||||
SAT_CODE_OBJS = $(SAT_CODE_SRCS:.cpp=.o)
|
||||
|
||||
OBJS += $(SAT_CODE_OBJS)
|
||||
|
||||
# Regression tests
|
||||
REGRESS = tle_parse sgp4_propagate coord_transforms pass_prediction gist_index
|
||||
REGRESS_OPTS = --inputdir=test
|
||||
|
||||
# Need C++ runtime for sat_code
|
||||
SHLIB_LINK += -lstdc++ -lm
|
||||
|
||||
# Compiler flags
|
||||
PG_CPPFLAGS = -I$(SAT_CODE_DIR)
|
||||
|
||||
# Use PGXS
|
||||
PG_CONFIG ?= pg_config
|
||||
PGXS := $(shell $(PG_CONFIG) --pgxs)
|
||||
include $(PGXS)
|
||||
|
||||
# Rule for compiling sat_code C++ files
|
||||
$(SAT_CODE_DIR)/%.o: $(SAT_CODE_DIR)/%.cpp
|
||||
$(CXX) $(CXXFLAGS) -fPIC -I$(SAT_CODE_DIR) -c -o $@ $<
|
||||
305
README.md
Normal file
305
README.md
Normal file
@ -0,0 +1,305 @@
|
||||
# pg_orbit
|
||||
|
||||
Orbital mechanics types and functions for PostgreSQL.
|
||||
|
||||
pg_orbit adds native SQL types for TLEs, orbital positions, ground stations, and
|
||||
satellite passes. It wraps Bill Gray's [sat_code](https://github.com/Bill-Gray/sat_code)
|
||||
(MIT) for SGP4/SDP4 propagation, provides coordinate transforms between inertial
|
||||
and ground-referenced frames, predicts passes over observer locations, and supports
|
||||
GiST-indexed conjunction screening on altitude bands.
|
||||
|
||||
Think PostGIS, but for objects in orbit.
|
||||
|
||||
## Installation
|
||||
|
||||
Requirements:
|
||||
- PostgreSQL 14+ development headers (`pg_config` in PATH)
|
||||
- GCC and Make
|
||||
- C++ compiler (for sat_code)
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/...
|
||||
cd pg_orbit
|
||||
make
|
||||
sudo make install
|
||||
```
|
||||
|
||||
Then in your database:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION pg_orbit;
|
||||
```
|
||||
|
||||
If you cloned without `--recurse-submodules`, initialize the sat_code dependency:
|
||||
|
||||
```bash
|
||||
git submodule update --init
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```sql
|
||||
-- Create a table with a TLE column
|
||||
CREATE TABLE satellites (
|
||||
norad_id int PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
tle tle NOT NULL
|
||||
);
|
||||
|
||||
-- Insert a TLE (standard two-line format, concatenated with newline)
|
||||
INSERT INTO satellites VALUES (25544, 'ISS',
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||
2 25544 51.6400 208.5000 0006000 30.0000 330.0000 15.50000000400000');
|
||||
|
||||
-- Propagate to a point in time
|
||||
SELECT sgp4_propagate(tle, now()) FROM satellites WHERE norad_id = 25544;
|
||||
|
||||
-- Subsatellite point (nadir)
|
||||
SELECT subsatellite_point(tle, now()) FROM satellites WHERE norad_id = 25544;
|
||||
|
||||
-- All passes over Boulder, CO in the next 24 hours (min 10 deg elevation)
|
||||
SELECT sat.name, p.*
|
||||
FROM satellites sat,
|
||||
LATERAL predict_passes(sat.tle, '40.0N 105.3W 1655m'::observer,
|
||||
now(), now() + '24h', 10.0) p
|
||||
ORDER BY pass_aos_time(p);
|
||||
|
||||
-- Conjunction screening with GiST index
|
||||
CREATE INDEX ON satellites USING gist (tle);
|
||||
|
||||
SELECT a.name, b.name, tle_distance(a.tle, b.tle, now())
|
||||
FROM satellites a, satellites b
|
||||
WHERE a.tle && b.tle AND a.norad_id < b.norad_id
|
||||
AND tle_distance(a.tle, b.tle, now()) < 10.0;
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
| Type | Size | Description |
|
||||
|------|------|-------------|
|
||||
| `tle` | 112 bytes | Parsed mean orbital elements (epoch, Keplerian elements, drag terms, identifiers). Stored as a fixed-size struct, not raw text. |
|
||||
| `eci_position` | 48 bytes | Position (km) and velocity (km/s) in the True Equator Mean Equinox (TEME) frame. |
|
||||
| `geodetic` | 24 bytes | Latitude/longitude (degrees) and altitude (km) on the WGS-84 ellipsoid. |
|
||||
| `topocentric` | 32 bytes | Azimuth, elevation (degrees), slant range (km), and range rate (km/s) relative to an observer. |
|
||||
| `observer` | 24 bytes | Ground station location. Accepts human-readable input: `'40.0N 105.3W 1655m'` or decimal degrees. |
|
||||
| `pass_event` | 48 bytes | Satellite pass with AOS/MAX/LOS times, max elevation, and AOS/LOS azimuths. |
|
||||
|
||||
### Input Formats
|
||||
|
||||
**tle** -- Standard two-line format (lines joined by newline):
|
||||
|
||||
```sql
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||
2 25544 51.6400 208.5000 0006000 30.0000 330.0000 15.50000000400000'::tle;
|
||||
```
|
||||
|
||||
**observer** -- Flexible ground station input:
|
||||
|
||||
```sql
|
||||
-- Compass notation with altitude
|
||||
SELECT '40.0N 105.3W 1655m'::observer;
|
||||
|
||||
-- Decimal degrees (positive East, altitude in meters)
|
||||
SELECT '40.0 -105.3 1655'::observer;
|
||||
```
|
||||
|
||||
## Functions
|
||||
|
||||
### TLE Accessors
|
||||
|
||||
| Function | Returns | Description |
|
||||
|----------|---------|-------------|
|
||||
| `tle_norad_id(tle)` | `int4` | NORAD catalog number |
|
||||
| `tle_epoch(tle)` | `float8` | Epoch as Julian date (UTC) |
|
||||
| `tle_inclination(tle)` | `float8` | Inclination in degrees |
|
||||
| `tle_eccentricity(tle)` | `float8` | Eccentricity (dimensionless) |
|
||||
| `tle_raan(tle)` | `float8` | Right ascension of ascending node (degrees) |
|
||||
| `tle_arg_perigee(tle)` | `float8` | Argument of perigee (degrees) |
|
||||
| `tle_mean_anomaly(tle)` | `float8` | Mean anomaly (degrees) |
|
||||
| `tle_mean_motion(tle)` | `float8` | Mean motion (rev/day) |
|
||||
| `tle_bstar(tle)` | `float8` | B* drag coefficient (1/earth-radii) |
|
||||
| `tle_period(tle)` | `float8` | Orbital period (minutes) |
|
||||
| `tle_perigee(tle)` | `float8` | Perigee altitude (km above WGS-72 ellipsoid) |
|
||||
| `tle_apogee(tle)` | `float8` | Apogee altitude (km above WGS-72 ellipsoid) |
|
||||
| `tle_age(tle, timestamptz)` | `float8` | TLE age in days (positive = past epoch) |
|
||||
| `tle_intl_desig(tle)` | `text` | International designator (COSPAR ID) |
|
||||
|
||||
### Propagation
|
||||
|
||||
| Function | Returns | Description |
|
||||
|----------|---------|-------------|
|
||||
| `sgp4_propagate(tle, timestamptz)` | `eci_position` | Propagate to a point in time. Uses SGP4 for near-earth, SDP4 for deep-space. |
|
||||
| `sgp4_propagate_series(tle, start, stop, step)` | `SETOF (t, x, y, z, vx, vy, vz)` | Time series of TEME positions at regular intervals. |
|
||||
| `tle_distance(tle, tle, timestamptz)` | `float8` | Euclidean distance (km) between two objects at a reference time. |
|
||||
|
||||
### Coordinate Transforms
|
||||
|
||||
| Function | Returns | Description |
|
||||
|----------|---------|-------------|
|
||||
| `eci_to_geodetic(eci_position, timestamptz)` | `geodetic` | TEME to WGS-84 geodetic (lat/lon/alt). Requires time for Earth rotation. |
|
||||
| `eci_to_topocentric(eci_position, observer, timestamptz)` | `topocentric` | TEME to observer-relative az/el/range. |
|
||||
| `subsatellite_point(tle, timestamptz)` | `geodetic` | Nadir point on WGS-84 ellipsoid. Propagates internally. |
|
||||
| `ground_track(tle, start, stop, step)` | `SETOF (t, lat, lon, alt)` | Ground track as time series of subsatellite points. |
|
||||
|
||||
### ECI Accessors
|
||||
|
||||
| Function | Returns | Description |
|
||||
|----------|---------|-------------|
|
||||
| `eci_x(eci_position)` | `float8` | X position (km, TEME) |
|
||||
| `eci_y(eci_position)` | `float8` | Y position (km, TEME) |
|
||||
| `eci_z(eci_position)` | `float8` | Z position (km, TEME) |
|
||||
| `eci_vx(eci_position)` | `float8` | X velocity (km/s) |
|
||||
| `eci_vy(eci_position)` | `float8` | Y velocity (km/s) |
|
||||
| `eci_vz(eci_position)` | `float8` | Z velocity (km/s) |
|
||||
| `eci_speed(eci_position)` | `float8` | Velocity magnitude (km/s) |
|
||||
| `eci_altitude(eci_position)` | `float8` | Geocentric altitude (km) |
|
||||
|
||||
### Topocentric Accessors
|
||||
|
||||
| Function | Returns | Description |
|
||||
|----------|---------|-------------|
|
||||
| `topo_azimuth(topocentric)` | `float8` | Azimuth in degrees (0=N, 90=E, 180=S, 270=W) |
|
||||
| `topo_elevation(topocentric)` | `float8` | Elevation in degrees (0=horizon, 90=zenith) |
|
||||
| `topo_range(topocentric)` | `float8` | Slant range (km) |
|
||||
| `topo_range_rate(topocentric)` | `float8` | Range rate (km/s, positive = receding) |
|
||||
|
||||
### Pass Prediction
|
||||
|
||||
| Function | Returns | Description |
|
||||
|----------|---------|-------------|
|
||||
| `next_pass(tle, observer, timestamptz)` | `pass_event` | Next pass over observer. Searches up to 7 days. |
|
||||
| `predict_passes(tle, observer, start, stop [, min_el])` | `SETOF pass_event` | All passes in a time window. Optional minimum elevation (degrees). |
|
||||
| `pass_visible(tle, observer, start, stop)` | `boolean` | True if any pass occurs in the time window. |
|
||||
|
||||
### Pass Event Accessors
|
||||
|
||||
| Function | Returns | Description |
|
||||
|----------|---------|-------------|
|
||||
| `pass_aos_time(pass_event)` | `timestamptz` | Acquisition of signal time |
|
||||
| `pass_max_el_time(pass_event)` | `timestamptz` | Maximum elevation time |
|
||||
| `pass_los_time(pass_event)` | `timestamptz` | Loss of signal time |
|
||||
| `pass_max_elevation(pass_event)` | `float8` | Maximum elevation (degrees) |
|
||||
| `pass_aos_azimuth(pass_event)` | `float8` | AOS azimuth (degrees, 0=N) |
|
||||
| `pass_los_azimuth(pass_event)` | `float8` | LOS azimuth (degrees, 0=N) |
|
||||
| `pass_duration(pass_event)` | `interval` | Pass duration (LOS - AOS) |
|
||||
|
||||
### Operators
|
||||
|
||||
| Operator | Operands | Description |
|
||||
|----------|----------|-------------|
|
||||
| `&&` | `tle, tle` | Altitude band overlap. Necessary (not sufficient) condition for conjunction. |
|
||||
| `<->` | `tle, tle` | Minimum altitude-band separation in km. Returns 0 if bands overlap. |
|
||||
|
||||
Both operators are supported by the GiST `tle_ops` operator class for indexed scans.
|
||||
|
||||
## GiST Indexing
|
||||
|
||||
The `tle_ops` operator class indexes TLEs by their altitude band (perigee to apogee).
|
||||
This provides fast filtering for conjunction screening: only pairs whose altitude
|
||||
bands overlap can possibly be close to each other.
|
||||
|
||||
```sql
|
||||
-- Create the index
|
||||
CREATE INDEX idx_tle_alt ON satellites USING gist (tle);
|
||||
|
||||
-- The && operator triggers index scans
|
||||
EXPLAIN SELECT a.name, b.name
|
||||
FROM satellites a, satellites b
|
||||
WHERE a.tle && b.tle AND a.norad_id < b.norad_id;
|
||||
|
||||
-- KNN ordering by altitude-band distance
|
||||
SELECT name, tle <-> (SELECT tle FROM satellites WHERE norad_id = 25544) AS sep
|
||||
FROM satellites
|
||||
ORDER BY tle <-> (SELECT tle FROM satellites WHERE norad_id = 25544)
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
The index reduces conjunction candidate pairs from O(n^2) to the set of objects with
|
||||
intersecting altitude bands, which is then refined by computing actual `tle_distance()`
|
||||
at a specific time.
|
||||
|
||||
## Geodetic Constants
|
||||
|
||||
TLEs are mean elements fitted using WGS-72 constants. Using WGS-84 constants for
|
||||
propagation introduces kilometer-scale position errors because the elements absorb
|
||||
geodetic model biases during the fitting process.
|
||||
|
||||
pg_orbit enforces this:
|
||||
- **Propagation (SGP4/SDP4):** WGS-72 constants only (mu, ae, J2, J3, J4, ke)
|
||||
- **Coordinate output (geodetic, topocentric):** WGS-84 (a=6378.137 km, f=1/298.257223563)
|
||||
|
||||
These are not interchangeable. Mixing them is a silent accuracy loss.
|
||||
|
||||
## Building from Source
|
||||
|
||||
```bash
|
||||
# Build (requires pg_config in PATH)
|
||||
make
|
||||
|
||||
# Install to PostgreSQL extension directory
|
||||
sudo make install
|
||||
|
||||
# Run regression tests against a live database
|
||||
make installcheck
|
||||
```
|
||||
|
||||
Override `pg_config` location if needed:
|
||||
|
||||
```bash
|
||||
make PG_CONFIG=/usr/lib/postgresql/16/bin/pg_config
|
||||
sudo make PG_CONFIG=/usr/lib/postgresql/16/bin/pg_config install
|
||||
```
|
||||
|
||||
### Project Layout
|
||||
|
||||
```
|
||||
pg_orbit.control Extension metadata (version 0.1.0)
|
||||
Makefile PGXS build
|
||||
sql/
|
||||
pg_orbit--0.1.0.sql Type, function, operator, and GiST definitions
|
||||
src/
|
||||
pg_orbit.c PG_MODULE_MAGIC entry point
|
||||
tle_type.c TLE input/output/binary/accessors
|
||||
eci_type.c ECI position type
|
||||
observer_type.c Observer type with flexible parsing
|
||||
sgp4_funcs.c SGP4 propagation and distance
|
||||
coord_funcs.c Coordinate transforms (TEME/geodetic/topocentric)
|
||||
pass_funcs.c Pass prediction (next_pass, predict_passes)
|
||||
gist_tle.c GiST operator class for altitude-band indexing
|
||||
types.h Shared struct definitions and constants
|
||||
lib/
|
||||
sat_code/ Bill Gray's SGP4/SDP4 (MIT, git submodule)
|
||||
test/
|
||||
sql/ Regression test SQL
|
||||
expected/ Expected output
|
||||
data/
|
||||
vallado_518.csv 518 verification test vectors (Vallado et al.)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
pg_orbit uses the standard PostgreSQL regression test framework.
|
||||
|
||||
```bash
|
||||
make installcheck
|
||||
```
|
||||
|
||||
Test categories:
|
||||
|
||||
| Suite | Coverage |
|
||||
|-------|----------|
|
||||
| `tle_parse` | TLE input/output round-trip, malformed input rejection |
|
||||
| `sgp4_propagate` | Vallado 518 test vectors, deep-space and high-eccentricity edge cases |
|
||||
| `coord_transforms` | TEME to geodetic, TEME to topocentric accuracy |
|
||||
| `pass_prediction` | Known ISS passes, polar and retrograde orbits |
|
||||
| `gist_index` | Index scan vs. sequential scan result equivalence |
|
||||
|
||||
The Vallado 518 test vectors are the standard SGP4 verification dataset. Each row
|
||||
contains a NORAD ID, minutes since epoch, and expected position/velocity. All 518
|
||||
must pass to machine epsilon.
|
||||
|
||||
## License
|
||||
|
||||
[PostgreSQL License](LICENSE). Copyright (c) 2025, Ryan Malloy.
|
||||
|
||||
The bundled sat_code library is separately licensed under the MIT license.
|
||||
590
docs/DESIGN.md
Normal file
590
docs/DESIGN.md
Normal file
@ -0,0 +1,590 @@
|
||||
# pg_orbit Design Document
|
||||
|
||||
Internal architecture notes. Documents WHY decisions were made,
|
||||
not how to use the extension. Intended audience: future maintainers
|
||||
who need to modify pg_orbit without breaking physical correctness.
|
||||
|
||||
|
||||
## 1. Constant Chain of Custody
|
||||
|
||||
This is the single most critical design constraint in the extension.
|
||||
Get it wrong and positions silently drift by kilometers. There is no
|
||||
runtime check that can detect this class of error after the fact.
|
||||
|
||||
### The Problem
|
||||
|
||||
Two-Line Elements are not raw orbital measurements. They are *mean*
|
||||
elements produced by a differential correction process that fits
|
||||
observed positions against an SGP4 propagator running with a specific
|
||||
set of geopotential constants (WGS-72). The mean elements absorb
|
||||
geodetic model biases -- the eccentricity, inclination, and mean
|
||||
motion encode corrections that only make physical sense when propagated
|
||||
with the same constants used to generate them.
|
||||
|
||||
Substituting WGS-84 constants (or any other set) into the propagator
|
||||
does not "upgrade" accuracy. It breaks the internal consistency of the
|
||||
element set. The resulting position error can exceed the natural
|
||||
prediction error of the TLE by an order of magnitude.
|
||||
|
||||
### The Rules
|
||||
|
||||
1. **SGP4/SDP4 propagation**: WGS-72 constants only (mu, ae, J2, J3, J4, ke).
|
||||
These flow through sat_code's `norad_in.h` defines and are never
|
||||
overridden.
|
||||
|
||||
2. **Coordinate output** (geodetic lat/lon/alt, topocentric az/el/range):
|
||||
WGS-84 ellipsoid (a = 6378.137 km, f = 1/298.257223563). This is
|
||||
the modern standard for ground-station positioning and GPS receivers.
|
||||
|
||||
3. **TEME frame**: The SGP4 output frame (True Equator, Mean Equinox)
|
||||
uses only 4 of the 106 IAU-80 nutation terms. Applying the full
|
||||
nutation model would "correct" for effects that SGP4 already accounts
|
||||
for internally, introducing error rather than removing it.
|
||||
|
||||
4. **No other combination is valid.** WGS-72 for propagation, WGS-84 for
|
||||
output. Perigee and apogee altitudes from `tle_perigee()` and
|
||||
`tle_apogee()` use WGS-72 AE because they derive from mean elements.
|
||||
Geodetic altitude from `eci_to_geodetic()` uses WGS-84 because it
|
||||
converts a physical position.
|
||||
|
||||
### Constant Inventory
|
||||
|
||||
| Constant | Source Paper | Value | pg_orbit Location | sat_code Location |
|
||||
|----------|-------------|-------|-------------------|-------------------|
|
||||
| ae (equatorial radius) | Hoots & Roehrich STR#3 | 6378.135 km | `types.h:20` (WGS72_AE) | `norad_in.h:64` (earth_radius_in_km) |
|
||||
| J2 | Hoots & Roehrich STR#3 | 0.001082616 | `types.h:21` (WGS72_J2) | `norad_in.h:69` (xj2) |
|
||||
| J3 | Hoots & Roehrich STR#3 | -2.53881e-6 | `types.h:22` (WGS72_J3) | `norad_in.h:63` (xj3) |
|
||||
| J4 | Hoots & Roehrich STR#3 | -1.65597e-6 | `types.h:23` (WGS72_J4) | `norad_in.h:79` (xj4) |
|
||||
| ke | Hoots & Roehrich STR#3 | 0.0743669161331734132 min^-1 | `types.h:24` (WGS72_KE) | `norad_in.h:83` (xke) |
|
||||
| mu | Hoots & Roehrich STR#3 | 398600.8 km^3/s^2 | `types.h:19` (WGS72_MU) | (implicit in xke) |
|
||||
| WGS-84 a | NIMA TR8350.2 | 6378.137 km | `types.h:31` (WGS84_A) | -- |
|
||||
| WGS-84 f | NIMA TR8350.2 | 1/298.257223563 | `types.h:32` (WGS84_F) | -- |
|
||||
|
||||
Note that `types.h` carries a parallel copy of the WGS-72 constants
|
||||
even though sat_code defines them in `norad_in.h`. This is intentional:
|
||||
`types.h` is the single header for all pg_orbit C sources, and
|
||||
`norad_in.h` is an internal sat_code header not meant for external
|
||||
consumers. The GiST index (`gist_tle.c`) and TLE accessor functions
|
||||
(`tle_type.c`) need KE and AE without pulling in sat_code internals.
|
||||
The values MUST be identical.
|
||||
|
||||
### Why Two Copies of AE?
|
||||
|
||||
`tle_perigee()`, `tle_apogee()`, and the GiST altitude-band computation
|
||||
all use `WGS72_KE` and `WGS72_AE` from `types.h`. They compute:
|
||||
|
||||
a_er = (KE / n)^(2/3) [earth radii]
|
||||
perigee_km = a_er * (1 - e) * AE - AE
|
||||
|
||||
This MUST use WGS-72 AE (6378.135), not WGS-84 (6378.137), because `n`
|
||||
is a mean motion fitted against the WGS-72 geopotential. Using the
|
||||
wrong radius shifts every altitude by 2 meters -- small in absolute terms
|
||||
but wrong in principle, and the error compounds in index operations.
|
||||
|
||||
|
||||
## 2. SGP4 Implementation Choice
|
||||
|
||||
pg_orbit wraps Bill Gray's `sat_code` library (MIT license, Project Pluto).
|
||||
|
||||
### Why sat_code over alternatives
|
||||
|
||||
**Vallado's reference implementation** (from the STR#3 revision paper) is
|
||||
the canonical source but has two problems: it is written in C++ with heavy
|
||||
use of global state, and its license is unclear for embedding in a
|
||||
PostgreSQL extension.
|
||||
|
||||
**libsgp4** (various forks on GitHub) is typically a C++ class hierarchy
|
||||
that assumes an object-per-satellite lifecycle. This conflicts with
|
||||
PostgreSQL's per-call execution model where we cannot persist C++ objects
|
||||
across function invocations.
|
||||
|
||||
**sat_code** was chosen because:
|
||||
|
||||
1. **Pure C linkage.** All public functions are declared `extern "C"` in
|
||||
`norad.h` (lines 97-133). The library is compiled as C++ but exposes
|
||||
a flat C function interface: `SGP4_init()`, `SGP4()`, `SDP4_init()`,
|
||||
`SDP4()`, `parse_elements()`, `select_ephemeris()`.
|
||||
|
||||
2. **No global mutable state.** The propagator state lives in a caller-
|
||||
allocated `double params[N_SAT_PARAMS]` array. This maps directly to
|
||||
PostgreSQL's `palloc`-based memory model: allocate before propagation,
|
||||
free after.
|
||||
|
||||
3. **Includes deep-space SDP4.** Many SGP4 implementations only handle
|
||||
near-earth orbits (period < 225 minutes). sat_code includes the full
|
||||
SDP4 with lunar/solar perturbations via `deep.cpp`, handling GEO,
|
||||
Molniya, and GPS orbits.
|
||||
|
||||
4. **MIT license.** Compatible with the PostgreSQL License for embedding
|
||||
in a shared library.
|
||||
|
||||
5. **Actively maintained.** Used in Bill Gray's Find_Orb production
|
||||
astrometry software. Bug fixes reach us through the git submodule.
|
||||
|
||||
### Build Integration
|
||||
|
||||
The Makefile compiles sat_code's `.cpp` files with `g++` and links the
|
||||
resulting `.o` files into the PostgreSQL shared library alongside our C
|
||||
sources. The `-lstdc++` link flag pulls in the C++ runtime. This is the
|
||||
same pattern used by PostGIS for GEOS integration (C extension linking
|
||||
C++ library objects).
|
||||
|
||||
```
|
||||
src/*.c --> gcc --> .o --|
|
||||
lib/sat_code/*.cpp -> g++ -> .o --|--> pg_orbit.so
|
||||
-lstdc++ -lm
|
||||
```
|
||||
|
||||
The `-I$(SAT_CODE_DIR)` flag lets our C files `#include "norad.h"`
|
||||
directly.
|
||||
|
||||
|
||||
## 3. Type System Design
|
||||
|
||||
### Design Principles
|
||||
|
||||
Every pg_orbit type is fixed-size, not varlena. This means:
|
||||
|
||||
- No TOAST overhead (no detoasting on access)
|
||||
- Direct pointer access via `PG_GETARG_POINTER(n)` -- no copy
|
||||
- Predictable memory layout for binary I/O (`tle_recv`/`tle_send`)
|
||||
- All types use `ALIGNMENT = double` to satisfy the strictest member
|
||||
alignment requirement (all structs contain `double` fields)
|
||||
- `STORAGE = plain` -- the type is never compressed or moved to TOAST
|
||||
|
||||
### TLE Type (112 bytes)
|
||||
|
||||
The TLE type stores **parsed mean elements**, not raw text.
|
||||
|
||||
This is the most important type design decision. Alternatives considered:
|
||||
|
||||
1. **Store raw 69+69 character lines, parse on every propagation.**
|
||||
Rejected. Parsing is ~10x slower than propagation itself for a
|
||||
pre-initialized model. Every `sgp4_propagate()` call would pay
|
||||
the parse cost.
|
||||
|
||||
2. **Store raw text plus parsed cache.**
|
||||
Rejected. Doubles the storage for no benefit. The parsed form
|
||||
round-trips perfectly through `write_elements_in_tle_format()`.
|
||||
|
||||
3. **Store parsed mean elements only.**
|
||||
Chosen. Input validation happens once at `tle_in()` time via
|
||||
sat_code's `parse_elements()`. The text representation is
|
||||
reconstructed on output via `write_elements_in_tle_format()`.
|
||||
|
||||
Storage layout:
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
0 88 11 doubles: epoch, inclination, raan, eccentricity,
|
||||
arg_perigee, mean_anomaly, mean_motion,
|
||||
mean_motion_dot, mean_motion_ddot, bstar
|
||||
(one unused slot in the double array for alignment)
|
||||
80 12 3 int32: norad_id, elset_num, rev_num
|
||||
92 12 classification (1), ephemeris_type (1),
|
||||
intl_desig (9), pad (1)
|
||||
----
|
||||
112 bytes total
|
||||
```
|
||||
|
||||
The SQL definition declares `INTERNALLENGTH = 112`. This is smaller
|
||||
than the raw two-line text (138+ bytes with line separator) and avoids
|
||||
the 4-byte varlena header overhead.
|
||||
|
||||
Angular elements are stored in radians (matching sat_code's internal
|
||||
representation). Accessor functions convert to degrees for human
|
||||
consumption. Mean motion is stored in radians/minute; the accessor
|
||||
returns revolutions/day.
|
||||
|
||||
### ECI Position Type (48 bytes)
|
||||
|
||||
Six doubles: x, y, z (km), vx, vy, vz (km/s).
|
||||
|
||||
SGP4 outputs velocity in km/min. We convert to km/s at the boundary
|
||||
(`sgp4_funcs.c`, lines 181-183: `vel[i] / 60.0`). This conversion
|
||||
happens exactly once, at the point where the pg_eci struct is populated.
|
||||
Internally, all velocity in pg_orbit is km/s.
|
||||
|
||||
### Geodetic Type (24 bytes)
|
||||
|
||||
Three doubles: lat, lon (radians), alt (km above WGS-84).
|
||||
|
||||
Radians internally, degrees in text representation. The accessor
|
||||
functions perform the conversion.
|
||||
|
||||
### Observer Type (24 bytes)
|
||||
|
||||
Three doubles: lat, lon (radians), alt_m (meters above WGS-84).
|
||||
|
||||
Note the asymmetry with geodetic: observer altitude is in meters
|
||||
(matching GPS receiver output and ground station databases), while
|
||||
geodetic altitude is in km (matching orbital altitude conventions).
|
||||
This prevents unit confusion at the API boundary -- you set up a
|
||||
ground station in meters, you get satellite altitude in kilometers.
|
||||
|
||||
### Topocentric Type (32 bytes)
|
||||
|
||||
Four doubles: azimuth, elevation (radians), range_km, range_rate (km/s).
|
||||
|
||||
### Pass Event Type (48 bytes)
|
||||
|
||||
Three int64 (TimestampTz): aos_time, max_el_time, los_time.
|
||||
Three doubles: max_elevation (degrees), aos_azimuth (degrees),
|
||||
los_azimuth (degrees).
|
||||
|
||||
Times are stored as native PostgreSQL TimestampTz values (microseconds
|
||||
since 2000-01-01 00:00:00 UTC). This allows direct comparison with
|
||||
SQL timestamp expressions without conversion.
|
||||
|
||||
|
||||
## 4. TEME to ECEF Transform
|
||||
|
||||
SGP4 outputs position and velocity in the TEME (True Equator, Mean
|
||||
Equinox) frame. Converting to Earth-fixed coordinates for geodetic
|
||||
and topocentric output requires a frame rotation.
|
||||
|
||||
### The Rotation
|
||||
|
||||
TEME to ECEF is a single Z-axis rotation by the negative of the
|
||||
Greenwich Mean Sidereal Time (GMST) angle:
|
||||
|
||||
```
|
||||
[x_ecef] [ cos(g) sin(g) 0 ] [x_teme]
|
||||
[y_ecef] = [-sin(g) cos(g) 0 ] [y_teme]
|
||||
[z_ecef] [ 0 0 1 ] [z_teme]
|
||||
```
|
||||
|
||||
This is implemented in `coord_funcs.c:teme_to_ecef()` and
|
||||
`pass_funcs.c:teme_to_ecef()` (duplicated -- see note below).
|
||||
|
||||
### GMST Computation
|
||||
|
||||
GMST is computed from the Julian date using the IAU 1982 formula,
|
||||
which is the same low-precision model that SGP4 uses internally.
|
||||
From Vallado (2013) Eq. 3-47:
|
||||
|
||||
```c
|
||||
T_UT1 = (JD - 2451545.0) / 36525.0
|
||||
GMST = 67310.54841
|
||||
+ (876600*3600 + 8640184.812866) * T_UT1
|
||||
+ 0.093104 * T_UT1^2
|
||||
- 6.2e-6 * T_UT1^3
|
||||
```
|
||||
|
||||
The result is in seconds of time, converted to radians by multiplying
|
||||
by pi/43200 and normalized to [0, 2*pi).
|
||||
|
||||
We deliberately do NOT use a higher-precision GMST model (e.g., IAU 2000A).
|
||||
The SGP4 output is only accurate to the precision of its own GMST model;
|
||||
applying a more precise rotation would not improve the final position and
|
||||
could introduce a systematic offset.
|
||||
|
||||
### Velocity Transform
|
||||
|
||||
The velocity transform includes the Earth angular velocity cross product
|
||||
term, accounting for the rotating reference frame:
|
||||
|
||||
```
|
||||
v_ecef = R(-g) * v_teme + omega_E x r_ecef
|
||||
```
|
||||
|
||||
where omega_E = 7.2921158553e-5 rad/s. In `pass_funcs.c`, the velocity
|
||||
cross product uses omega_E in rad/min (multiplied by 60.0) because
|
||||
sat_code outputs velocity in km/min.
|
||||
|
||||
### ECEF to Geodetic
|
||||
|
||||
The ECEF-to-geodetic conversion uses Bowring's iterative method, which
|
||||
converges in 2-3 iterations for LEO altitudes:
|
||||
|
||||
```
|
||||
phi_0 = atan2(z, p * (1 - e^2)) [initial estimate]
|
||||
N = a / sqrt(1 - e^2 * sin^2(phi)) [prime vertical radius]
|
||||
phi = atan2(z + e^2 * N * sin(phi), p) [iterate]
|
||||
```
|
||||
|
||||
Convergence criterion: 1.0e-12 radians (sub-millimeter at Earth's surface).
|
||||
Maximum 10 iterations as a safety bound, though LEO cases converge in 2-3.
|
||||
|
||||
We chose Bowring's method over Vermeille's or Heikkinen's closed-form
|
||||
solutions because it is simpler, well-tested, and the iteration cost is
|
||||
negligible compared to the SGP4 propagation that precedes it.
|
||||
|
||||
### Why Duplicated Helpers
|
||||
|
||||
`coord_funcs.c` and `pass_funcs.c` each contain their own static copies
|
||||
of `pg_tle_to_sat_code()`, `gmst_from_jd()`, `teme_to_ecef()`,
|
||||
`observer_to_ecef()`, and `ecef_to_topocentric()`. All `.c` files link
|
||||
into a single `.so`, so we could share these through a common object file.
|
||||
We chose duplication instead because:
|
||||
|
||||
1. Each translation unit is self-contained. No hidden coupling between
|
||||
files through shared internal functions.
|
||||
2. The functions are small (5-20 lines each). The binary size increase
|
||||
is negligible.
|
||||
3. The compiler can inline them within each translation unit.
|
||||
4. If the helpers ever need to diverge (e.g., pass_funcs using km/min
|
||||
velocity while coord_funcs uses km/s), they can do so without
|
||||
affecting each other.
|
||||
|
||||
|
||||
## 5. Pass Prediction Algorithm
|
||||
|
||||
### The Core Problem
|
||||
|
||||
Given a TLE and a ground observer, find all time intervals where the
|
||||
satellite is above the observer's local horizon. SGP4 has no closed-form
|
||||
inverse for the elevation function -- you cannot algebraically solve
|
||||
"at what time does elevation = 0?" You have to evaluate the function
|
||||
at many points and search for zero crossings.
|
||||
|
||||
This is the same fundamental approach used by Skyfield's `find_events()`
|
||||
and Gpredict's pass finder.
|
||||
|
||||
### Algorithm
|
||||
|
||||
1. **Coarse scan**: Step through the search window at 30-second intervals,
|
||||
evaluating elevation at each step. A 30-second step is fine enough
|
||||
for LEO (~90 min period, ~10 min pass duration) that no pass shorter
|
||||
than about 1 minute gets skipped, and coarse enough that a 7-day
|
||||
window requires ~20,000 propagation calls (fast on modern hardware).
|
||||
|
||||
2. **AOS detection**: When elevation transitions from negative to
|
||||
positive between consecutive coarse steps, a rising edge has been
|
||||
found. Bisect the interval to locate AOS to within 0.1 second
|
||||
tolerance (BISECT_TOL_JD = 0.1/86400).
|
||||
|
||||
3. **LOS detection**: Continue the coarse scan from AOS until elevation
|
||||
goes negative again. Bisect to refine LOS to the same 0.1 second
|
||||
tolerance.
|
||||
|
||||
4. **Peak elevation**: Between AOS and LOS, the elevation function is
|
||||
unimodal (one peak). Use ternary search (50 iterations) to locate
|
||||
the maximum. Ternary search is chosen over golden section because
|
||||
the convergence rate is adequate and the implementation is simpler.
|
||||
|
||||
5. **Degenerate pass filter**: Passes shorter than 10 seconds
|
||||
(MIN_PASS_DURATION_JD) are discarded. These are typically numerical
|
||||
noise from orbits just barely grazing the horizon.
|
||||
|
||||
6. **Minimum elevation filter**: Passes whose peak elevation falls below
|
||||
the caller-specified threshold are silently skipped. The scan resumes
|
||||
from their LOS.
|
||||
|
||||
7. **Post-LOS gap**: After finding a complete pass, the scan resumes
|
||||
1 minute past LOS (POST_LOS_GAP_JD) to avoid re-detecting the
|
||||
same descending arc.
|
||||
|
||||
### Error Handling in the Scan
|
||||
|
||||
If SGP4 returns a hard error (negative semi-major axis, nearly parabolic,
|
||||
convergence failure) during the elevation scan, `elevation_at_jd()`
|
||||
returns -pi radians. This is well below any real horizon elevation, so
|
||||
the scan treats the point as "below horizon" and continues. The pass
|
||||
finder does NOT abort the query on propagation errors during scanning --
|
||||
a TLE might be valid for part of the search window and decayed for
|
||||
the rest.
|
||||
|
||||
### SRF (Set-Returning Function) Pattern
|
||||
|
||||
`predict_passes()` uses PostgreSQL's ValuePerCall SRF protocol:
|
||||
|
||||
- First call: allocate a `predict_passes_ctx` struct in
|
||||
`funcctx->multi_call_memory_ctx`, copy the TLE and observer into it,
|
||||
initialize the scan position.
|
||||
- Each subsequent call: call `find_next_pass()` starting from the
|
||||
current scan position. If a pass is found, advance `current_jd`
|
||||
past LOS and return the pass. If not, return DONE.
|
||||
|
||||
The TLE and observer are copied into the multi_call context because
|
||||
the original function arguments may be freed between calls.
|
||||
|
||||
|
||||
## 6. GiST Index Architecture
|
||||
|
||||
### The Indexing Problem
|
||||
|
||||
The natural query pattern for conjunction screening is: "which TLEs
|
||||
have orbits that could intersect with this TLE?" Full conjunction
|
||||
analysis requires propagating both objects and computing distance at
|
||||
every timestep -- far too expensive for an index scan.
|
||||
|
||||
### Altitude-Band Approximation
|
||||
|
||||
Every orbit has a perigee altitude and an apogee altitude. Two orbits
|
||||
can only come close to each other if their altitude ranges overlap.
|
||||
This is a **necessary but not sufficient** condition for conjunction.
|
||||
|
||||
The GiST index stores [perigee_km, apogee_km] as its internal key
|
||||
(`tle_alt_range` struct: two doubles, 16 bytes). This is derived from
|
||||
the mean elements via Kepler's third law:
|
||||
|
||||
```
|
||||
a = (KE / n)^(2/3) [earth radii, WGS-72]
|
||||
perigee_km = a * (1 - e) * AE - AE
|
||||
apogee_km = a * (1 + e) * AE - AE
|
||||
```
|
||||
|
||||
The altitude band uses WGS-72 constants because n and e are WGS-72
|
||||
mean elements.
|
||||
|
||||
### GiST Support Functions
|
||||
|
||||
| Function | Strategy | Description |
|
||||
|----------|----------|-------------|
|
||||
| compress | -- | Leaf: extract [perigee, apogee] from pg_tle. Internal: pass through (already a range). |
|
||||
| decompress | -- | Identity. We operate on compressed keys directly. |
|
||||
| consistent | `&&` (3), `@>` (7), `<@` (8) | Does the subtree's bounding range overlap/contain the query range? |
|
||||
| union | -- | [min(low), max(high)] over all entries in a node. |
|
||||
| penalty | -- | Increase in altitude span (km) from expanding the node's range to include the new entry. |
|
||||
| picksplit | -- | Sort entries by altitude midpoint, split at the median. |
|
||||
| same | -- | Endpoint comparison within 1e-9 km tolerance. |
|
||||
| distance | `<->` (15) | Minimum gap between altitude bands (0 if overlapping). Drives KNN queries. |
|
||||
|
||||
### Always Recheck
|
||||
|
||||
`gist_tle_consistent()` always sets `*recheck = true`. Altitude band
|
||||
overlap eliminates the vast majority of non-candidates (a LEO satellite
|
||||
cannot conjunct with a GEO satellite), but it cannot confirm conjunction.
|
||||
Two objects at the same altitude but on opposite sides of the earth are
|
||||
not close. After the index scan filters candidates, the query must
|
||||
propagate both TLEs to the reference time and compute actual distance.
|
||||
|
||||
### Picksplit Strategy
|
||||
|
||||
The picksplit function sorts entries by the midpoint of their altitude
|
||||
band ((perigee + apogee) / 2) and splits at the median. This is optimal
|
||||
for a 1-D range index because it minimizes overlap between the two
|
||||
resulting subtrees. Orbits at similar altitudes end up in the same
|
||||
subtree, which matches the access pattern of conjunction screening
|
||||
(you are usually querying within a narrow altitude band).
|
||||
|
||||
|
||||
## 7. Memory Management
|
||||
|
||||
### Allocation Strategy
|
||||
|
||||
All heap allocation goes through `palloc()` / `pfree()`. No `malloc()`,
|
||||
no `new`, no static buffers.
|
||||
|
||||
- **Single-shot propagation** (`sgp4_propagate`, `tle_distance`):
|
||||
`palloc(sizeof(double) * N_SAT_PARAMS)` for the params array,
|
||||
propagate, `pfree(params)`. The params array lives in the current
|
||||
memory context and is freed before the function returns.
|
||||
|
||||
- **Set-returning functions** (`sgp4_propagate_series`, `ground_track`,
|
||||
`predict_passes`): The SRF context struct (containing `tle_t`,
|
||||
`params[N_SAT_PARAMS]`, and scan state) is allocated in
|
||||
`funcctx->multi_call_memory_ctx`. This context survives across calls
|
||||
and is freed automatically when the SRF completes.
|
||||
|
||||
- **Type I/O functions** (`tle_in`, `eci_in`, etc.): The result struct
|
||||
is allocated with `palloc()` in the current context. PostgreSQL
|
||||
manages its lifecycle.
|
||||
|
||||
### No Global Mutable State
|
||||
|
||||
There are no file-scope variables, no static locals that accumulate
|
||||
state, no caches. Every function computes from its arguments alone.
|
||||
This is required for `PARALLEL SAFE` (all pg_orbit functions are
|
||||
declared PARALLEL SAFE) and avoids cross-session contamination in
|
||||
a multi-backend PostgreSQL server.
|
||||
|
||||
sat_code itself has no global mutable state. The propagator state is
|
||||
entirely in the `params[]` array and the `tle_t` struct, both of which
|
||||
are caller-provided.
|
||||
|
||||
### SRF Context Layout
|
||||
|
||||
For `sgp4_propagate_series` and `ground_track`, the params array is
|
||||
embedded directly in the context struct (not a separate allocation):
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
tle_t sat;
|
||||
double params[N_SAT_PARAMS]; /* ~92 doubles, embedded */
|
||||
int is_deep;
|
||||
double epoch_jd;
|
||||
int64 start_ts;
|
||||
int64 step_usec;
|
||||
} propagate_series_ctx;
|
||||
```
|
||||
|
||||
This puts everything in a single allocation, reducing palloc overhead
|
||||
and keeping the data contiguous in memory for cache locality during
|
||||
the per-row propagation loop.
|
||||
|
||||
|
||||
## 8. Error Handling
|
||||
|
||||
### Error Classification
|
||||
|
||||
sat_code returns integer error codes from SGP4() and SDP4():
|
||||
|
||||
| Code | Constant | Severity | Meaning | pg_orbit Response |
|
||||
|------|----------|----------|---------|-------------------|
|
||||
| 0 | -- | OK | Normal | Return result |
|
||||
| -1 | SXPX_ERR_NEARLY_PARABOLIC | Fatal | eccentricity >= 1 | `ereport(ERROR)` |
|
||||
| -2 | SXPX_ERR_NEGATIVE_MAJOR_AXIS | Fatal | Decayed orbit | `ereport(ERROR)` |
|
||||
| -3 | SXPX_WARN_ORBIT_WITHIN_EARTH | Warning | Entire orbit below surface | `ereport(NOTICE)`, return result |
|
||||
| -4 | SXPX_WARN_PERIGEE_WITHIN_EARTH | Warning | Perigee below surface | `ereport(NOTICE)`, return result |
|
||||
| -5 | SXPX_ERR_NEGATIVE_XN | Fatal | Negative mean motion | `ereport(ERROR)` |
|
||||
| -6 | SXPX_ERR_CONVERGENCE_FAIL | Fatal | Kepler equation diverged | `ereport(ERROR)` |
|
||||
|
||||
The distinction between warnings (-3, -4) and errors (-1, -2, -5, -6) is
|
||||
physical. A satellite with perigee within the earth is plausible during
|
||||
reentry or shortly after launch -- the state vector is still mathematically
|
||||
valid. A negative semi-major axis means the orbit has decayed past the
|
||||
point where the model produces meaningful output.
|
||||
|
||||
### Error Handling in Different Contexts
|
||||
|
||||
**Direct propagation** (`sgp4_propagate`, `tle_distance`): Fatal errors
|
||||
abort the query via `ereport(ERROR)`. Warnings emit a `NOTICE` and
|
||||
return the result.
|
||||
|
||||
**Pass prediction** (`elevation_at_jd` in `pass_funcs.c`): Fatal errors
|
||||
return -pi elevation instead of aborting. The scan treats this as "below
|
||||
horizon" and continues. A TLE might be valid for the first 3 days of a
|
||||
7-day search window and then decay -- the pass finder should return the
|
||||
valid passes, not abort.
|
||||
|
||||
**SRF propagation** (`sgp4_propagate_series`): Fatal errors abort the
|
||||
entire series. There is no meaningful way to "skip" a bad timestep in a
|
||||
continuous time series.
|
||||
|
||||
### Input Validation
|
||||
|
||||
TLE parsing errors from `parse_elements()` are caught in `tle_in()` and
|
||||
reported as `ERRCODE_INVALID_TEXT_REPRESENTATION`. Invalid TLEs never
|
||||
make it into storage.
|
||||
|
||||
`select_ephemeris()` returning -1 (invalid mean motion or eccentricity
|
||||
range) is caught at propagation time, not at storage time. A TLE with
|
||||
marginal elements might parse correctly but fail when you try to
|
||||
initialize the propagator.
|
||||
|
||||
|
||||
## 9. Theory-to-Code Mapping
|
||||
|
||||
This table maps key equations from the SGP4 theory papers to their
|
||||
implementation in pg_orbit and sat_code.
|
||||
|
||||
| Theory | Paper | What | Code Location |
|
||||
|--------|-------|------|---------------|
|
||||
| Mean element recovery | Brouwer (1959) | Recover original mean motion (xnodp) and semi-major axis (aodp) from input TLE elements, removing secular J2 perturbations | `sat_code/common.cpp:sxpall_common_init()` lines 17-35 |
|
||||
| Secular perturbations | Lane & Cranford (1969), Hoots & Roehrich STR#3 | Secular rates of mean anomaly, argument of perigee, and RAAN due to J2, J4 | `sat_code/common.cpp:sxpx_common_init()` lines 86-101 |
|
||||
| Atmospheric drag | Hoots & Roehrich STR#3 | B* formulation of drag, C1/C2/C4 coefficients, perigee-dependent s parameter | `sat_code/common.cpp:sxpx_common_init()` lines 47-84; `sat_code/sgp4.cpp:SGP4_init()` |
|
||||
| Short-period perturbations | Lane & Cranford (1969), Brouwer (1959) | Oscillatory corrections to radius, argument of latitude, node, and inclination | `sat_code/common.cpp:sxpx_posn_vel()` lines 121-229 |
|
||||
| Kepler equation | Classical | Newton-Raphson with second-order correction, bounded first step | `sat_code/common.cpp:sxpx_posn_vel()` lines 175-208 |
|
||||
| Deep-space resonance | Hujsak (1979) | Lunar and solar gravitational perturbations, geopotential resonance for 12-hour and 24-hour orbits | `sat_code/deep.cpp:Deep_dpinit()`, `Deep_dpsec()`, `Deep_dpper()` |
|
||||
| Near-earth propagation | Hoots & Roehrich STR#3 | SGP4 main loop: secular + short-period + drag terms | `sat_code/sgp4.cpp:SGP4()` |
|
||||
| Deep-space propagation | Hoots & Roehrich STR#3 | SDP4: SGP4 core + deep-space secular/periodic corrections | `sat_code/sdp4.cpp:SDP4()` |
|
||||
| Semi-major axis from n | Kepler's third law | a = (KE / n)^(2/3) in earth radii | `src/tle_type.c:tle_perigee()` line 415; `src/gist_tle.c:tle_to_alt_range()` line 76 |
|
||||
| GMST | Vallado (2013) Eq. 3-47 | Greenwich Mean Sidereal Time from Julian date | `src/coord_funcs.c:gmst_from_jd()` lines 59-73; `src/pass_funcs.c:gmst_from_jd()` lines 129-151 |
|
||||
| TEME to ECEF | Vallado (2013) | Z-axis rotation by -GMST, velocity cross-product correction | `src/coord_funcs.c:teme_to_ecef()` lines 83-103; `src/pass_funcs.c:teme_to_ecef()` lines 157-179 |
|
||||
| Geodetic from ECEF | Bowring (1976) | Iterative latitude from ECEF Cartesian coordinates on WGS-84 | `src/coord_funcs.c:ecef_to_geodetic()` lines 109-138 |
|
||||
| Topocentric transform | Standard SEZ | ECEF range vector rotated to South-East-Zenith, azimuth from north | `src/coord_funcs.c:ecef_to_topocentric()` lines 163-188 |
|
||||
| Observer to ECEF | Geodesy standard | WGS-84 ellipsoid surface point to Cartesian | `src/coord_funcs.c:observer_to_ecef()` lines 143-156 |
|
||||
| Range rate | Dot product | Projection of relative velocity onto line-of-sight unit vector | `src/coord_funcs.c:eci_to_topocentric()` line 618 |
|
||||
| Near/deep selection | Hoots & Roehrich STR#3 | Period threshold: 225 minutes (n < 2*pi/225 rad/min) | `sat_code/norad.h:select_ephemeris()` |
|
||||
1
lib/sat_code
Submodule
1
lib/sat_code
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit ff7b98957dfa2979700a482bde9de9542807293e
|
||||
4
pg_orbit.control
Normal file
4
pg_orbit.control
Normal file
@ -0,0 +1,4 @@
|
||||
comment = 'Orbital mechanics types and functions for PostgreSQL'
|
||||
default_version = '0.1.0'
|
||||
module_pathname = '$libdir/pg_orbit'
|
||||
relocatable = true
|
||||
491
sql/pg_orbit--0.1.0.sql
Normal file
491
sql/pg_orbit--0.1.0.sql
Normal file
@ -0,0 +1,491 @@
|
||||
-- pg_orbit -- Orbital mechanics types and functions for PostgreSQL
|
||||
--
|
||||
-- Types: tle, eci_position, geodetic, topocentric, observer, pass_event
|
||||
-- Provides SGP4/SDP4 propagation, coordinate transforms, pass prediction,
|
||||
-- and GiST indexing on altitude bands for conjunction screening.
|
||||
--
|
||||
-- All propagation uses WGS-72 constants (matching TLE mean element fitting).
|
||||
-- Coordinate output uses WGS-84 (matching modern geodetic standards).
|
||||
|
||||
-- ============================================================
|
||||
-- Shell types (forward declarations)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TYPE tle;
|
||||
CREATE TYPE eci_position;
|
||||
CREATE TYPE geodetic;
|
||||
CREATE TYPE topocentric;
|
||||
CREATE TYPE observer;
|
||||
CREATE TYPE pass_event;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- TLE type: Two-Line Element set
|
||||
-- ============================================================
|
||||
|
||||
CREATE FUNCTION tle_in(cstring) RETURNS tle
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION tle_out(tle) RETURNS cstring
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION tle_recv(internal) RETURNS tle
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION tle_send(tle) RETURNS bytea
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE TYPE tle (
|
||||
INPUT = tle_in,
|
||||
OUTPUT = tle_out,
|
||||
RECEIVE = tle_recv,
|
||||
SEND = tle_send,
|
||||
INTERNALLENGTH = 112,
|
||||
ALIGNMENT = double,
|
||||
STORAGE = plain
|
||||
);
|
||||
|
||||
COMMENT ON TYPE tle IS 'Two-Line Element set — parsed mean orbital elements for SGP4/SDP4 propagation';
|
||||
|
||||
-- TLE accessor functions
|
||||
|
||||
CREATE FUNCTION tle_epoch(tle) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_epoch(tle) IS 'TLE epoch as Julian date (UTC)';
|
||||
|
||||
CREATE FUNCTION tle_norad_id(tle) RETURNS int4
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_norad_id(tle) IS 'NORAD catalog number';
|
||||
|
||||
CREATE FUNCTION tle_inclination(tle) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_inclination(tle) IS 'Orbital inclination in degrees';
|
||||
|
||||
CREATE FUNCTION tle_eccentricity(tle) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_eccentricity(tle) IS 'Orbital eccentricity (dimensionless)';
|
||||
|
||||
CREATE FUNCTION tle_raan(tle) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_raan(tle) IS 'Right ascension of ascending node in degrees';
|
||||
|
||||
CREATE FUNCTION tle_arg_perigee(tle) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_arg_perigee(tle) IS 'Argument of perigee in degrees';
|
||||
|
||||
CREATE FUNCTION tle_mean_anomaly(tle) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_mean_anomaly(tle) IS 'Mean anomaly in degrees';
|
||||
|
||||
CREATE FUNCTION tle_mean_motion(tle) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_mean_motion(tle) IS 'Mean motion in revolutions per day';
|
||||
|
||||
CREATE FUNCTION tle_bstar(tle) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_bstar(tle) IS 'B* drag coefficient (1/earth-radii)';
|
||||
|
||||
CREATE FUNCTION tle_period(tle) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_period(tle) IS 'Orbital period in minutes';
|
||||
|
||||
CREATE FUNCTION tle_age(tle, timestamptz) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_age(tle, timestamptz) IS 'TLE age in days (positive = past epoch, negative = before epoch)';
|
||||
|
||||
CREATE FUNCTION tle_perigee(tle) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_perigee(tle) IS 'Perigee altitude in km above WGS-72 ellipsoid';
|
||||
|
||||
CREATE FUNCTION tle_apogee(tle) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_apogee(tle) IS 'Apogee altitude in km above WGS-72 ellipsoid';
|
||||
|
||||
CREATE FUNCTION tle_intl_desig(tle) RETURNS text
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_intl_desig(tle) IS 'International designator (COSPAR ID)';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- ECI position type: True Equator Mean Equinox (TEME) frame
|
||||
-- ============================================================
|
||||
|
||||
CREATE FUNCTION eci_in(cstring) RETURNS eci_position
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION eci_out(eci_position) RETURNS cstring
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION eci_recv(internal) RETURNS eci_position
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION eci_send(eci_position) RETURNS bytea
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE TYPE eci_position (
|
||||
INPUT = eci_in,
|
||||
OUTPUT = eci_out,
|
||||
RECEIVE = eci_recv,
|
||||
SEND = eci_send,
|
||||
INTERNALLENGTH = 48,
|
||||
ALIGNMENT = double,
|
||||
STORAGE = plain
|
||||
);
|
||||
|
||||
COMMENT ON TYPE eci_position IS 'Earth-Centered Inertial position and velocity in TEME frame (km, km/s)';
|
||||
|
||||
-- ECI accessor functions
|
||||
|
||||
CREATE FUNCTION eci_x(eci_position) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION eci_y(eci_position) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION eci_z(eci_position) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION eci_vx(eci_position) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION eci_vy(eci_position) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION eci_vz(eci_position) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION eci_speed(eci_position) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION eci_speed(eci_position) IS 'Velocity magnitude in km/s';
|
||||
|
||||
CREATE FUNCTION eci_altitude(eci_position) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION eci_altitude(eci_position) IS 'Approximate geocentric altitude in km (radius - WGS72_AE)';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- Geodetic type: WGS-84 latitude/longitude/altitude
|
||||
-- ============================================================
|
||||
|
||||
CREATE FUNCTION geodetic_in(cstring) RETURNS geodetic
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION geodetic_out(geodetic) RETURNS cstring
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION geodetic_recv(internal) RETURNS geodetic
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION geodetic_send(geodetic) RETURNS bytea
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE TYPE geodetic (
|
||||
INPUT = geodetic_in,
|
||||
OUTPUT = geodetic_out,
|
||||
RECEIVE = geodetic_recv,
|
||||
SEND = geodetic_send,
|
||||
INTERNALLENGTH = 24,
|
||||
ALIGNMENT = double,
|
||||
STORAGE = plain
|
||||
);
|
||||
|
||||
COMMENT ON TYPE geodetic IS 'Geodetic coordinates on WGS-84 ellipsoid (lat/lon in degrees, altitude in km)';
|
||||
|
||||
CREATE FUNCTION geodetic_lat(geodetic) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION geodetic_lon(geodetic) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION geodetic_alt(geodetic) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- Topocentric type: observer-relative az/el/range
|
||||
-- ============================================================
|
||||
|
||||
CREATE FUNCTION topocentric_in(cstring) RETURNS topocentric
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION topocentric_out(topocentric) RETURNS cstring
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION topocentric_recv(internal) RETURNS topocentric
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION topocentric_send(topocentric) RETURNS bytea
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE TYPE topocentric (
|
||||
INPUT = topocentric_in,
|
||||
OUTPUT = topocentric_out,
|
||||
RECEIVE = topocentric_recv,
|
||||
SEND = topocentric_send,
|
||||
INTERNALLENGTH = 32,
|
||||
ALIGNMENT = double,
|
||||
STORAGE = plain
|
||||
);
|
||||
|
||||
COMMENT ON TYPE topocentric IS 'Topocentric coordinates relative to observer (azimuth, elevation, range, range rate)';
|
||||
|
||||
CREATE FUNCTION topo_azimuth(topocentric) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION topo_azimuth(topocentric) IS 'Azimuth in degrees (0=N, 90=E, 180=S, 270=W)';
|
||||
|
||||
CREATE FUNCTION topo_elevation(topocentric) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION topo_elevation(topocentric) IS 'Elevation in degrees (0=horizon, 90=zenith)';
|
||||
|
||||
CREATE FUNCTION topo_range(topocentric) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION topo_range(topocentric) IS 'Slant range in km';
|
||||
|
||||
CREATE FUNCTION topo_range_rate(topocentric) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION topo_range_rate(topocentric) IS 'Range rate in km/s (positive = receding)';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- Observer type: ground station location
|
||||
-- ============================================================
|
||||
|
||||
CREATE FUNCTION observer_in(cstring) RETURNS observer
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION observer_out(observer) RETURNS cstring
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION observer_recv(internal) RETURNS observer
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION observer_send(observer) RETURNS bytea
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE TYPE observer (
|
||||
INPUT = observer_in,
|
||||
OUTPUT = observer_out,
|
||||
RECEIVE = observer_recv,
|
||||
SEND = observer_send,
|
||||
INTERNALLENGTH = 24,
|
||||
ALIGNMENT = double,
|
||||
STORAGE = plain
|
||||
);
|
||||
|
||||
COMMENT ON TYPE observer IS 'Ground observer location (accepts: 40.0N 105.3W 1655m or decimal degrees)';
|
||||
|
||||
CREATE FUNCTION observer_lat(observer) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION observer_lat(observer) IS 'Latitude in degrees (positive = North)';
|
||||
|
||||
CREATE FUNCTION observer_lon(observer) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION observer_lon(observer) IS 'Longitude in degrees (positive = East)';
|
||||
|
||||
CREATE FUNCTION observer_alt(observer) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION observer_alt(observer) IS 'Altitude in meters above WGS-84 ellipsoid';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- Pass event type: satellite visibility window
|
||||
-- ============================================================
|
||||
|
||||
CREATE FUNCTION pass_event_in(cstring) RETURNS pass_event
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION pass_event_out(pass_event) RETURNS cstring
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION pass_event_recv(internal) RETURNS pass_event
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION pass_event_send(pass_event) RETURNS bytea
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE TYPE pass_event (
|
||||
INPUT = pass_event_in,
|
||||
OUTPUT = pass_event_out,
|
||||
RECEIVE = pass_event_recv,
|
||||
SEND = pass_event_send,
|
||||
INTERNALLENGTH = 48,
|
||||
ALIGNMENT = double,
|
||||
STORAGE = plain
|
||||
);
|
||||
|
||||
COMMENT ON TYPE pass_event IS 'Satellite pass event (AOS/MAX/LOS times, max elevation, AOS/LOS azimuths)';
|
||||
|
||||
CREATE FUNCTION pass_aos_time(pass_event) RETURNS timestamptz
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION pass_aos_time(pass_event) IS 'Acquisition of signal time';
|
||||
|
||||
CREATE FUNCTION pass_max_el_time(pass_event) RETURNS timestamptz
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION pass_max_el_time(pass_event) IS 'Maximum elevation time';
|
||||
|
||||
CREATE FUNCTION pass_los_time(pass_event) RETURNS timestamptz
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION pass_los_time(pass_event) IS 'Loss of signal time';
|
||||
|
||||
CREATE FUNCTION pass_max_elevation(pass_event) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION pass_max_elevation(pass_event) IS 'Maximum elevation in degrees';
|
||||
|
||||
CREATE FUNCTION pass_aos_azimuth(pass_event) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION pass_aos_azimuth(pass_event) IS 'AOS azimuth in degrees (0=N)';
|
||||
|
||||
CREATE FUNCTION pass_los_azimuth(pass_event) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION pass_los_azimuth(pass_event) IS 'LOS azimuth in degrees (0=N)';
|
||||
|
||||
CREATE FUNCTION pass_duration(pass_event) RETURNS interval
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION pass_duration(pass_event) IS 'Pass duration (LOS - AOS)';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- SGP4/SDP4 propagation functions
|
||||
-- ============================================================
|
||||
|
||||
CREATE FUNCTION sgp4_propagate(tle, timestamptz) RETURNS eci_position
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION sgp4_propagate(tle, timestamptz) IS
|
||||
'Propagate TLE to a point in time using SGP4 (near-earth) or SDP4 (deep-space). Returns TEME ECI position/velocity.';
|
||||
|
||||
CREATE FUNCTION sgp4_propagate_series(tle, timestamptz, timestamptz, interval)
|
||||
RETURNS TABLE(t timestamptz, x float8, y float8, z float8, vx float8, vy float8, vz float8)
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE
|
||||
ROWS 100;
|
||||
COMMENT ON FUNCTION sgp4_propagate_series(tle, timestamptz, timestamptz, interval) IS
|
||||
'Propagate TLE over a time range at regular intervals. Returns time series of TEME positions.';
|
||||
|
||||
CREATE FUNCTION tle_distance(tle, tle, timestamptz) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_distance(tle, tle, timestamptz) IS
|
||||
'Euclidean distance in km between two TLEs at a reference time';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- Coordinate transform functions
|
||||
-- ============================================================
|
||||
|
||||
CREATE FUNCTION eci_to_geodetic(eci_position, timestamptz) RETURNS geodetic
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION eci_to_geodetic(eci_position, timestamptz) IS
|
||||
'Convert TEME ECI position to WGS-84 geodetic coordinates at given time';
|
||||
|
||||
CREATE FUNCTION eci_to_topocentric(eci_position, observer, timestamptz) RETURNS topocentric
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION eci_to_topocentric(eci_position, observer, timestamptz) IS
|
||||
'Convert TEME ECI position to topocentric (az/el/range) relative to observer';
|
||||
|
||||
CREATE FUNCTION subsatellite_point(tle, timestamptz) RETURNS geodetic
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION subsatellite_point(tle, timestamptz) IS
|
||||
'Subsatellite (nadir) point on WGS-84 ellipsoid at given time';
|
||||
|
||||
CREATE FUNCTION ground_track(tle, timestamptz, timestamptz, interval)
|
||||
RETURNS TABLE(t timestamptz, lat float8, lon float8, alt float8)
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE
|
||||
ROWS 100;
|
||||
COMMENT ON FUNCTION ground_track(tle, timestamptz, timestamptz, interval) IS
|
||||
'Ground track as time series of subsatellite points (lat/lon in degrees, alt in km)';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- Pass prediction functions
|
||||
-- ============================================================
|
||||
|
||||
CREATE FUNCTION next_pass(tle, observer, timestamptz) RETURNS pass_event
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION next_pass(tle, observer, timestamptz) IS
|
||||
'Find the next satellite pass over observer (searches up to 7 days ahead)';
|
||||
|
||||
CREATE FUNCTION predict_passes(tle, observer, timestamptz, timestamptz, float8 DEFAULT 0.0)
|
||||
RETURNS SETOF pass_event
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE
|
||||
ROWS 10;
|
||||
COMMENT ON FUNCTION predict_passes(tle, observer, timestamptz, timestamptz, float8) IS
|
||||
'Predict all satellite passes over observer in time window. Optional min_elevation in degrees.';
|
||||
|
||||
CREATE FUNCTION pass_visible(tle, observer, timestamptz, timestamptz) RETURNS boolean
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION pass_visible(tle, observer, timestamptz, timestamptz) IS
|
||||
'True if any pass occurs over observer in the time window';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- GiST operator support functions
|
||||
-- ============================================================
|
||||
|
||||
-- Overlap operator: do altitude bands intersect?
|
||||
CREATE FUNCTION tle_overlap(tle, tle) RETURNS boolean
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
-- Altitude distance operator
|
||||
CREATE FUNCTION tle_alt_distance(tle, tle) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE OPERATOR && (
|
||||
LEFTARG = tle,
|
||||
RIGHTARG = tle,
|
||||
FUNCTION = tle_overlap,
|
||||
COMMUTATOR = &&,
|
||||
RESTRICT = areasel,
|
||||
JOIN = areajoinsel
|
||||
);
|
||||
|
||||
COMMENT ON OPERATOR && (tle, tle) IS 'Altitude band overlap — necessary condition for conjunction';
|
||||
|
||||
CREATE OPERATOR <-> (
|
||||
LEFTARG = tle,
|
||||
RIGHTARG = tle,
|
||||
FUNCTION = tle_alt_distance,
|
||||
COMMUTATOR = <->
|
||||
);
|
||||
|
||||
COMMENT ON OPERATOR <-> (tle, tle) IS 'Minimum altitude-band separation in km (0 if overlapping)';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- GiST operator class for altitude-band indexing
|
||||
-- ============================================================
|
||||
|
||||
-- GiST internal support functions
|
||||
CREATE FUNCTION gist_tle_compress(internal) RETURNS internal
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION gist_tle_decompress(internal) RETURNS internal
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION gist_tle_consistent(internal, tle, smallint, oid, internal) RETURNS boolean
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION gist_tle_union(internal, internal) RETURNS internal
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION gist_tle_penalty(internal, internal, internal) RETURNS internal
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION gist_tle_picksplit(internal, internal) RETURNS internal
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION gist_tle_same(internal, internal, internal) RETURNS internal
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE FUNCTION gist_tle_distance(internal, tle, smallint, oid, internal) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
|
||||
CREATE OPERATOR CLASS tle_ops
|
||||
DEFAULT FOR TYPE tle USING gist AS
|
||||
OPERATOR 3 && ,
|
||||
OPERATOR 15 <-> (tle, tle) FOR ORDER BY float_ops,
|
||||
FUNCTION 1 gist_tle_consistent(internal, tle, smallint, oid, internal),
|
||||
FUNCTION 2 gist_tle_union(internal, internal),
|
||||
FUNCTION 3 gist_tle_compress(internal),
|
||||
FUNCTION 4 gist_tle_decompress(internal),
|
||||
FUNCTION 5 gist_tle_penalty(internal, internal, internal),
|
||||
FUNCTION 6 gist_tle_picksplit(internal, internal),
|
||||
FUNCTION 7 gist_tle_same(internal, internal, internal),
|
||||
FUNCTION 8 gist_tle_distance(internal, tle, smallint, oid, internal);
|
||||
820
src/coord_funcs.c
Normal file
820
src/coord_funcs.c
Normal file
@ -0,0 +1,820 @@
|
||||
/*
|
||||
* coord_funcs.c -- Coordinate transform functions for pg_orbit
|
||||
*
|
||||
* TEME -> WGS-84 geodetic and TEME -> topocentric transforms.
|
||||
*
|
||||
* SGP4 outputs in the TEME (True Equator, Mean Equinox) frame.
|
||||
* Converting to Earth-fixed coordinates requires rotating by GMST.
|
||||
* We use only the 4 IAU-80 nutation terms that SGP4 itself uses --
|
||||
* applying the full 106-term model would "correct" what SGP4
|
||||
* already accounts for.
|
||||
*
|
||||
* The TEME->ITRF rotation is a single Z-axis rotation by -GMST.
|
||||
* After that, standard geodetic conversions on the WGS-84 ellipsoid.
|
||||
*/
|
||||
|
||||
#include "postgres.h"
|
||||
#include "fmgr.h"
|
||||
#include "funcapi.h"
|
||||
#include "utils/timestamp.h"
|
||||
#include "libpq/pqformat.h"
|
||||
#include "norad.h"
|
||||
#include "types.h"
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
#define DEG_TO_RAD (M_PI / 180.0)
|
||||
#define RAD_TO_DEG (180.0 / M_PI)
|
||||
|
||||
PG_FUNCTION_INFO_V1(geodetic_in);
|
||||
PG_FUNCTION_INFO_V1(geodetic_out);
|
||||
PG_FUNCTION_INFO_V1(geodetic_recv);
|
||||
PG_FUNCTION_INFO_V1(geodetic_send);
|
||||
PG_FUNCTION_INFO_V1(geodetic_lat);
|
||||
PG_FUNCTION_INFO_V1(geodetic_lon);
|
||||
PG_FUNCTION_INFO_V1(geodetic_alt);
|
||||
PG_FUNCTION_INFO_V1(topocentric_in);
|
||||
PG_FUNCTION_INFO_V1(topocentric_out);
|
||||
PG_FUNCTION_INFO_V1(topocentric_recv);
|
||||
PG_FUNCTION_INFO_V1(topocentric_send);
|
||||
PG_FUNCTION_INFO_V1(topo_azimuth);
|
||||
PG_FUNCTION_INFO_V1(topo_elevation);
|
||||
PG_FUNCTION_INFO_V1(topo_range);
|
||||
PG_FUNCTION_INFO_V1(topo_range_rate);
|
||||
PG_FUNCTION_INFO_V1(eci_to_geodetic);
|
||||
PG_FUNCTION_INFO_V1(eci_to_topocentric);
|
||||
PG_FUNCTION_INFO_V1(subsatellite_point);
|
||||
PG_FUNCTION_INFO_V1(ground_track);
|
||||
|
||||
/* ================================================================
|
||||
* Internal helpers -- GMST, frame rotation, geodetic conversion
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
/*
|
||||
* Greenwich Mean Sidereal Time from Julian date.
|
||||
* Vallado, "Fundamentals of Astrodynamics", Eq. 3-47.
|
||||
* Returns angle in radians.
|
||||
*/
|
||||
static double
|
||||
gmst_from_jd(double jd)
|
||||
{
|
||||
double t_ut1 = (jd - 2451545.0) / 36525.0;
|
||||
double gmst = 67310.54841
|
||||
+ (876600.0 * 3600.0 + 8640184.812866) * t_ut1
|
||||
+ 0.093104 * t_ut1 * t_ut1
|
||||
- 6.2e-6 * t_ut1 * t_ut1 * t_ut1;
|
||||
|
||||
/* Convert seconds of time to radians, then normalize to [0, 2*pi) */
|
||||
gmst = fmod(gmst * M_PI / 43200.0, 2.0 * M_PI);
|
||||
if (gmst < 0.0)
|
||||
gmst += 2.0 * M_PI;
|
||||
return gmst;
|
||||
}
|
||||
|
||||
/*
|
||||
* Rotate TEME position/velocity to ECEF (Earth-Centered Earth-Fixed).
|
||||
* Single rotation around Z by -GMST angle.
|
||||
* omega_e = Earth rotation rate = 7.2921158553e-5 rad/s
|
||||
*/
|
||||
#define OMEGA_EARTH 7.2921158553e-5 /* rad/s */
|
||||
|
||||
static void
|
||||
teme_to_ecef(const double *pos_teme, const double *vel_teme,
|
||||
double gmst, double *pos_ecef, double *vel_ecef)
|
||||
{
|
||||
double cos_g = cos(gmst);
|
||||
double sin_g = sin(gmst);
|
||||
|
||||
/* Position rotation */
|
||||
pos_ecef[0] = cos_g * pos_teme[0] + sin_g * pos_teme[1];
|
||||
pos_ecef[1] = -sin_g * pos_teme[0] + cos_g * pos_teme[1];
|
||||
pos_ecef[2] = pos_teme[2];
|
||||
|
||||
if (vel_ecef && vel_teme)
|
||||
{
|
||||
/* Velocity includes both rotation and Earth angular velocity cross product */
|
||||
vel_ecef[0] = cos_g * vel_teme[0] + sin_g * vel_teme[1]
|
||||
+ OMEGA_EARTH * pos_ecef[1];
|
||||
vel_ecef[1] = -sin_g * vel_teme[0] + cos_g * vel_teme[1]
|
||||
- OMEGA_EARTH * pos_ecef[0];
|
||||
vel_ecef[2] = vel_teme[2];
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* ECEF (km) to WGS-84 geodetic (lat, lon in radians; alt in km).
|
||||
* Bowring's iterative method -- converges in 2-3 iterations for LEO.
|
||||
*/
|
||||
static void
|
||||
ecef_to_geodetic(const double *pos_ecef,
|
||||
double *lat, double *lon, double *alt_km)
|
||||
{
|
||||
double x = pos_ecef[0];
|
||||
double y = pos_ecef[1];
|
||||
double z = pos_ecef[2];
|
||||
double a = WGS84_A;
|
||||
double e2 = WGS84_E2;
|
||||
double p = sqrt(x * x + y * y);
|
||||
double phi, N, phi_prev;
|
||||
int i;
|
||||
|
||||
*lon = atan2(y, x);
|
||||
|
||||
/* Bowring's iterative method */
|
||||
phi = atan2(z, p * (1.0 - e2)); /* initial estimate */
|
||||
for (i = 0; i < 10; i++)
|
||||
{
|
||||
phi_prev = phi;
|
||||
N = a / sqrt(1.0 - e2 * sin(phi) * sin(phi));
|
||||
phi = atan2(z + e2 * N * sin(phi), p);
|
||||
if (fabs(phi - phi_prev) < 1.0e-12)
|
||||
break;
|
||||
}
|
||||
*lat = phi;
|
||||
|
||||
N = a / sqrt(1.0 - e2 * sin(phi) * sin(phi));
|
||||
*alt_km = p / cos(phi) - N;
|
||||
}
|
||||
|
||||
/*
|
||||
* Observer (WGS-84 lat/lon radians, alt meters) to ECEF vector in km.
|
||||
*/
|
||||
static void
|
||||
observer_to_ecef(const pg_observer *obs, double *pos_ecef)
|
||||
{
|
||||
double a = WGS84_A;
|
||||
double e2 = WGS84_E2;
|
||||
double sin_lat = sin(obs->lat);
|
||||
double cos_lat = cos(obs->lat);
|
||||
double N = a / sqrt(1.0 - e2 * sin_lat * sin_lat);
|
||||
double alt_km = obs->alt_m / 1000.0;
|
||||
|
||||
pos_ecef[0] = (N + alt_km) * cos_lat * cos(obs->lon);
|
||||
pos_ecef[1] = (N + alt_km) * cos_lat * sin(obs->lon);
|
||||
pos_ecef[2] = (N * (1.0 - e2) + alt_km) * sin_lat;
|
||||
}
|
||||
|
||||
/*
|
||||
* ECEF range vector to topocentric (azimuth, elevation, range).
|
||||
* Azimuth: 0=N, 90=E, 180=S, 270=W.
|
||||
* Elevation: 0=horizon, +90=zenith.
|
||||
*/
|
||||
static void
|
||||
ecef_to_topocentric(const double *sat_ecef, const double *obs_ecef,
|
||||
double obs_lat, double obs_lon,
|
||||
double *az, double *el, double *range_km)
|
||||
{
|
||||
/* Range vector in ECEF */
|
||||
double dx = sat_ecef[0] - obs_ecef[0];
|
||||
double dy = sat_ecef[1] - obs_ecef[1];
|
||||
double dz = sat_ecef[2] - obs_ecef[2];
|
||||
|
||||
/* Rotate to topocentric (South, East, Up) */
|
||||
double sin_lat = sin(obs_lat);
|
||||
double cos_lat = cos(obs_lat);
|
||||
double sin_lon = sin(obs_lon);
|
||||
double cos_lon = cos(obs_lon);
|
||||
|
||||
double south = sin_lat * cos_lon * dx + sin_lat * sin_lon * dy - cos_lat * dz;
|
||||
double east = -sin_lon * dx + cos_lon * dy;
|
||||
double up = cos_lat * cos_lon * dx + cos_lat * sin_lon * dy + sin_lat * dz;
|
||||
|
||||
*range_km = sqrt(dx * dx + dy * dy + dz * dz);
|
||||
*el = asin(up / *range_km);
|
||||
*az = atan2(east, -south); /* negative south = north */
|
||||
if (*az < 0.0)
|
||||
*az += 2.0 * M_PI;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* TLE struct conversion + propagation (local copies)
|
||||
*
|
||||
* These replicate helpers from sgp4_funcs.c. All .c files link into
|
||||
* a single .so, so we could call those directly, but keeping them
|
||||
* static avoids symbol coupling between translation units.
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
static void
|
||||
pg_tle_to_sat_code(const pg_tle *src, tle_t *dst)
|
||||
{
|
||||
memset(dst, 0, sizeof(tle_t));
|
||||
|
||||
dst->epoch = src->epoch;
|
||||
dst->xincl = src->inclination;
|
||||
dst->xnodeo = src->raan;
|
||||
dst->eo = src->eccentricity;
|
||||
dst->omegao = src->arg_perigee;
|
||||
dst->xmo = src->mean_anomaly;
|
||||
dst->xno = src->mean_motion;
|
||||
dst->xndt2o = src->mean_motion_dot;
|
||||
dst->xndd6o = src->mean_motion_ddot;
|
||||
dst->bstar = src->bstar;
|
||||
|
||||
dst->norad_number = src->norad_id;
|
||||
dst->bulletin_number = src->elset_num;
|
||||
dst->revolution_number = src->rev_num;
|
||||
dst->classification = src->classification;
|
||||
dst->ephemeris_type = src->ephemeris_type;
|
||||
|
||||
memcpy(dst->intl_desig, src->intl_desig, 9);
|
||||
}
|
||||
|
||||
static int
|
||||
do_propagate(const pg_tle *tle, double jd, double *pos, double *vel)
|
||||
{
|
||||
tle_t sat;
|
||||
double *params;
|
||||
int is_deep;
|
||||
double tsince;
|
||||
int err;
|
||||
|
||||
pg_tle_to_sat_code(tle, &sat);
|
||||
|
||||
is_deep = select_ephemeris(&sat);
|
||||
if (is_deep < 0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_DATA_EXCEPTION),
|
||||
errmsg("invalid TLE for NORAD %d: "
|
||||
"mean motion or eccentricity out of range",
|
||||
tle->norad_id)));
|
||||
|
||||
params = palloc(sizeof(double) * N_SAT_PARAMS);
|
||||
|
||||
if (is_deep)
|
||||
SDP4_init(params, &sat);
|
||||
else
|
||||
SGP4_init(params, &sat);
|
||||
|
||||
tsince = (jd - sat.epoch) * 1440.0;
|
||||
|
||||
if (is_deep)
|
||||
err = SDP4(tsince, &sat, params, pos, vel);
|
||||
else
|
||||
err = SGP4(tsince, &sat, params, pos, vel);
|
||||
|
||||
pfree(params);
|
||||
|
||||
if (err != 0 &&
|
||||
err != SXPX_WARN_ORBIT_WITHIN_EARTH &&
|
||||
err != SXPX_WARN_PERIGEE_WITHIN_EARTH)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_DATA_EXCEPTION),
|
||||
errmsg("SGP4/SDP4 propagation failed for NORAD %d "
|
||||
"at t+%.1f min",
|
||||
tle->norad_id, tsince)));
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Geodetic type I/O
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
/*
|
||||
* geodetic_in -- parse text to pg_geodetic
|
||||
*
|
||||
* Accepts: (lat_deg,lon_deg,alt_km)
|
||||
*/
|
||||
Datum
|
||||
geodetic_in(PG_FUNCTION_ARGS)
|
||||
{
|
||||
char *str = PG_GETARG_CSTRING(0);
|
||||
pg_geodetic *result;
|
||||
double lat_deg, lon_deg, alt_km;
|
||||
int nfields;
|
||||
|
||||
result = (pg_geodetic *) palloc(sizeof(pg_geodetic));
|
||||
|
||||
nfields = sscanf(str, " ( %lf , %lf , %lf )",
|
||||
&lat_deg, &lon_deg, &alt_km);
|
||||
|
||||
if (nfields != 3)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
|
||||
errmsg("invalid input syntax for type geodetic: \"%s\"", str),
|
||||
errhint("Expected (lat_deg,lon_deg,alt_km).")));
|
||||
|
||||
if (lat_deg < -90.0 || lat_deg > 90.0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
|
||||
errmsg("latitude out of range: %.6f", lat_deg),
|
||||
errhint("Latitude must be between -90 and +90 degrees.")));
|
||||
|
||||
if (lon_deg < -180.0 || lon_deg > 360.0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
|
||||
errmsg("longitude out of range: %.6f", lon_deg),
|
||||
errhint("Longitude must be between -180 and +360 degrees.")));
|
||||
|
||||
result->lat = lat_deg * DEG_TO_RAD;
|
||||
result->lon = lon_deg * DEG_TO_RAD;
|
||||
result->alt = alt_km;
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
/*
|
||||
* geodetic_out -- pg_geodetic to text
|
||||
*/
|
||||
Datum
|
||||
geodetic_out(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_geodetic *geo = (pg_geodetic *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_CSTRING(psprintf("(%.6f,%.6f,%.3f)",
|
||||
geo->lat * RAD_TO_DEG,
|
||||
geo->lon * RAD_TO_DEG,
|
||||
geo->alt));
|
||||
}
|
||||
|
||||
/*
|
||||
* geodetic_recv -- binary input
|
||||
*/
|
||||
Datum
|
||||
geodetic_recv(PG_FUNCTION_ARGS)
|
||||
{
|
||||
StringInfo buf = (StringInfo) PG_GETARG_POINTER(0);
|
||||
pg_geodetic *result;
|
||||
|
||||
result = (pg_geodetic *) palloc(sizeof(pg_geodetic));
|
||||
result->lat = pq_getmsgfloat8(buf);
|
||||
result->lon = pq_getmsgfloat8(buf);
|
||||
result->alt = pq_getmsgfloat8(buf);
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
/*
|
||||
* geodetic_send -- binary output
|
||||
*/
|
||||
Datum
|
||||
geodetic_send(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_geodetic *geo = (pg_geodetic *) PG_GETARG_POINTER(0);
|
||||
StringInfoData buf;
|
||||
|
||||
pq_begintypsend(&buf);
|
||||
pq_sendfloat8(&buf, geo->lat);
|
||||
pq_sendfloat8(&buf, geo->lon);
|
||||
pq_sendfloat8(&buf, geo->alt);
|
||||
|
||||
PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
|
||||
}
|
||||
|
||||
/* --- geodetic accessor functions --- */
|
||||
|
||||
Datum
|
||||
geodetic_lat(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_geodetic *geo = (pg_geodetic *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(geo->lat * RAD_TO_DEG);
|
||||
}
|
||||
|
||||
Datum
|
||||
geodetic_lon(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_geodetic *geo = (pg_geodetic *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(geo->lon * RAD_TO_DEG);
|
||||
}
|
||||
|
||||
Datum
|
||||
geodetic_alt(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_geodetic *geo = (pg_geodetic *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(geo->alt);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Topocentric type I/O
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
/*
|
||||
* topocentric_in -- parse text to pg_topocentric
|
||||
*
|
||||
* Accepts: (az_deg,el_deg,range_km,range_rate_kms)
|
||||
*/
|
||||
Datum
|
||||
topocentric_in(PG_FUNCTION_ARGS)
|
||||
{
|
||||
char *str = PG_GETARG_CSTRING(0);
|
||||
pg_topocentric *result;
|
||||
double az_deg, el_deg, range_km, range_rate;
|
||||
int nfields;
|
||||
|
||||
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
|
||||
|
||||
nfields = sscanf(str, " ( %lf , %lf , %lf , %lf )",
|
||||
&az_deg, &el_deg, &range_km, &range_rate);
|
||||
|
||||
if (nfields != 4)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
|
||||
errmsg("invalid input syntax for type topocentric: \"%s\"", str),
|
||||
errhint("Expected (az_deg,el_deg,range_km,range_rate_kms).")));
|
||||
|
||||
if (az_deg < 0.0 || az_deg > 360.0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
|
||||
errmsg("azimuth out of range: %.6f", az_deg),
|
||||
errhint("Azimuth must be between 0 and 360 degrees.")));
|
||||
|
||||
if (el_deg < -90.0 || el_deg > 90.0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
|
||||
errmsg("elevation out of range: %.6f", el_deg),
|
||||
errhint("Elevation must be between -90 and +90 degrees.")));
|
||||
|
||||
result->azimuth = az_deg * DEG_TO_RAD;
|
||||
result->elevation = el_deg * DEG_TO_RAD;
|
||||
result->range_km = range_km;
|
||||
result->range_rate = range_rate;
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
/*
|
||||
* topocentric_out -- pg_topocentric to text
|
||||
*/
|
||||
Datum
|
||||
topocentric_out(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_topocentric *topo = (pg_topocentric *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_CSTRING(psprintf("(%.4f,%.4f,%.3f,%.6f)",
|
||||
topo->azimuth * RAD_TO_DEG,
|
||||
topo->elevation * RAD_TO_DEG,
|
||||
topo->range_km,
|
||||
topo->range_rate));
|
||||
}
|
||||
|
||||
/*
|
||||
* topocentric_recv -- binary input
|
||||
*/
|
||||
Datum
|
||||
topocentric_recv(PG_FUNCTION_ARGS)
|
||||
{
|
||||
StringInfo buf = (StringInfo) PG_GETARG_POINTER(0);
|
||||
pg_topocentric *result;
|
||||
|
||||
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
|
||||
result->azimuth = pq_getmsgfloat8(buf);
|
||||
result->elevation = pq_getmsgfloat8(buf);
|
||||
result->range_km = pq_getmsgfloat8(buf);
|
||||
result->range_rate = pq_getmsgfloat8(buf);
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
/*
|
||||
* topocentric_send -- binary output
|
||||
*/
|
||||
Datum
|
||||
topocentric_send(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_topocentric *topo = (pg_topocentric *) PG_GETARG_POINTER(0);
|
||||
StringInfoData buf;
|
||||
|
||||
pq_begintypsend(&buf);
|
||||
pq_sendfloat8(&buf, topo->azimuth);
|
||||
pq_sendfloat8(&buf, topo->elevation);
|
||||
pq_sendfloat8(&buf, topo->range_km);
|
||||
pq_sendfloat8(&buf, topo->range_rate);
|
||||
|
||||
PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
|
||||
}
|
||||
|
||||
/* --- topocentric accessor functions --- */
|
||||
|
||||
Datum
|
||||
topo_azimuth(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_topocentric *topo = (pg_topocentric *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(topo->azimuth * RAD_TO_DEG);
|
||||
}
|
||||
|
||||
Datum
|
||||
topo_elevation(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_topocentric *topo = (pg_topocentric *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(topo->elevation * RAD_TO_DEG);
|
||||
}
|
||||
|
||||
Datum
|
||||
topo_range(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_topocentric *topo = (pg_topocentric *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(topo->range_km);
|
||||
}
|
||||
|
||||
Datum
|
||||
topo_range_rate(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_topocentric *topo = (pg_topocentric *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(topo->range_rate);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Coordinate transform functions
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
/*
|
||||
* eci_to_geodetic(eci_position, timestamptz) -> geodetic
|
||||
*
|
||||
* Convert TEME state vector to WGS-84 geodetic coordinates.
|
||||
* The timestamptz provides the time for GMST calculation.
|
||||
*/
|
||||
Datum
|
||||
eci_to_geodetic(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
|
||||
int64 ts = PG_GETARG_INT64(1);
|
||||
|
||||
double jd;
|
||||
double gmst;
|
||||
double pos_teme[3];
|
||||
double pos_ecef[3];
|
||||
double lat, lon, alt_km;
|
||||
pg_geodetic *result;
|
||||
|
||||
jd = timestamptz_to_jd(ts);
|
||||
gmst = gmst_from_jd(jd);
|
||||
|
||||
pos_teme[0] = eci->x;
|
||||
pos_teme[1] = eci->y;
|
||||
pos_teme[2] = eci->z;
|
||||
|
||||
teme_to_ecef(pos_teme, NULL, gmst, pos_ecef, NULL);
|
||||
ecef_to_geodetic(pos_ecef, &lat, &lon, &alt_km);
|
||||
|
||||
result = (pg_geodetic *) palloc(sizeof(pg_geodetic));
|
||||
result->lat = lat;
|
||||
result->lon = lon;
|
||||
result->alt = alt_km;
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
/*
|
||||
* eci_to_topocentric(eci_position, observer, timestamptz) -> topocentric
|
||||
*
|
||||
* Convert TEME state vector to observer-relative look angles.
|
||||
* Range rate is computed by projecting the ECEF-relative velocity
|
||||
* onto the line-of-sight unit vector.
|
||||
*/
|
||||
Datum
|
||||
eci_to_topocentric(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
|
||||
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(1);
|
||||
int64 ts = PG_GETARG_INT64(2);
|
||||
|
||||
double jd;
|
||||
double gmst;
|
||||
double pos_teme[3], vel_teme[3];
|
||||
double pos_ecef[3], vel_ecef[3];
|
||||
double obs_ecef[3];
|
||||
double az, el, range_km;
|
||||
double range_rate;
|
||||
double dx, dy, dz;
|
||||
double dvx, dvy, dvz;
|
||||
pg_topocentric *result;
|
||||
|
||||
jd = timestamptz_to_jd(ts);
|
||||
gmst = gmst_from_jd(jd);
|
||||
|
||||
pos_teme[0] = eci->x;
|
||||
pos_teme[1] = eci->y;
|
||||
pos_teme[2] = eci->z;
|
||||
|
||||
/* ECI velocity is in km/s; teme_to_ecef expects km/s */
|
||||
vel_teme[0] = eci->vx;
|
||||
vel_teme[1] = eci->vy;
|
||||
vel_teme[2] = eci->vz;
|
||||
|
||||
teme_to_ecef(pos_teme, vel_teme, gmst, pos_ecef, vel_ecef);
|
||||
observer_to_ecef(obs, obs_ecef);
|
||||
|
||||
ecef_to_topocentric(pos_ecef, obs_ecef, obs->lat, obs->lon,
|
||||
&az, &el, &range_km);
|
||||
|
||||
/*
|
||||
* Range rate: project relative velocity onto line-of-sight.
|
||||
* Positive = satellite receding from observer.
|
||||
* Observer is stationary in ECEF, so relative velocity = sat ECEF velocity.
|
||||
*/
|
||||
dx = pos_ecef[0] - obs_ecef[0];
|
||||
dy = pos_ecef[1] - obs_ecef[1];
|
||||
dz = pos_ecef[2] - obs_ecef[2];
|
||||
|
||||
dvx = vel_ecef[0];
|
||||
dvy = vel_ecef[1];
|
||||
dvz = vel_ecef[2];
|
||||
|
||||
range_rate = (dx * dvx + dy * dvy + dz * dvz) / range_km;
|
||||
|
||||
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
|
||||
result->azimuth = az;
|
||||
result->elevation = el;
|
||||
result->range_km = range_km;
|
||||
result->range_rate = range_rate;
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
/*
|
||||
* subsatellite_point(tle, timestamptz) -> geodetic
|
||||
*
|
||||
* Propagate TLE to the given time and return the nadir point
|
||||
* on the WGS-84 ellipsoid. The altitude field contains the
|
||||
* satellite's altitude above the ellipsoid, not zero.
|
||||
*/
|
||||
Datum
|
||||
subsatellite_point(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
int64 ts = PG_GETARG_INT64(1);
|
||||
|
||||
double jd;
|
||||
double gmst;
|
||||
double pos[3], vel[3];
|
||||
double pos_ecef[3];
|
||||
double lat, lon, alt_km;
|
||||
pg_geodetic *result;
|
||||
|
||||
jd = timestamptz_to_jd(ts);
|
||||
|
||||
do_propagate(tle, jd, pos, vel);
|
||||
|
||||
gmst = gmst_from_jd(jd);
|
||||
teme_to_ecef(pos, NULL, gmst, pos_ecef, NULL);
|
||||
ecef_to_geodetic(pos_ecef, &lat, &lon, &alt_km);
|
||||
|
||||
result = (pg_geodetic *) palloc(sizeof(pg_geodetic));
|
||||
result->lat = lat;
|
||||
result->lon = lon;
|
||||
result->alt = alt_km;
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* ground_track -- SRF returning subsatellite points over time
|
||||
*
|
||||
* ground_track(tle, start_ts, stop_ts, step_interval)
|
||||
* -> SETOF (t timestamptz, point geodetic)
|
||||
*
|
||||
* The model is initialized once and reused for every step,
|
||||
* same pattern as sgp4_propagate_series in sgp4_funcs.c.
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
typedef struct
|
||||
{
|
||||
tle_t sat;
|
||||
double params[N_SAT_PARAMS];
|
||||
int is_deep;
|
||||
double epoch_jd;
|
||||
int64 start_ts;
|
||||
int64 step_usec;
|
||||
} ground_track_ctx;
|
||||
|
||||
Datum
|
||||
ground_track(PG_FUNCTION_ARGS)
|
||||
{
|
||||
FuncCallContext *funcctx;
|
||||
ground_track_ctx *ctx;
|
||||
|
||||
if (SRF_IS_FIRSTCALL())
|
||||
{
|
||||
MemoryContext oldctx;
|
||||
pg_tle *tle;
|
||||
int64 start_ts;
|
||||
int64 stop_ts;
|
||||
Interval *step;
|
||||
int64 step_usec;
|
||||
int64 span;
|
||||
uint64 nsteps;
|
||||
TupleDesc tupdesc;
|
||||
|
||||
funcctx = SRF_FIRSTCALL_INIT();
|
||||
oldctx = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
|
||||
|
||||
tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
start_ts = PG_GETARG_INT64(1);
|
||||
stop_ts = PG_GETARG_INT64(2);
|
||||
step = PG_GETARG_INTERVAL_P(3);
|
||||
|
||||
/* Convert interval to microseconds */
|
||||
step_usec = step->time
|
||||
+ (int64) step->day * USECS_PER_DAY
|
||||
+ (int64) step->month * (30 * USECS_PER_DAY);
|
||||
|
||||
if (step_usec <= 0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
|
||||
errmsg("step interval must be positive")));
|
||||
|
||||
if (stop_ts < start_ts)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
|
||||
errmsg("stop time must be >= start time")));
|
||||
|
||||
span = stop_ts - start_ts;
|
||||
nsteps = (uint64)(span / step_usec) + 1;
|
||||
|
||||
ctx = (ground_track_ctx *)
|
||||
palloc0(sizeof(ground_track_ctx));
|
||||
|
||||
pg_tle_to_sat_code(tle, &ctx->sat);
|
||||
|
||||
ctx->is_deep = select_ephemeris(&ctx->sat);
|
||||
if (ctx->is_deep < 0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_DATA_EXCEPTION),
|
||||
errmsg("invalid TLE for NORAD %d: "
|
||||
"mean motion or eccentricity out of range",
|
||||
ctx->sat.norad_number)));
|
||||
|
||||
/* Initialize the model once */
|
||||
if (ctx->is_deep)
|
||||
SDP4_init(ctx->params, &ctx->sat);
|
||||
else
|
||||
SGP4_init(ctx->params, &ctx->sat);
|
||||
|
||||
ctx->epoch_jd = ctx->sat.epoch;
|
||||
ctx->start_ts = start_ts;
|
||||
ctx->step_usec = step_usec;
|
||||
|
||||
funcctx->max_calls = nsteps;
|
||||
funcctx->user_fctx = ctx;
|
||||
|
||||
/* Output tuple: (timestamptz, lat_deg, lon_deg, alt_km) */
|
||||
tupdesc = CreateTemplateTupleDesc(4);
|
||||
TupleDescInitEntry(tupdesc, 1, "t", TIMESTAMPTZOID, -1, 0);
|
||||
TupleDescInitEntry(tupdesc, 2, "lat_deg", FLOAT8OID, -1, 0);
|
||||
TupleDescInitEntry(tupdesc, 3, "lon_deg", FLOAT8OID, -1, 0);
|
||||
TupleDescInitEntry(tupdesc, 4, "alt_km", FLOAT8OID, -1, 0);
|
||||
|
||||
funcctx->tuple_desc = BlessTupleDesc(tupdesc);
|
||||
|
||||
MemoryContextSwitchTo(oldctx);
|
||||
}
|
||||
|
||||
funcctx = SRF_PERCALL_SETUP();
|
||||
ctx = (ground_track_ctx *) funcctx->user_fctx;
|
||||
|
||||
if (funcctx->call_cntr < funcctx->max_calls)
|
||||
{
|
||||
int64 ts;
|
||||
double jd;
|
||||
double tsince;
|
||||
double pos[3], vel[3];
|
||||
double gmst;
|
||||
double pos_ecef[3];
|
||||
double lat, lon, alt_km;
|
||||
int err;
|
||||
Datum values[4];
|
||||
bool nulls[4];
|
||||
HeapTuple tuple;
|
||||
|
||||
ts = ctx->start_ts + (int64) funcctx->call_cntr * ctx->step_usec;
|
||||
jd = timestamptz_to_jd(ts);
|
||||
tsince = jd_to_minutes_since_epoch(jd, ctx->epoch_jd);
|
||||
|
||||
if (ctx->is_deep)
|
||||
err = SDP4(tsince, &ctx->sat, ctx->params, pos, vel);
|
||||
else
|
||||
err = SGP4(tsince, &ctx->sat, ctx->params, pos, vel);
|
||||
|
||||
if (err != 0 &&
|
||||
err != SXPX_WARN_ORBIT_WITHIN_EARTH &&
|
||||
err != SXPX_WARN_PERIGEE_WITHIN_EARTH)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_DATA_EXCEPTION),
|
||||
errmsg("SGP4/SDP4 propagation failed for NORAD %d "
|
||||
"at t+%.1f min",
|
||||
ctx->sat.norad_number, tsince)));
|
||||
|
||||
gmst = gmst_from_jd(jd);
|
||||
teme_to_ecef(pos, NULL, gmst, pos_ecef, NULL);
|
||||
ecef_to_geodetic(pos_ecef, &lat, &lon, &alt_km);
|
||||
|
||||
memset(nulls, 0, sizeof(nulls));
|
||||
|
||||
values[0] = Int64GetDatum(ts);
|
||||
values[1] = Float8GetDatum(lat * RAD_TO_DEG);
|
||||
values[2] = Float8GetDatum(lon * RAD_TO_DEG);
|
||||
values[3] = Float8GetDatum(alt_km);
|
||||
|
||||
tuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
|
||||
|
||||
SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple));
|
||||
}
|
||||
|
||||
SRF_RETURN_DONE(funcctx);
|
||||
}
|
||||
219
src/eci_type.c
Normal file
219
src/eci_type.c
Normal file
@ -0,0 +1,219 @@
|
||||
/*
|
||||
* eci_type.c -- ECI position type (TEME frame)
|
||||
*
|
||||
* Position in km, velocity in km/s.
|
||||
* Input/output as (x,y,z,vx,vy,vz) or (x,y,z) with zero velocity.
|
||||
*/
|
||||
|
||||
#include "postgres.h"
|
||||
#include "fmgr.h"
|
||||
#include "utils/builtins.h"
|
||||
#include "libpq/pqformat.h"
|
||||
#include "types.h"
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
|
||||
PG_FUNCTION_INFO_V1(eci_in);
|
||||
PG_FUNCTION_INFO_V1(eci_out);
|
||||
PG_FUNCTION_INFO_V1(eci_recv);
|
||||
PG_FUNCTION_INFO_V1(eci_send);
|
||||
PG_FUNCTION_INFO_V1(eci_x);
|
||||
PG_FUNCTION_INFO_V1(eci_y);
|
||||
PG_FUNCTION_INFO_V1(eci_z);
|
||||
PG_FUNCTION_INFO_V1(eci_vx);
|
||||
PG_FUNCTION_INFO_V1(eci_vy);
|
||||
PG_FUNCTION_INFO_V1(eci_vz);
|
||||
PG_FUNCTION_INFO_V1(eci_speed);
|
||||
PG_FUNCTION_INFO_V1(eci_altitude);
|
||||
|
||||
|
||||
/*
|
||||
* eci_in -- parse text to pg_eci
|
||||
*
|
||||
* Accepts:
|
||||
* (x,y,z,vx,vy,vz) full state vector
|
||||
* (x,y,z) position only, velocity = 0
|
||||
*/
|
||||
Datum
|
||||
eci_in(PG_FUNCTION_ARGS)
|
||||
{
|
||||
char *str = PG_GETARG_CSTRING(0);
|
||||
pg_eci *result;
|
||||
double x, y, z, vx, vy, vz;
|
||||
int nfields;
|
||||
|
||||
result = (pg_eci *) palloc(sizeof(pg_eci));
|
||||
|
||||
nfields = sscanf(str, " ( %lf , %lf , %lf , %lf , %lf , %lf )",
|
||||
&x, &y, &z, &vx, &vy, &vz);
|
||||
|
||||
if (nfields == 6)
|
||||
{
|
||||
result->x = x;
|
||||
result->y = y;
|
||||
result->z = z;
|
||||
result->vx = vx;
|
||||
result->vy = vy;
|
||||
result->vz = vz;
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
/* Try position-only format */
|
||||
nfields = sscanf(str, " ( %lf , %lf , %lf )", &x, &y, &z);
|
||||
if (nfields == 3)
|
||||
{
|
||||
result->x = x;
|
||||
result->y = y;
|
||||
result->z = z;
|
||||
result->vx = 0.0;
|
||||
result->vy = 0.0;
|
||||
result->vz = 0.0;
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
|
||||
errmsg("invalid input syntax for type eci: \"%s\"", str),
|
||||
errhint("Expected (x,y,z,vx,vy,vz) or (x,y,z).")));
|
||||
|
||||
PG_RETURN_NULL(); /* not reached */
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* eci_out -- pg_eci to text
|
||||
*/
|
||||
Datum
|
||||
eci_out(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_CSTRING(psprintf("(%.6f,%.6f,%.6f,%.6f,%.6f,%.6f)",
|
||||
eci->x, eci->y, eci->z,
|
||||
eci->vx, eci->vy, eci->vz));
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* eci_recv -- binary input
|
||||
*/
|
||||
Datum
|
||||
eci_recv(PG_FUNCTION_ARGS)
|
||||
{
|
||||
StringInfo buf = (StringInfo) PG_GETARG_POINTER(0);
|
||||
pg_eci *result;
|
||||
|
||||
result = (pg_eci *) palloc(sizeof(pg_eci));
|
||||
result->x = pq_getmsgfloat8(buf);
|
||||
result->y = pq_getmsgfloat8(buf);
|
||||
result->z = pq_getmsgfloat8(buf);
|
||||
result->vx = pq_getmsgfloat8(buf);
|
||||
result->vy = pq_getmsgfloat8(buf);
|
||||
result->vz = pq_getmsgfloat8(buf);
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* eci_send -- binary output
|
||||
*/
|
||||
Datum
|
||||
eci_send(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
|
||||
StringInfoData buf;
|
||||
|
||||
pq_begintypsend(&buf);
|
||||
pq_sendfloat8(&buf, eci->x);
|
||||
pq_sendfloat8(&buf, eci->y);
|
||||
pq_sendfloat8(&buf, eci->z);
|
||||
pq_sendfloat8(&buf, eci->vx);
|
||||
pq_sendfloat8(&buf, eci->vy);
|
||||
pq_sendfloat8(&buf, eci->vz);
|
||||
|
||||
PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
|
||||
}
|
||||
|
||||
|
||||
/* --- accessor functions --- */
|
||||
|
||||
Datum
|
||||
eci_x(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(eci->x);
|
||||
}
|
||||
|
||||
Datum
|
||||
eci_y(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(eci->y);
|
||||
}
|
||||
|
||||
Datum
|
||||
eci_z(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(eci->z);
|
||||
}
|
||||
|
||||
Datum
|
||||
eci_vx(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(eci->vx);
|
||||
}
|
||||
|
||||
Datum
|
||||
eci_vy(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(eci->vy);
|
||||
}
|
||||
|
||||
Datum
|
||||
eci_vz(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(eci->vz);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* eci_speed -- orbital speed in km/s
|
||||
*/
|
||||
Datum
|
||||
eci_speed(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
|
||||
double spd;
|
||||
|
||||
spd = sqrt(eci->vx * eci->vx +
|
||||
eci->vy * eci->vy +
|
||||
eci->vz * eci->vz);
|
||||
|
||||
PG_RETURN_FLOAT8(spd);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* eci_altitude -- approximate altitude above WGS-72 sphere in km
|
||||
*
|
||||
* This is geocentric radius minus equatorial radius, not geodetic altitude.
|
||||
* Good enough for quick filtering; use eci_to_geodetic() for precision.
|
||||
*/
|
||||
Datum
|
||||
eci_altitude(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
|
||||
double r;
|
||||
|
||||
r = sqrt(eci->x * eci->x +
|
||||
eci->y * eci->y +
|
||||
eci->z * eci->z);
|
||||
|
||||
PG_RETURN_FLOAT8(r - WGS72_AE);
|
||||
}
|
||||
505
src/gist_tle.c
Normal file
505
src/gist_tle.c
Normal file
@ -0,0 +1,505 @@
|
||||
/*
|
||||
* gist_tle.c -- GiST operator class for altitude-band indexing on TLE
|
||||
*
|
||||
* Every TLE defines an orbit whose perigee and apogee altitudes form a
|
||||
* 1-D range (the "altitude band"). Two orbits can only be in proximity
|
||||
* if their altitude bands overlap -- a necessary but not sufficient
|
||||
* condition for conjunction.
|
||||
*
|
||||
* The GiST index stores [perigee_km, apogee_km] ranges as internal
|
||||
* keys, enabling fast coarse filtering. The && (overlap) operator is
|
||||
* always rechecked: real conjunction screening requires propagation.
|
||||
*
|
||||
* Semi-major axis from Kepler's third law using WGS-72 constants:
|
||||
* a = (KE / n)^(2/3) [earth radii]
|
||||
* perigee_km = a*(1-e)*AE - AE
|
||||
* apogee_km = a*(1+e)*AE - AE
|
||||
*/
|
||||
|
||||
#include "postgres.h"
|
||||
#include "fmgr.h"
|
||||
#include "access/gist.h"
|
||||
#include "access/stratnum.h"
|
||||
#include "utils/float.h"
|
||||
#include "norad.h"
|
||||
#include "types.h"
|
||||
#include <math.h>
|
||||
#include <float.h>
|
||||
|
||||
PG_FUNCTION_INFO_V1(tle_overlap);
|
||||
PG_FUNCTION_INFO_V1(tle_alt_distance);
|
||||
PG_FUNCTION_INFO_V1(gist_tle_compress);
|
||||
PG_FUNCTION_INFO_V1(gist_tle_decompress);
|
||||
PG_FUNCTION_INFO_V1(gist_tle_consistent);
|
||||
PG_FUNCTION_INFO_V1(gist_tle_union);
|
||||
PG_FUNCTION_INFO_V1(gist_tle_penalty);
|
||||
PG_FUNCTION_INFO_V1(gist_tle_picksplit);
|
||||
PG_FUNCTION_INFO_V1(gist_tle_same);
|
||||
PG_FUNCTION_INFO_V1(gist_tle_distance);
|
||||
|
||||
/* Floating-point comparison tolerance (km) */
|
||||
#define ALT_EPSILON 1.0e-9
|
||||
|
||||
/*
|
||||
* Altitude band extracted from a TLE's mean elements.
|
||||
* This is the GiST internal key -- much cheaper to compare
|
||||
* than propagating two full state vectors.
|
||||
*/
|
||||
typedef struct tle_alt_range
|
||||
{
|
||||
double low; /* perigee altitude, km */
|
||||
double high; /* apogee altitude, km */
|
||||
} tle_alt_range;
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* tle_to_alt_range -- compute [perigee, apogee] from mean elements
|
||||
*
|
||||
* Uses WGS-72 KE and AE (the only constants valid for SGP4 elements).
|
||||
* Degenerate TLEs with n <= 0 map to a zero-width range at 0 km.
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
static void
|
||||
tle_to_alt_range(const pg_tle *tle, tle_alt_range *range)
|
||||
{
|
||||
double n = tle->mean_motion; /* rad/min */
|
||||
double e = tle->eccentricity;
|
||||
double a_er; /* semi-major axis, earth radii */
|
||||
|
||||
if (n <= 0.0)
|
||||
{
|
||||
range->low = 0.0;
|
||||
range->high = 0.0;
|
||||
return;
|
||||
}
|
||||
|
||||
a_er = pow(WGS72_KE / n, 2.0 / 3.0);
|
||||
range->low = a_er * (1.0 - e) * WGS72_AE - WGS72_AE;
|
||||
range->high = a_er * (1.0 + e) * WGS72_AE - WGS72_AE;
|
||||
|
||||
/* Guard against numerical inversion from near-zero eccentricity */
|
||||
if (range->low > range->high)
|
||||
{
|
||||
double tmp = range->low;
|
||||
range->low = range->high;
|
||||
range->high = tmp;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* range_overlaps -- do two altitude bands share any interval?
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
static inline bool
|
||||
range_overlaps(const tle_alt_range *a, const tle_alt_range *b)
|
||||
{
|
||||
return (a->low <= b->high) && (b->low <= a->high);
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* range_contains -- does outer fully contain inner?
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
static inline bool
|
||||
range_contains(const tle_alt_range *outer, const tle_alt_range *inner)
|
||||
{
|
||||
return (outer->low <= inner->low) && (inner->high <= outer->high);
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* range_merge -- expand dst to encompass src
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
static inline void
|
||||
range_merge(tle_alt_range *dst, const tle_alt_range *src)
|
||||
{
|
||||
if (src->low < dst->low)
|
||||
dst->low = src->low;
|
||||
if (src->high > dst->high)
|
||||
dst->high = src->high;
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* range_separation -- minimum gap between two non-overlapping ranges
|
||||
*
|
||||
* Returns 0 if the ranges overlap.
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
static inline double
|
||||
range_separation(const tle_alt_range *a, const tle_alt_range *b)
|
||||
{
|
||||
if (a->high < b->low)
|
||||
return b->low - a->high;
|
||||
if (b->high < a->low)
|
||||
return a->low - b->high;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
|
||||
/* ================================================================
|
||||
* SQL-callable operators
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* tle_overlap(tle, tle) -> bool [the && operator]
|
||||
*
|
||||
* True if the altitude bands of two TLEs share any interval.
|
||||
* This is a fast pre-filter: overlapping bands are necessary
|
||||
* but not sufficient for actual conjunction.
|
||||
*/
|
||||
Datum
|
||||
tle_overlap(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *a = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
pg_tle *b = (pg_tle *) PG_GETARG_POINTER(1);
|
||||
tle_alt_range ra, rb;
|
||||
|
||||
tle_to_alt_range(a, &ra);
|
||||
tle_to_alt_range(b, &rb);
|
||||
|
||||
PG_RETURN_BOOL(range_overlaps(&ra, &rb));
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* tle_alt_distance(tle, tle) -> float8 [the <-> operator]
|
||||
*
|
||||
* Minimum altitude-band separation in km. Returns 0 if the bands
|
||||
* overlap. This is not the physical distance between the objects --
|
||||
* it is the gap between their orbital shells, useful for ordering
|
||||
* nearest-neighbor queries without propagation.
|
||||
*/
|
||||
Datum
|
||||
tle_alt_distance(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *a = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
pg_tle *b = (pg_tle *) PG_GETARG_POINTER(1);
|
||||
tle_alt_range ra, rb;
|
||||
|
||||
tle_to_alt_range(a, &ra);
|
||||
tle_to_alt_range(b, &rb);
|
||||
|
||||
PG_RETURN_FLOAT8(range_separation(&ra, &rb));
|
||||
}
|
||||
|
||||
|
||||
/* ================================================================
|
||||
* GiST support functions
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* gist_tle_compress -- extract altitude range from a leaf TLE
|
||||
*
|
||||
* Leaf entries carry the full pg_tle; we compress to tle_alt_range.
|
||||
* Internal entries are already tle_alt_range from union operations.
|
||||
*/
|
||||
Datum
|
||||
gist_tle_compress(PG_FUNCTION_ARGS)
|
||||
{
|
||||
GISTENTRY *entry = (GISTENTRY *) PG_GETARG_POINTER(0);
|
||||
GISTENTRY *retval;
|
||||
|
||||
if (entry->leafkey)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) DatumGetPointer(entry->key);
|
||||
tle_alt_range *range = (tle_alt_range *) palloc(sizeof(tle_alt_range));
|
||||
|
||||
tle_to_alt_range(tle, range);
|
||||
|
||||
retval = (GISTENTRY *) palloc(sizeof(GISTENTRY));
|
||||
gistentryinit(*retval, PointerGetDatum(range),
|
||||
entry->rel, entry->page, entry->offset, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
/* Internal node: already a tle_alt_range */
|
||||
retval = entry;
|
||||
}
|
||||
|
||||
PG_RETURN_POINTER(retval);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* gist_tle_decompress -- identity (we operate on compressed keys directly)
|
||||
*/
|
||||
Datum
|
||||
gist_tle_decompress(PG_FUNCTION_ARGS)
|
||||
{
|
||||
PG_RETURN_POINTER(PG_GETARG_POINTER(0));
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* gist_tle_consistent -- can this subtree contain matches for the query?
|
||||
*
|
||||
* Strategy RTOverlapStrategyNumber (&&): altitude bands must overlap.
|
||||
* Always sets recheck = true because altitude overlap is only a necessary
|
||||
* condition -- the real conjunction test requires propagation.
|
||||
*/
|
||||
Datum
|
||||
gist_tle_consistent(PG_FUNCTION_ARGS)
|
||||
{
|
||||
GISTENTRY *entry = (GISTENTRY *) PG_GETARG_POINTER(0);
|
||||
pg_tle *query = (pg_tle *) PG_GETARG_POINTER(1);
|
||||
StrategyNumber strategy = (StrategyNumber) PG_GETARG_UINT16(2);
|
||||
/* arg 3 is the query type OID, unused */
|
||||
bool *recheck = (bool *) PG_GETARG_POINTER(4);
|
||||
tle_alt_range *key = (tle_alt_range *) DatumGetPointer(entry->key);
|
||||
tle_alt_range query_range;
|
||||
bool result;
|
||||
|
||||
tle_to_alt_range(query, &query_range);
|
||||
|
||||
/*
|
||||
* Altitude overlap is necessary, not sufficient.
|
||||
* The actual operator (if exact) would need propagation, so always recheck.
|
||||
*/
|
||||
*recheck = true;
|
||||
|
||||
switch (strategy)
|
||||
{
|
||||
case RTOverlapStrategyNumber: /* && */
|
||||
result = range_overlaps(key, &query_range);
|
||||
break;
|
||||
|
||||
case RTContainedByStrategyNumber: /* <@ */
|
||||
if (GIST_LEAF(entry))
|
||||
result = range_contains(&query_range, key);
|
||||
else
|
||||
result = range_overlaps(key, &query_range);
|
||||
break;
|
||||
|
||||
case RTContainsStrategyNumber: /* @> */
|
||||
if (GIST_LEAF(entry))
|
||||
result = range_contains(key, &query_range);
|
||||
else
|
||||
result = range_overlaps(key, &query_range);
|
||||
break;
|
||||
|
||||
default:
|
||||
elog(ERROR, "gist_tle_consistent: unrecognized strategy %d",
|
||||
strategy);
|
||||
result = false;
|
||||
break;
|
||||
}
|
||||
|
||||
PG_RETURN_BOOL(result);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* gist_tle_union -- compute bounding altitude range for a set of entries
|
||||
*
|
||||
* The union of N altitude ranges is simply [min(low), max(high)].
|
||||
*/
|
||||
Datum
|
||||
gist_tle_union(PG_FUNCTION_ARGS)
|
||||
{
|
||||
GistEntryVector *entryvec = (GistEntryVector *) PG_GETARG_POINTER(0);
|
||||
int *sizep = (int *) PG_GETARG_POINTER(1);
|
||||
int i;
|
||||
tle_alt_range *result;
|
||||
tle_alt_range *cur;
|
||||
|
||||
result = (tle_alt_range *) palloc(sizeof(tle_alt_range));
|
||||
cur = (tle_alt_range *) DatumGetPointer(entryvec->vector[0].key);
|
||||
result->low = cur->low;
|
||||
result->high = cur->high;
|
||||
|
||||
for (i = 1; i < entryvec->n; i++)
|
||||
{
|
||||
cur = (tle_alt_range *) DatumGetPointer(entryvec->vector[i].key);
|
||||
range_merge(result, cur);
|
||||
}
|
||||
|
||||
*sizep = sizeof(tle_alt_range);
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* gist_tle_penalty -- cost of inserting a new entry into an existing subtree
|
||||
*
|
||||
* Penalty is the increase in altitude span (km) that results from
|
||||
* expanding the subtree's bounding range to include the new entry.
|
||||
* A good penalty function keeps the tree balanced and minimizes
|
||||
* unnecessary range expansion.
|
||||
*/
|
||||
Datum
|
||||
gist_tle_penalty(PG_FUNCTION_ARGS)
|
||||
{
|
||||
GISTENTRY *origentry = (GISTENTRY *) PG_GETARG_POINTER(0);
|
||||
GISTENTRY *newentry = (GISTENTRY *) PG_GETARG_POINTER(1);
|
||||
float *penalty = (float *) PG_GETARG_POINTER(2);
|
||||
tle_alt_range *orig = (tle_alt_range *) DatumGetPointer(origentry->key);
|
||||
tle_alt_range *add = (tle_alt_range *) DatumGetPointer(newentry->key);
|
||||
double orig_span;
|
||||
double merged_span;
|
||||
|
||||
orig_span = orig->high - orig->low;
|
||||
merged_span = fmax(orig->high, add->high) - fmin(orig->low, add->low);
|
||||
|
||||
*penalty = (float)(merged_span - orig_span);
|
||||
|
||||
PG_RETURN_POINTER(penalty);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Comparison callback for qsort in picksplit.
|
||||
* Sorts entries by the midpoint of their altitude range.
|
||||
*/
|
||||
typedef struct
|
||||
{
|
||||
int index; /* position in the original entry vector */
|
||||
double midpoint; /* (low + high) / 2 */
|
||||
} picksplit_item;
|
||||
|
||||
static int
|
||||
picksplit_cmp(const void *a, const void *b)
|
||||
{
|
||||
double ma = ((const picksplit_item *) a)->midpoint;
|
||||
double mb = ((const picksplit_item *) b)->midpoint;
|
||||
|
||||
if (ma < mb)
|
||||
return -1;
|
||||
if (ma > mb)
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* gist_tle_picksplit -- split an overfull page into two groups
|
||||
*
|
||||
* Strategy: sort entries by altitude midpoint, split at the median.
|
||||
* This keeps nearby altitude bands in the same subtree, which is
|
||||
* optimal for a 1-D range index.
|
||||
*/
|
||||
Datum
|
||||
gist_tle_picksplit(PG_FUNCTION_ARGS)
|
||||
{
|
||||
GistEntryVector *entryvec = (GistEntryVector *) PG_GETARG_POINTER(0);
|
||||
GIST_SPLITVEC *splitvec = (GIST_SPLITVEC *) PG_GETARG_POINTER(1);
|
||||
int nentries = entryvec->n;
|
||||
picksplit_item *items;
|
||||
tle_alt_range *left_union;
|
||||
tle_alt_range *right_union;
|
||||
tle_alt_range *cur;
|
||||
int split_at;
|
||||
int i;
|
||||
|
||||
items = (picksplit_item *) palloc(sizeof(picksplit_item) * nentries);
|
||||
for (i = 0; i < nentries; i++)
|
||||
{
|
||||
cur = (tle_alt_range *) DatumGetPointer(entryvec->vector[i].key);
|
||||
items[i].index = i;
|
||||
items[i].midpoint = (cur->low + cur->high) / 2.0;
|
||||
}
|
||||
|
||||
qsort(items, nentries, sizeof(picksplit_item), picksplit_cmp);
|
||||
|
||||
split_at = nentries / 2;
|
||||
|
||||
/* Allocate offset arrays (GiST uses OffsetNumber, 1-based) */
|
||||
splitvec->spl_left = (OffsetNumber *) palloc(sizeof(OffsetNumber) * nentries);
|
||||
splitvec->spl_right = (OffsetNumber *) palloc(sizeof(OffsetNumber) * nentries);
|
||||
splitvec->spl_nleft = 0;
|
||||
splitvec->spl_nright = 0;
|
||||
|
||||
/* Compute union ranges and assign entries */
|
||||
left_union = (tle_alt_range *) palloc(sizeof(tle_alt_range));
|
||||
right_union = (tle_alt_range *) palloc(sizeof(tle_alt_range));
|
||||
|
||||
/* Seed the unions from the first entry in each half */
|
||||
cur = (tle_alt_range *) DatumGetPointer(
|
||||
entryvec->vector[items[0].index].key);
|
||||
left_union->low = cur->low;
|
||||
left_union->high = cur->high;
|
||||
|
||||
cur = (tle_alt_range *) DatumGetPointer(
|
||||
entryvec->vector[items[split_at].index].key);
|
||||
right_union->low = cur->low;
|
||||
right_union->high = cur->high;
|
||||
|
||||
for (i = 0; i < nentries; i++)
|
||||
{
|
||||
int idx = items[i].index;
|
||||
|
||||
cur = (tle_alt_range *) DatumGetPointer(
|
||||
entryvec->vector[idx].key);
|
||||
|
||||
if (i < split_at)
|
||||
{
|
||||
splitvec->spl_left[splitvec->spl_nleft++] =
|
||||
(OffsetNumber)(idx + 1); /* 1-based */
|
||||
range_merge(left_union, cur);
|
||||
}
|
||||
else
|
||||
{
|
||||
splitvec->spl_right[splitvec->spl_nright++] =
|
||||
(OffsetNumber)(idx + 1);
|
||||
range_merge(right_union, cur);
|
||||
}
|
||||
}
|
||||
|
||||
splitvec->spl_ldatum = PointerGetDatum(left_union);
|
||||
splitvec->spl_rdatum = PointerGetDatum(right_union);
|
||||
|
||||
pfree(items);
|
||||
|
||||
PG_RETURN_POINTER(splitvec);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* gist_tle_same -- equality test on compressed keys
|
||||
*
|
||||
* Two altitude ranges are "same" if both endpoints match within
|
||||
* a small tolerance. This lets the GiST machinery detect
|
||||
* duplicate keys.
|
||||
*/
|
||||
Datum
|
||||
gist_tle_same(PG_FUNCTION_ARGS)
|
||||
{
|
||||
tle_alt_range *a = (tle_alt_range *) PG_GETARG_POINTER(0);
|
||||
tle_alt_range *b = (tle_alt_range *) PG_GETARG_POINTER(1);
|
||||
bool *result = (bool *) PG_GETARG_POINTER(2);
|
||||
|
||||
*result = (fabs(a->low - b->low) < ALT_EPSILON &&
|
||||
fabs(a->high - b->high) < ALT_EPSILON);
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* gist_tle_distance -- GiST distance function for KNN ordering
|
||||
*
|
||||
* Returns the minimum altitude-band separation in km.
|
||||
* For overlapping ranges this is 0, making the entry a candidate.
|
||||
* The planner uses this to drive ORDER BY <-> queries.
|
||||
*/
|
||||
Datum
|
||||
gist_tle_distance(PG_FUNCTION_ARGS)
|
||||
{
|
||||
GISTENTRY *entry = (GISTENTRY *) PG_GETARG_POINTER(0);
|
||||
pg_tle *query = (pg_tle *) PG_GETARG_POINTER(1);
|
||||
/* strategy number at arg 2, unused for single-distance class */
|
||||
tle_alt_range *key = (tle_alt_range *) DatumGetPointer(entry->key);
|
||||
tle_alt_range query_range;
|
||||
|
||||
tle_to_alt_range(query, &query_range);
|
||||
|
||||
PG_RETURN_FLOAT8(range_separation(key, &query_range));
|
||||
}
|
||||
284
src/observer_type.c
Normal file
284
src/observer_type.c
Normal file
@ -0,0 +1,284 @@
|
||||
/*
|
||||
* observer_type.c -- Ground station observer location type
|
||||
*
|
||||
* Latitude/longitude stored internally in radians, altitude in meters.
|
||||
* Flexible input: compass directions, decimal degrees, or tuple format.
|
||||
* Output as "40.0000N 105.3000W 1655m".
|
||||
*/
|
||||
|
||||
#include "postgres.h"
|
||||
#include "fmgr.h"
|
||||
#include "utils/builtins.h"
|
||||
#include "libpq/pqformat.h"
|
||||
#include "types.h"
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <ctype.h>
|
||||
|
||||
#define DEG_TO_RAD (M_PI / 180.0)
|
||||
#define RAD_TO_DEG (180.0 / M_PI)
|
||||
|
||||
PG_FUNCTION_INFO_V1(observer_in);
|
||||
PG_FUNCTION_INFO_V1(observer_out);
|
||||
PG_FUNCTION_INFO_V1(observer_recv);
|
||||
PG_FUNCTION_INFO_V1(observer_send);
|
||||
PG_FUNCTION_INFO_V1(observer_lat);
|
||||
PG_FUNCTION_INFO_V1(observer_lon);
|
||||
PG_FUNCTION_INFO_V1(observer_alt);
|
||||
|
||||
|
||||
/*
|
||||
* skip_whitespace -- advance pointer past spaces and tabs
|
||||
*/
|
||||
static const char *
|
||||
skip_whitespace(const char *p)
|
||||
{
|
||||
while (*p == ' ' || *p == '\t')
|
||||
p++;
|
||||
return p;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* observer_in -- parse text to pg_observer
|
||||
*
|
||||
* Accepted formats:
|
||||
* 40.0N 105.3W 1655m compass directions with altitude
|
||||
* 40.0N 105.3W compass directions, altitude = 0
|
||||
* 40.0 -105.3 1655 decimal degrees (N/E positive) with altitude
|
||||
* (40.0,-105.3,1655) parenthesized tuple
|
||||
*/
|
||||
Datum
|
||||
observer_in(PG_FUNCTION_ARGS)
|
||||
{
|
||||
char *str = PG_GETARG_CSTRING(0);
|
||||
pg_observer *result;
|
||||
double lat_deg, lon_deg, alt_m;
|
||||
const char *p;
|
||||
char *endptr;
|
||||
char dir;
|
||||
|
||||
result = (pg_observer *) palloc(sizeof(pg_observer));
|
||||
alt_m = 0.0;
|
||||
|
||||
p = skip_whitespace(str);
|
||||
|
||||
/*
|
||||
* Format 4: parenthesized tuple (lat_deg, lon_deg, alt_m)
|
||||
*/
|
||||
if (*p == '(')
|
||||
{
|
||||
int nfields;
|
||||
|
||||
nfields = sscanf(str, " ( %lf , %lf , %lf )",
|
||||
&lat_deg, &lon_deg, &alt_m);
|
||||
if (nfields < 2)
|
||||
goto bad_input;
|
||||
if (nfields == 2)
|
||||
alt_m = 0.0;
|
||||
|
||||
if (lat_deg < -90.0 || lat_deg > 90.0)
|
||||
goto bad_lat;
|
||||
if (lon_deg < -180.0 || lon_deg > 180.0)
|
||||
goto bad_lon;
|
||||
|
||||
result->lat = lat_deg * DEG_TO_RAD;
|
||||
result->lon = lon_deg * DEG_TO_RAD;
|
||||
result->alt_m = alt_m;
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
/*
|
||||
* Parse latitude value
|
||||
*/
|
||||
lat_deg = strtod(p, &endptr);
|
||||
if (endptr == p)
|
||||
goto bad_input;
|
||||
p = endptr;
|
||||
|
||||
/*
|
||||
* Check for compass direction suffix (N/S)
|
||||
*/
|
||||
dir = toupper((unsigned char) *p);
|
||||
if (dir == 'N' || dir == 'S')
|
||||
{
|
||||
/* Format 1/3: compass directions */
|
||||
if (dir == 'S')
|
||||
lat_deg = -lat_deg;
|
||||
p++;
|
||||
p = skip_whitespace(p);
|
||||
|
||||
/* Parse longitude value */
|
||||
lon_deg = strtod(p, &endptr);
|
||||
if (endptr == p)
|
||||
goto bad_input;
|
||||
p = endptr;
|
||||
|
||||
/* Expect E or W */
|
||||
dir = toupper((unsigned char) *p);
|
||||
if (dir == 'W')
|
||||
lon_deg = -lon_deg;
|
||||
else if (dir != 'E')
|
||||
goto bad_input;
|
||||
p++;
|
||||
p = skip_whitespace(p);
|
||||
|
||||
/* Optional altitude with 'm' suffix */
|
||||
if (*p != '\0')
|
||||
{
|
||||
alt_m = strtod(p, &endptr);
|
||||
if (endptr == p)
|
||||
goto bad_input;
|
||||
p = endptr;
|
||||
/* Skip optional 'm' suffix */
|
||||
if (toupper((unsigned char) *p) == 'M')
|
||||
p++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
/* Format 2: decimal degrees, space-separated */
|
||||
p = skip_whitespace(p);
|
||||
|
||||
lon_deg = strtod(p, &endptr);
|
||||
if (endptr == p)
|
||||
goto bad_input;
|
||||
p = endptr;
|
||||
p = skip_whitespace(p);
|
||||
|
||||
/* Optional altitude */
|
||||
if (*p != '\0')
|
||||
{
|
||||
alt_m = strtod(p, &endptr);
|
||||
if (endptr == p)
|
||||
goto bad_input;
|
||||
}
|
||||
}
|
||||
|
||||
/* Validate ranges (in degrees before conversion) */
|
||||
if (fabs(lat_deg) > 90.0)
|
||||
goto bad_lat;
|
||||
if (fabs(lon_deg) > 180.0)
|
||||
goto bad_lon;
|
||||
|
||||
result->lat = lat_deg * DEG_TO_RAD;
|
||||
result->lon = lon_deg * DEG_TO_RAD;
|
||||
result->alt_m = alt_m;
|
||||
PG_RETURN_POINTER(result);
|
||||
|
||||
bad_lat:
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
|
||||
errmsg("latitude out of range: %.6f", lat_deg),
|
||||
errhint("Latitude must be between -90 and +90 degrees.")));
|
||||
|
||||
bad_lon:
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
|
||||
errmsg("longitude out of range: %.6f", lon_deg),
|
||||
errhint("Longitude must be between -180 and +180 degrees.")));
|
||||
|
||||
bad_input:
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
|
||||
errmsg("invalid input syntax for type observer: \"%s\"", str),
|
||||
errhint("Expected \"40.0N 105.3W 1655m\", \"40.0 -105.3 1655\", "
|
||||
"or \"(40.0,-105.3,1655)\".")));
|
||||
|
||||
PG_RETURN_NULL(); /* not reached */
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* observer_out -- pg_observer to text
|
||||
*
|
||||
* Output: "40.0000N 105.3000W 1655m"
|
||||
*/
|
||||
Datum
|
||||
observer_out(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(0);
|
||||
double lat_deg, lon_deg;
|
||||
char lat_dir, lon_dir;
|
||||
|
||||
lat_deg = obs->lat * RAD_TO_DEG;
|
||||
lon_deg = obs->lon * RAD_TO_DEG;
|
||||
|
||||
lat_dir = (lat_deg >= 0.0) ? 'N' : 'S';
|
||||
lon_dir = (lon_deg >= 0.0) ? 'E' : 'W';
|
||||
|
||||
PG_RETURN_CSTRING(psprintf("%.4f%c %.4f%c %.0fm",
|
||||
fabs(lat_deg), lat_dir,
|
||||
fabs(lon_deg), lon_dir,
|
||||
obs->alt_m));
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* observer_recv -- binary input
|
||||
*/
|
||||
Datum
|
||||
observer_recv(PG_FUNCTION_ARGS)
|
||||
{
|
||||
StringInfo buf = (StringInfo) PG_GETARG_POINTER(0);
|
||||
pg_observer *result;
|
||||
|
||||
result = (pg_observer *) palloc(sizeof(pg_observer));
|
||||
result->lat = pq_getmsgfloat8(buf);
|
||||
result->lon = pq_getmsgfloat8(buf);
|
||||
result->alt_m = pq_getmsgfloat8(buf);
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* observer_send -- binary output
|
||||
*/
|
||||
Datum
|
||||
observer_send(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(0);
|
||||
StringInfoData buf;
|
||||
|
||||
pq_begintypsend(&buf);
|
||||
pq_sendfloat8(&buf, obs->lat);
|
||||
pq_sendfloat8(&buf, obs->lon);
|
||||
pq_sendfloat8(&buf, obs->alt_m);
|
||||
|
||||
PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
|
||||
}
|
||||
|
||||
|
||||
/* --- accessor functions --- */
|
||||
|
||||
/*
|
||||
* observer_lat -- latitude in degrees
|
||||
*/
|
||||
Datum
|
||||
observer_lat(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(obs->lat * RAD_TO_DEG);
|
||||
}
|
||||
|
||||
/*
|
||||
* observer_lon -- longitude in degrees (positive east)
|
||||
*/
|
||||
Datum
|
||||
observer_lon(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(obs->lon * RAD_TO_DEG);
|
||||
}
|
||||
|
||||
/*
|
||||
* observer_alt -- altitude in meters
|
||||
*/
|
||||
Datum
|
||||
observer_alt(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(obs->alt_m);
|
||||
}
|
||||
773
src/pass_funcs.c
Normal file
773
src/pass_funcs.c
Normal file
@ -0,0 +1,773 @@
|
||||
/*
|
||||
* pass_funcs.c -- Satellite pass prediction for pg_orbit
|
||||
*
|
||||
* Finds visibility windows (AOS/LOS) for a satellite relative to a
|
||||
* ground observer. Uses bisection on the elevation function to pin
|
||||
* zero-crossings, then ternary search for peak elevation.
|
||||
*
|
||||
* The coarse scan steps at 30-second intervals -- fine enough for LEO
|
||||
* orbits (~90 min period) that no pass shorter than a minute gets
|
||||
* missed, coarse enough that a 7-day window doesn't require millions
|
||||
* of propagation calls.
|
||||
*/
|
||||
|
||||
#include "postgres.h"
|
||||
#include "fmgr.h"
|
||||
#include "funcapi.h"
|
||||
#include "utils/timestamp.h"
|
||||
#include "utils/builtins.h"
|
||||
#include "libpq/pqformat.h"
|
||||
#include "norad.h"
|
||||
#include "types.h"
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
PG_FUNCTION_INFO_V1(pass_event_in);
|
||||
PG_FUNCTION_INFO_V1(pass_event_out);
|
||||
PG_FUNCTION_INFO_V1(pass_event_recv);
|
||||
PG_FUNCTION_INFO_V1(pass_event_send);
|
||||
PG_FUNCTION_INFO_V1(pass_aos_time);
|
||||
PG_FUNCTION_INFO_V1(pass_max_el_time);
|
||||
PG_FUNCTION_INFO_V1(pass_los_time);
|
||||
PG_FUNCTION_INFO_V1(pass_max_elevation);
|
||||
PG_FUNCTION_INFO_V1(pass_aos_azimuth);
|
||||
PG_FUNCTION_INFO_V1(pass_los_azimuth);
|
||||
PG_FUNCTION_INFO_V1(pass_duration);
|
||||
PG_FUNCTION_INFO_V1(next_pass);
|
||||
PG_FUNCTION_INFO_V1(predict_passes);
|
||||
PG_FUNCTION_INFO_V1(pass_visible);
|
||||
|
||||
#define DEG_TO_RAD (M_PI / 180.0)
|
||||
#define RAD_TO_DEG (180.0 / M_PI)
|
||||
|
||||
#define COARSE_STEP_JD (30.0 / 86400.0) /* 30 seconds */
|
||||
#define BISECT_TOL_JD (0.1 / 86400.0) /* 0.1 second */
|
||||
#define MIN_PASS_DURATION_JD (10.0 / 86400.0) /* 10 seconds */
|
||||
#define DEFAULT_WINDOW_DAYS 7.0
|
||||
#define POST_LOS_GAP_JD (60.0 / 86400.0) /* 1 minute */
|
||||
#define TERNARY_ITERATIONS 50
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* Static helpers -- duplicated from coord_funcs.c because both
|
||||
* files need them and they're too small to warrant a shared module.
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/*
|
||||
* Convert pg_tle to sat_code's tle_t. No unit conversion needed --
|
||||
* both store radians, radians/min, Julian dates.
|
||||
*/
|
||||
static void
|
||||
pg_tle_to_sat_code(const pg_tle *src, tle_t *dst)
|
||||
{
|
||||
memset(dst, 0, sizeof(tle_t));
|
||||
|
||||
dst->epoch = src->epoch;
|
||||
dst->xincl = src->inclination;
|
||||
dst->xnodeo = src->raan;
|
||||
dst->eo = src->eccentricity;
|
||||
dst->omegao = src->arg_perigee;
|
||||
dst->xmo = src->mean_anomaly;
|
||||
dst->xno = src->mean_motion;
|
||||
dst->xndt2o = src->mean_motion_dot;
|
||||
dst->xndd6o = src->mean_motion_ddot;
|
||||
dst->bstar = src->bstar;
|
||||
|
||||
dst->norad_number = src->norad_id;
|
||||
dst->bulletin_number = src->elset_num;
|
||||
dst->revolution_number = src->rev_num;
|
||||
dst->classification = src->classification;
|
||||
dst->ephemeris_type = src->ephemeris_type;
|
||||
|
||||
memcpy(dst->intl_desig, src->intl_desig, 9);
|
||||
}
|
||||
|
||||
/*
|
||||
* Propagate TLE to a Julian date. Returns sat_code error code.
|
||||
* pos[3] in km (TEME), vel[3] in km/min (TEME).
|
||||
*/
|
||||
static int
|
||||
do_propagate(const pg_tle *tle, double jd, double *pos, double *vel)
|
||||
{
|
||||
tle_t sat;
|
||||
double *params;
|
||||
int is_deep;
|
||||
int err;
|
||||
double tsince;
|
||||
|
||||
pg_tle_to_sat_code(tle, &sat);
|
||||
|
||||
is_deep = select_ephemeris(&sat);
|
||||
if (is_deep < 0)
|
||||
return -99; /* invalid TLE */
|
||||
|
||||
tsince = jd_to_minutes_since_epoch(jd, sat.epoch);
|
||||
|
||||
params = palloc(sizeof(double) * N_SAT_PARAMS);
|
||||
|
||||
if (is_deep)
|
||||
{
|
||||
SDP4_init(params, &sat);
|
||||
err = SDP4(tsince, &sat, params, pos, vel);
|
||||
}
|
||||
else
|
||||
{
|
||||
SGP4_init(params, &sat);
|
||||
err = SGP4(tsince, &sat, params, pos, vel);
|
||||
}
|
||||
|
||||
pfree(params);
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
/*
|
||||
* Greenwich Mean Sidereal Time from Julian date.
|
||||
* Returns GMST in radians. Uses the IAU 1982 formula matching
|
||||
* the low-precision model inside SGP4.
|
||||
*/
|
||||
static double
|
||||
gmst_from_jd(double jd)
|
||||
{
|
||||
double ut1, tu;
|
||||
double gmst;
|
||||
|
||||
/* Julian centuries of UT1 from J2000.0 */
|
||||
ut1 = jd - J2000_JD;
|
||||
tu = ut1 / 36525.0;
|
||||
|
||||
/* GMST in seconds at 0h UT1, then add fractional day rotation */
|
||||
gmst = 67310.54841
|
||||
+ (876600.0 * 3600.0 + 8640184.812866) * tu
|
||||
+ 0.093104 * tu * tu
|
||||
- 6.2e-6 * tu * tu * tu;
|
||||
|
||||
/* Convert seconds to radians, mod 2pi */
|
||||
gmst = fmod(gmst * M_PI / 43200.0, 2.0 * M_PI);
|
||||
if (gmst < 0.0)
|
||||
gmst += 2.0 * M_PI;
|
||||
|
||||
return gmst;
|
||||
}
|
||||
|
||||
/*
|
||||
* Rotate TEME position (and optionally velocity) to ECEF via GMST.
|
||||
* Only the z-axis rotation matters for SGP4's simplified nutation.
|
||||
*/
|
||||
static void
|
||||
teme_to_ecef(const double *pos_teme, const double *vel_teme,
|
||||
double gmst, double *pos_ecef, double *vel_ecef)
|
||||
{
|
||||
double cg = cos(gmst);
|
||||
double sg = sin(gmst);
|
||||
|
||||
pos_ecef[0] = cg * pos_teme[0] + sg * pos_teme[1];
|
||||
pos_ecef[1] = -sg * pos_teme[0] + cg * pos_teme[1];
|
||||
pos_ecef[2] = pos_teme[2];
|
||||
|
||||
if (vel_teme && vel_ecef)
|
||||
{
|
||||
/* Earth rotation rate, rad/min */
|
||||
double omega_e = 7.29211514670698e-5 * 60.0;
|
||||
|
||||
vel_ecef[0] = cg * vel_teme[0] + sg * vel_teme[1]
|
||||
+ omega_e * pos_ecef[1];
|
||||
vel_ecef[1] = -sg * vel_teme[0] + cg * vel_teme[1]
|
||||
- omega_e * pos_ecef[0];
|
||||
vel_ecef[2] = vel_teme[2];
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Observer geodetic (radians, meters) to ECEF (km).
|
||||
* Uses WGS-84 ellipsoid for ground station positioning.
|
||||
*/
|
||||
static void
|
||||
observer_to_ecef(const pg_observer *obs, double *ecef)
|
||||
{
|
||||
double sinlat = sin(obs->lat);
|
||||
double coslat = cos(obs->lat);
|
||||
double sinlon = sin(obs->lon);
|
||||
double coslon = cos(obs->lon);
|
||||
double N; /* radius of curvature in the prime vertical */
|
||||
double alt_km = obs->alt_m / 1000.0;
|
||||
|
||||
N = WGS84_A / sqrt(1.0 - WGS84_E2 * sinlat * sinlat);
|
||||
|
||||
ecef[0] = (N + alt_km) * coslat * coslon;
|
||||
ecef[1] = (N + alt_km) * coslat * sinlon;
|
||||
ecef[2] = (N * (1.0 - WGS84_E2) + alt_km) * sinlat;
|
||||
}
|
||||
|
||||
/*
|
||||
* Compute topocentric azimuth, elevation, and range from ECEF positions.
|
||||
* Azimuth: 0=N, 90=E, 180=S, 270=W (radians).
|
||||
* Elevation: positive above horizon (radians).
|
||||
*/
|
||||
static void
|
||||
ecef_to_topocentric(const double *sat_ecef, const double *obs_ecef,
|
||||
double obs_lat, double obs_lon,
|
||||
double *az, double *el, double *range_km)
|
||||
{
|
||||
double dx, dy, dz;
|
||||
double sinlat, coslat, sinlon, coslon;
|
||||
double south, east, up;
|
||||
double rng;
|
||||
|
||||
dx = sat_ecef[0] - obs_ecef[0];
|
||||
dy = sat_ecef[1] - obs_ecef[1];
|
||||
dz = sat_ecef[2] - obs_ecef[2];
|
||||
|
||||
sinlat = sin(obs_lat);
|
||||
coslat = cos(obs_lat);
|
||||
sinlon = sin(obs_lon);
|
||||
coslon = cos(obs_lon);
|
||||
|
||||
/* Rotate difference vector into SEZ (south-east-zenith) frame */
|
||||
south = sinlat * coslon * dx + sinlat * sinlon * dy - coslat * dz;
|
||||
east = -sinlon * dx + coslon * dy;
|
||||
up = coslat * coslon * dx + coslat * sinlon * dy + sinlat * dz;
|
||||
|
||||
rng = sqrt(dx * dx + dy * dy + dz * dz);
|
||||
|
||||
*range_km = rng;
|
||||
*el = asin(up / rng);
|
||||
|
||||
/* Azimuth from north, measured clockwise */
|
||||
*az = atan2(east, -south);
|
||||
if (*az < 0.0)
|
||||
*az += 2.0 * M_PI;
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* elevation_at_jd -- the function we bisect on
|
||||
*
|
||||
* Returns satellite elevation in radians relative to the observer's
|
||||
* local horizon. Negative means below horizon. On hard propagation
|
||||
* errors, returns -pi (well below any real horizon).
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
static double
|
||||
elevation_at_jd(const pg_tle *tle, const pg_observer *obs,
|
||||
double jd, double *az_out)
|
||||
{
|
||||
double pos[3], vel[3];
|
||||
double gmst, pos_ecef[3], obs_ecef[3];
|
||||
double az, el, range_km;
|
||||
int err;
|
||||
|
||||
err = do_propagate(tle, jd, pos, vel);
|
||||
|
||||
/* On hard errors, return well below horizon */
|
||||
if (err == SXPX_ERR_NEARLY_PARABOLIC ||
|
||||
err == SXPX_ERR_NEGATIVE_MAJOR_AXIS ||
|
||||
err == SXPX_ERR_NEGATIVE_XN ||
|
||||
err == SXPX_ERR_CONVERGENCE_FAIL ||
|
||||
err == -99)
|
||||
return -M_PI;
|
||||
|
||||
gmst = gmst_from_jd(jd);
|
||||
teme_to_ecef(pos, NULL, gmst, pos_ecef, NULL);
|
||||
observer_to_ecef(obs, obs_ecef);
|
||||
ecef_to_topocentric(pos_ecef, obs_ecef, obs->lat, obs->lon,
|
||||
&az, &el, &range_km);
|
||||
|
||||
if (az_out)
|
||||
*az_out = az;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* find_next_pass -- core pass-finding algorithm
|
||||
*
|
||||
* Scans from start_jd to stop_jd looking for an elevation zero
|
||||
* crossing (rising edge). When found, bisects to refine AOS,
|
||||
* scans forward to find LOS, bisects to refine LOS, then uses
|
||||
* ternary search to locate peak elevation.
|
||||
*
|
||||
* Passes below min_el_rad are silently skipped; scanning resumes
|
||||
* from their LOS.
|
||||
*
|
||||
* Returns true if a qualifying pass was found.
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
static bool
|
||||
find_next_pass(const pg_tle *tle, const pg_observer *obs,
|
||||
double start_jd, double stop_jd,
|
||||
double min_el_rad,
|
||||
double *aos_jd, double *los_jd,
|
||||
double *max_el_jd, double *max_el,
|
||||
double *aos_az, double *los_az)
|
||||
{
|
||||
double jd = start_jd;
|
||||
double prev_el, curr_el;
|
||||
double az;
|
||||
|
||||
prev_el = elevation_at_jd(tle, obs, jd, NULL);
|
||||
|
||||
while (jd < stop_jd)
|
||||
{
|
||||
jd += COARSE_STEP_JD;
|
||||
if (jd > stop_jd)
|
||||
jd = stop_jd;
|
||||
|
||||
curr_el = elevation_at_jd(tle, obs, jd, NULL);
|
||||
|
||||
/* Rising edge: was below horizon, now above */
|
||||
if (prev_el <= 0.0 && curr_el > 0.0)
|
||||
{
|
||||
double lo, hi, mid;
|
||||
double peak_el;
|
||||
double scan_jd, scan_el;
|
||||
|
||||
/* Bisect to find AOS */
|
||||
lo = jd - COARSE_STEP_JD;
|
||||
hi = jd;
|
||||
while (hi - lo > BISECT_TOL_JD)
|
||||
{
|
||||
mid = (lo + hi) / 2.0;
|
||||
if (elevation_at_jd(tle, obs, mid, NULL) > 0.0)
|
||||
hi = mid;
|
||||
else
|
||||
lo = mid;
|
||||
}
|
||||
*aos_jd = (lo + hi) / 2.0;
|
||||
elevation_at_jd(tle, obs, *aos_jd, &az);
|
||||
*aos_az = az;
|
||||
|
||||
/* Scan forward to find LOS */
|
||||
scan_jd = *aos_jd;
|
||||
peak_el = 0.0;
|
||||
|
||||
while (scan_jd < stop_jd)
|
||||
{
|
||||
scan_jd += COARSE_STEP_JD;
|
||||
scan_el = elevation_at_jd(tle, obs, scan_jd, NULL);
|
||||
|
||||
if (scan_el > peak_el)
|
||||
peak_el = scan_el;
|
||||
|
||||
if (scan_el <= 0.0)
|
||||
{
|
||||
/* Bisect to find LOS */
|
||||
lo = scan_jd - COARSE_STEP_JD;
|
||||
hi = scan_jd;
|
||||
while (hi - lo > BISECT_TOL_JD)
|
||||
{
|
||||
mid = (lo + hi) / 2.0;
|
||||
if (elevation_at_jd(tle, obs, mid, NULL) > 0.0)
|
||||
lo = mid;
|
||||
else
|
||||
hi = mid;
|
||||
}
|
||||
*los_jd = (lo + hi) / 2.0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ran past the search window without finding LOS -- clip */
|
||||
if (scan_jd >= stop_jd)
|
||||
*los_jd = stop_jd;
|
||||
|
||||
/* Skip degenerate passes */
|
||||
if (*los_jd - *aos_jd < MIN_PASS_DURATION_JD)
|
||||
{
|
||||
jd = *los_jd;
|
||||
prev_el = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Refine peak elevation with ternary search */
|
||||
lo = *aos_jd;
|
||||
hi = *los_jd;
|
||||
for (int i = 0; i < TERNARY_ITERATIONS; i++)
|
||||
{
|
||||
double m1 = lo + (hi - lo) / 3.0;
|
||||
double m2 = hi - (hi - lo) / 3.0;
|
||||
|
||||
if (elevation_at_jd(tle, obs, m1, NULL) <
|
||||
elevation_at_jd(tle, obs, m2, NULL))
|
||||
lo = m1;
|
||||
else
|
||||
hi = m2;
|
||||
}
|
||||
*max_el_jd = (lo + hi) / 2.0;
|
||||
*max_el = elevation_at_jd(tle, obs, *max_el_jd, NULL);
|
||||
|
||||
elevation_at_jd(tle, obs, *los_jd, &az);
|
||||
*los_az = az;
|
||||
|
||||
/* Below the caller's minimum elevation threshold -- skip */
|
||||
if (*max_el < min_el_rad)
|
||||
{
|
||||
jd = *los_jd;
|
||||
prev_el = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
prev_el = curr_el;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* pass_event type I/O
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/*
|
||||
* pass_event_in -- parse text to pg_pass_event
|
||||
*
|
||||
* Format: (aos_ts,maxel_ts,los_ts,max_el_deg,aos_az_deg,los_az_deg)
|
||||
* Timestamps are raw int64 microseconds (PG internal representation).
|
||||
*/
|
||||
Datum
|
||||
pass_event_in(PG_FUNCTION_ARGS)
|
||||
{
|
||||
char *str = PG_GETARG_CSTRING(0);
|
||||
pg_pass_event *result;
|
||||
long long aos_raw, maxel_raw, los_raw;
|
||||
double max_el_deg, aos_az_deg, los_az_deg;
|
||||
int nfields;
|
||||
|
||||
result = (pg_pass_event *) palloc(sizeof(pg_pass_event));
|
||||
|
||||
nfields = sscanf(str, " ( %lld , %lld , %lld , %lf , %lf , %lf )",
|
||||
&aos_raw, &maxel_raw, &los_raw,
|
||||
&max_el_deg, &aos_az_deg, &los_az_deg);
|
||||
|
||||
if (nfields != 6)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
|
||||
errmsg("invalid input syntax for type pass_event: \"%s\"", str),
|
||||
errhint("Expected (aos_usec,maxel_usec,los_usec,max_el_deg,aos_az_deg,los_az_deg).")));
|
||||
|
||||
result->aos_time = (int64) aos_raw;
|
||||
result->max_el_time = (int64) maxel_raw;
|
||||
result->los_time = (int64) los_raw;
|
||||
result->max_elevation = max_el_deg;
|
||||
result->aos_azimuth = aos_az_deg;
|
||||
result->los_azimuth = los_az_deg;
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* pass_event_out -- pg_pass_event to human-readable text
|
||||
*
|
||||
* Format: (2024-01-01 12:00:00+00,2024-01-01 12:05:00+00,2024-01-01 12:10:00+00,45.2,180.0,350.0)
|
||||
* Timestamps formatted via DirectFunctionCall1(timestamptz_out, ...).
|
||||
*/
|
||||
Datum
|
||||
pass_event_out(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_pass_event *pe = (pg_pass_event *) PG_GETARG_POINTER(0);
|
||||
char *aos_str;
|
||||
char *maxel_str;
|
||||
char *los_str;
|
||||
|
||||
aos_str = DatumGetCString(
|
||||
DirectFunctionCall1(timestamptz_out,
|
||||
Int64GetDatum(pe->aos_time)));
|
||||
maxel_str = DatumGetCString(
|
||||
DirectFunctionCall1(timestamptz_out,
|
||||
Int64GetDatum(pe->max_el_time)));
|
||||
los_str = DatumGetCString(
|
||||
DirectFunctionCall1(timestamptz_out,
|
||||
Int64GetDatum(pe->los_time)));
|
||||
|
||||
PG_RETURN_CSTRING(psprintf("(%s,%s,%s,%.1f,%.1f,%.1f)",
|
||||
aos_str, maxel_str, los_str,
|
||||
pe->max_elevation,
|
||||
pe->aos_azimuth,
|
||||
pe->los_azimuth));
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* pass_event_recv -- binary input (3 int64 + 3 float8 = 48 bytes)
|
||||
*/
|
||||
Datum
|
||||
pass_event_recv(PG_FUNCTION_ARGS)
|
||||
{
|
||||
StringInfo buf = (StringInfo) PG_GETARG_POINTER(0);
|
||||
pg_pass_event *result;
|
||||
|
||||
result = (pg_pass_event *) palloc(sizeof(pg_pass_event));
|
||||
result->aos_time = pq_getmsgint64(buf);
|
||||
result->max_el_time = pq_getmsgint64(buf);
|
||||
result->los_time = pq_getmsgint64(buf);
|
||||
result->max_elevation = pq_getmsgfloat8(buf);
|
||||
result->aos_azimuth = pq_getmsgfloat8(buf);
|
||||
result->los_azimuth = pq_getmsgfloat8(buf);
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* pass_event_send -- binary output
|
||||
*/
|
||||
Datum
|
||||
pass_event_send(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_pass_event *pe = (pg_pass_event *) PG_GETARG_POINTER(0);
|
||||
StringInfoData buf;
|
||||
|
||||
pq_begintypsend(&buf);
|
||||
pq_sendint64(&buf, pe->aos_time);
|
||||
pq_sendint64(&buf, pe->max_el_time);
|
||||
pq_sendint64(&buf, pe->los_time);
|
||||
pq_sendfloat8(&buf, pe->max_elevation);
|
||||
pq_sendfloat8(&buf, pe->aos_azimuth);
|
||||
pq_sendfloat8(&buf, pe->los_azimuth);
|
||||
|
||||
PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* pass_event accessor functions
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
|
||||
Datum
|
||||
pass_aos_time(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_pass_event *pe = (pg_pass_event *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_TIMESTAMPTZ(pe->aos_time);
|
||||
}
|
||||
|
||||
Datum
|
||||
pass_max_el_time(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_pass_event *pe = (pg_pass_event *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_TIMESTAMPTZ(pe->max_el_time);
|
||||
}
|
||||
|
||||
Datum
|
||||
pass_los_time(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_pass_event *pe = (pg_pass_event *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_TIMESTAMPTZ(pe->los_time);
|
||||
}
|
||||
|
||||
Datum
|
||||
pass_max_elevation(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_pass_event *pe = (pg_pass_event *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_FLOAT8(pe->max_elevation);
|
||||
}
|
||||
|
||||
Datum
|
||||
pass_aos_azimuth(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_pass_event *pe = (pg_pass_event *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_FLOAT8(pe->aos_azimuth);
|
||||
}
|
||||
|
||||
Datum
|
||||
pass_los_azimuth(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_pass_event *pe = (pg_pass_event *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_FLOAT8(pe->los_azimuth);
|
||||
}
|
||||
|
||||
/*
|
||||
* pass_duration -- time from AOS to LOS as a PostgreSQL interval
|
||||
*/
|
||||
Datum
|
||||
pass_duration(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_pass_event *pe = (pg_pass_event *) PG_GETARG_POINTER(0);
|
||||
Interval *result;
|
||||
|
||||
result = (Interval *) palloc(sizeof(Interval));
|
||||
result->time = pe->los_time - pe->aos_time; /* microseconds */
|
||||
result->day = 0;
|
||||
result->month = 0;
|
||||
|
||||
PG_RETURN_INTERVAL_P(result);
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* next_pass(tle, observer, from_time) -> pass_event
|
||||
*
|
||||
* Finds the next pass above the horizon starting from from_time.
|
||||
* Searches a 7-day window. Returns NULL if no pass is found.
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
Datum
|
||||
next_pass(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(1);
|
||||
int64 from_ts = PG_GETARG_INT64(2);
|
||||
|
||||
double start_jd, stop_jd;
|
||||
double aos_jd, los_jd, max_el_jd, max_el;
|
||||
double aos_az, los_az;
|
||||
pg_pass_event *result;
|
||||
|
||||
start_jd = timestamptz_to_jd(from_ts);
|
||||
stop_jd = start_jd + DEFAULT_WINDOW_DAYS;
|
||||
|
||||
if (!find_next_pass(tle, obs, start_jd, stop_jd,
|
||||
0.0, /* minimum elevation = 0 degrees */
|
||||
&aos_jd, &los_jd,
|
||||
&max_el_jd, &max_el,
|
||||
&aos_az, &los_az))
|
||||
PG_RETURN_NULL();
|
||||
|
||||
result = (pg_pass_event *) palloc(sizeof(pg_pass_event));
|
||||
result->aos_time = jd_to_timestamptz(aos_jd);
|
||||
result->max_el_time = jd_to_timestamptz(max_el_jd);
|
||||
result->los_time = jd_to_timestamptz(los_jd);
|
||||
result->max_elevation = max_el * RAD_TO_DEG;
|
||||
result->aos_azimuth = aos_az * RAD_TO_DEG;
|
||||
result->los_azimuth = los_az * RAD_TO_DEG;
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* predict_passes(tle, observer, start, stop [, min_elevation])
|
||||
* -> SETOF pass_event
|
||||
*
|
||||
* Returns all passes in the given time window. Optional 5th arg
|
||||
* sets the minimum peak elevation filter in degrees (default 0).
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
|
||||
typedef struct
|
||||
{
|
||||
pg_tle tle;
|
||||
pg_observer obs;
|
||||
double current_jd;
|
||||
double stop_jd;
|
||||
double min_el_rad;
|
||||
} predict_passes_ctx;
|
||||
|
||||
Datum
|
||||
predict_passes(PG_FUNCTION_ARGS)
|
||||
{
|
||||
FuncCallContext *funcctx;
|
||||
predict_passes_ctx *ctx;
|
||||
|
||||
if (SRF_IS_FIRSTCALL())
|
||||
{
|
||||
MemoryContext oldctx;
|
||||
pg_tle *tle;
|
||||
pg_observer *obs;
|
||||
int64 start_ts;
|
||||
int64 stop_ts;
|
||||
double min_el_deg;
|
||||
|
||||
funcctx = SRF_FIRSTCALL_INIT();
|
||||
oldctx = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
|
||||
|
||||
tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
obs = (pg_observer *) PG_GETARG_POINTER(1);
|
||||
start_ts = PG_GETARG_INT64(2);
|
||||
stop_ts = PG_GETARG_INT64(3);
|
||||
|
||||
min_el_deg = (PG_NARGS() > 4 && !PG_ARGISNULL(4))
|
||||
? PG_GETARG_FLOAT8(4)
|
||||
: 0.0;
|
||||
|
||||
if (stop_ts <= start_ts)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
|
||||
errmsg("stop time must be after start time")));
|
||||
|
||||
ctx = (predict_passes_ctx *)
|
||||
palloc0(sizeof(predict_passes_ctx));
|
||||
|
||||
memcpy(&ctx->tle, tle, sizeof(pg_tle));
|
||||
memcpy(&ctx->obs, obs, sizeof(pg_observer));
|
||||
ctx->current_jd = timestamptz_to_jd(start_ts);
|
||||
ctx->stop_jd = timestamptz_to_jd(stop_ts);
|
||||
ctx->min_el_rad = min_el_deg * DEG_TO_RAD;
|
||||
|
||||
funcctx->user_fctx = ctx;
|
||||
|
||||
MemoryContextSwitchTo(oldctx);
|
||||
}
|
||||
|
||||
funcctx = SRF_PERCALL_SETUP();
|
||||
ctx = (predict_passes_ctx *) funcctx->user_fctx;
|
||||
|
||||
{
|
||||
double aos_jd, los_jd, max_el_jd, max_el;
|
||||
double aos_az, los_az;
|
||||
pg_pass_event *result;
|
||||
|
||||
if (!find_next_pass(&ctx->tle, &ctx->obs,
|
||||
ctx->current_jd, ctx->stop_jd,
|
||||
ctx->min_el_rad,
|
||||
&aos_jd, &los_jd,
|
||||
&max_el_jd, &max_el,
|
||||
&aos_az, &los_az))
|
||||
SRF_RETURN_DONE(funcctx);
|
||||
|
||||
result = (pg_pass_event *) palloc(sizeof(pg_pass_event));
|
||||
result->aos_time = jd_to_timestamptz(aos_jd);
|
||||
result->max_el_time = jd_to_timestamptz(max_el_jd);
|
||||
result->los_time = jd_to_timestamptz(los_jd);
|
||||
result->max_elevation = max_el * RAD_TO_DEG;
|
||||
result->aos_azimuth = aos_az * RAD_TO_DEG;
|
||||
result->los_azimuth = los_az * RAD_TO_DEG;
|
||||
|
||||
/* Advance past this pass before the next call */
|
||||
ctx->current_jd = los_jd + POST_LOS_GAP_JD;
|
||||
|
||||
SRF_RETURN_NEXT(funcctx, PointerGetDatum(result));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* pass_visible(tle, observer, start, stop) -> bool
|
||||
*
|
||||
* Returns true if any pass crosses above the horizon in the window.
|
||||
* Cheaper than predict_passes when you only need a yes/no answer.
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
Datum
|
||||
pass_visible(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(1);
|
||||
int64 start_ts = PG_GETARG_INT64(2);
|
||||
int64 stop_ts = PG_GETARG_INT64(3);
|
||||
|
||||
double start_jd, stop_jd;
|
||||
double aos_jd, los_jd, max_el_jd, max_el;
|
||||
double aos_az, los_az;
|
||||
|
||||
start_jd = timestamptz_to_jd(start_ts);
|
||||
stop_jd = timestamptz_to_jd(stop_ts);
|
||||
|
||||
PG_RETURN_BOOL(find_next_pass(tle, obs, start_jd, stop_jd,
|
||||
0.0,
|
||||
&aos_jd, &los_jd,
|
||||
&max_el_jd, &max_el,
|
||||
&aos_az, &los_az));
|
||||
}
|
||||
12
src/pg_orbit.c
Normal file
12
src/pg_orbit.c
Normal file
@ -0,0 +1,12 @@
|
||||
/*
|
||||
* pg_orbit.c -- Extension entry point
|
||||
*
|
||||
* PostgreSQL extension for orbital mechanics.
|
||||
* Provides TLE, ECI, geodetic, topocentric, observer, and pass_event types
|
||||
* with SGP4/SDP4 propagation, coordinate transforms, and pass prediction.
|
||||
*/
|
||||
|
||||
#include "postgres.h"
|
||||
#include "fmgr.h"
|
||||
|
||||
PG_MODULE_MAGIC;
|
||||
381
src/sgp4_funcs.c
Normal file
381
src/sgp4_funcs.c
Normal file
@ -0,0 +1,381 @@
|
||||
/*
|
||||
* sgp4_funcs.c -- SGP4/SDP4 propagation functions for pg_orbit
|
||||
*
|
||||
* Wraps Bill Gray's sat_code implementation. Near-earth objects
|
||||
* use SGP4, deep-space objects use SDP4. The choice is automatic
|
||||
* based on orbital period (threshold: 225 minutes).
|
||||
*
|
||||
* All propagation uses WGS-72 constants internally (via sat_code).
|
||||
* Velocities are converted from km/min to km/s on output.
|
||||
*/
|
||||
|
||||
#include "postgres.h"
|
||||
#include "fmgr.h"
|
||||
#include "funcapi.h"
|
||||
#include "utils/timestamp.h"
|
||||
#include "norad.h"
|
||||
#include "types.h"
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
|
||||
PG_FUNCTION_INFO_V1(sgp4_propagate);
|
||||
PG_FUNCTION_INFO_V1(sgp4_propagate_series);
|
||||
PG_FUNCTION_INFO_V1(tle_distance);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* TLE struct conversion helpers
|
||||
*
|
||||
* pg_tle (our type) and tle_t (sat_code) store the same data in
|
||||
* different field layouts. These copy between them without any
|
||||
* unit conversion -- both use radians, radians/min, Julian dates.
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
static void
|
||||
pg_tle_to_sat_code(const pg_tle *src, tle_t *dst)
|
||||
{
|
||||
memset(dst, 0, sizeof(tle_t));
|
||||
|
||||
dst->epoch = src->epoch;
|
||||
dst->xincl = src->inclination;
|
||||
dst->xnodeo = src->raan;
|
||||
dst->eo = src->eccentricity;
|
||||
dst->omegao = src->arg_perigee;
|
||||
dst->xmo = src->mean_anomaly;
|
||||
dst->xno = src->mean_motion;
|
||||
dst->xndt2o = src->mean_motion_dot;
|
||||
dst->xndd6o = src->mean_motion_ddot;
|
||||
dst->bstar = src->bstar;
|
||||
|
||||
dst->norad_number = src->norad_id;
|
||||
dst->bulletin_number = src->elset_num;
|
||||
dst->revolution_number = src->rev_num;
|
||||
dst->classification = src->classification;
|
||||
dst->ephemeris_type = src->ephemeris_type;
|
||||
|
||||
memcpy(dst->intl_desig, src->intl_desig, 9);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* propagate_tle -- shared propagation logic
|
||||
*
|
||||
* Initializes the appropriate model, propagates to tsince minutes,
|
||||
* and fills pos[3] (km) and vel[3] (km/min). Returns the sat_code
|
||||
* error code (0 on success, negative on error/warning).
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
static int
|
||||
propagate_tle(const tle_t *sat, double tsince, double *pos, double *vel)
|
||||
{
|
||||
double *params;
|
||||
int is_deep;
|
||||
int err;
|
||||
|
||||
is_deep = select_ephemeris(sat);
|
||||
if (is_deep < 0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_DATA_EXCEPTION),
|
||||
errmsg("invalid TLE for NORAD %d: "
|
||||
"mean motion or eccentricity out of range",
|
||||
sat->norad_number)));
|
||||
|
||||
params = palloc(sizeof(double) * N_SAT_PARAMS);
|
||||
|
||||
if (is_deep)
|
||||
{
|
||||
SDP4_init(params, sat);
|
||||
err = SDP4(tsince, sat, params, pos, vel);
|
||||
}
|
||||
else
|
||||
{
|
||||
SGP4_init(params, sat);
|
||||
err = SGP4(tsince, sat, params, pos, vel);
|
||||
}
|
||||
|
||||
pfree(params);
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
/* Map sat_code error codes to human-readable messages */
|
||||
static const char *
|
||||
sxpx_error_msg(int err)
|
||||
{
|
||||
switch (err)
|
||||
{
|
||||
case SXPX_ERR_NEARLY_PARABOLIC:
|
||||
return "nearly parabolic orbit";
|
||||
case SXPX_ERR_NEGATIVE_MAJOR_AXIS:
|
||||
return "negative semi-major axis (decayed)";
|
||||
case SXPX_WARN_ORBIT_WITHIN_EARTH:
|
||||
return "orbit center within Earth";
|
||||
case SXPX_WARN_PERIGEE_WITHIN_EARTH:
|
||||
return "perigee within Earth";
|
||||
case SXPX_ERR_NEGATIVE_XN:
|
||||
return "negative mean motion";
|
||||
case SXPX_ERR_CONVERGENCE_FAIL:
|
||||
return "Kepler equation convergence failure";
|
||||
default:
|
||||
return "unknown propagation error";
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Check propagation result. Warnings (-3, -4) are tolerated --
|
||||
* the position is still mathematically valid. Hard errors (<= -5
|
||||
* or -1, -2) abort the query.
|
||||
*/
|
||||
static void
|
||||
check_propagation_result(int err, int norad_number, double tsince)
|
||||
{
|
||||
if (err == 0)
|
||||
return;
|
||||
|
||||
/* Perigee/orbit within Earth: valid state vector, just unusual */
|
||||
if (err == SXPX_WARN_ORBIT_WITHIN_EARTH ||
|
||||
err == SXPX_WARN_PERIGEE_WITHIN_EARTH)
|
||||
{
|
||||
ereport(NOTICE,
|
||||
(errmsg("NORAD %d at t+%.1f min: %s",
|
||||
norad_number, tsince, sxpx_error_msg(err))));
|
||||
return;
|
||||
}
|
||||
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_DATA_EXCEPTION),
|
||||
errmsg("SGP4/SDP4 propagation failed for NORAD %d at t+%.1f min: %s",
|
||||
norad_number, tsince, sxpx_error_msg(err))));
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* sgp4_propagate(tle, timestamptz) -> eci_position
|
||||
*
|
||||
* Propagate a TLE to a single point in time.
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
Datum
|
||||
sgp4_propagate(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
int64 ts = PG_GETARG_INT64(1);
|
||||
|
||||
tle_t sat;
|
||||
double jd;
|
||||
double tsince;
|
||||
double pos[3];
|
||||
double vel[3];
|
||||
int err;
|
||||
pg_eci *result;
|
||||
|
||||
pg_tle_to_sat_code(tle, &sat);
|
||||
|
||||
jd = timestamptz_to_jd(ts);
|
||||
tsince = jd_to_minutes_since_epoch(jd, sat.epoch);
|
||||
|
||||
err = propagate_tle(&sat, tsince, pos, vel);
|
||||
check_propagation_result(err, sat.norad_number, tsince);
|
||||
|
||||
result = (pg_eci *) palloc(sizeof(pg_eci));
|
||||
result->x = pos[0];
|
||||
result->y = pos[1];
|
||||
result->z = pos[2];
|
||||
result->vx = vel[0] / 60.0; /* km/min -> km/s */
|
||||
result->vy = vel[1] / 60.0;
|
||||
result->vz = vel[2] / 60.0;
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* sgp4_propagate_series(tle, start, stop, step) -> SETOF (timestamptz, eci_position)
|
||||
*
|
||||
* Generates a time series of propagated positions. The model is
|
||||
* initialized once and reused for every step.
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
|
||||
typedef struct
|
||||
{
|
||||
tle_t sat;
|
||||
double params[N_SAT_PARAMS];
|
||||
int is_deep;
|
||||
double epoch_jd;
|
||||
int64 start_ts;
|
||||
int64 step_usec;
|
||||
} propagate_series_ctx;
|
||||
|
||||
Datum
|
||||
sgp4_propagate_series(PG_FUNCTION_ARGS)
|
||||
{
|
||||
FuncCallContext *funcctx;
|
||||
propagate_series_ctx *ctx;
|
||||
|
||||
if (SRF_IS_FIRSTCALL())
|
||||
{
|
||||
MemoryContext oldctx;
|
||||
pg_tle *tle;
|
||||
int64 start_ts;
|
||||
int64 stop_ts;
|
||||
Interval *step;
|
||||
int64 step_usec;
|
||||
int64 span;
|
||||
uint64 nsteps;
|
||||
TupleDesc tupdesc;
|
||||
|
||||
funcctx = SRF_FIRSTCALL_INIT();
|
||||
oldctx = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
|
||||
|
||||
tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
start_ts = PG_GETARG_INT64(1);
|
||||
stop_ts = PG_GETARG_INT64(2);
|
||||
step = PG_GETARG_INTERVAL_P(3);
|
||||
|
||||
/*
|
||||
* Convert interval to microseconds. We only use the time
|
||||
* component -- month/day fields would need calendar logic
|
||||
* that doesn't belong in a propagation step.
|
||||
*/
|
||||
step_usec = step->time
|
||||
+ (int64) step->day * USECS_PER_DAY
|
||||
+ (int64) step->month * (30 * USECS_PER_DAY);
|
||||
|
||||
if (step_usec <= 0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
|
||||
errmsg("step interval must be positive")));
|
||||
|
||||
if (stop_ts < start_ts)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
|
||||
errmsg("stop time must be >= start time")));
|
||||
|
||||
span = stop_ts - start_ts;
|
||||
nsteps = (uint64)(span / step_usec) + 1;
|
||||
|
||||
/* Allocate persistent context for the SRF */
|
||||
ctx = (propagate_series_ctx *)
|
||||
palloc0(sizeof(propagate_series_ctx));
|
||||
|
||||
pg_tle_to_sat_code(tle, &ctx->sat);
|
||||
|
||||
ctx->is_deep = select_ephemeris(&ctx->sat);
|
||||
if (ctx->is_deep < 0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_DATA_EXCEPTION),
|
||||
errmsg("invalid TLE for NORAD %d: "
|
||||
"mean motion or eccentricity out of range",
|
||||
ctx->sat.norad_number)));
|
||||
|
||||
/* Initialize the model once */
|
||||
if (ctx->is_deep)
|
||||
SDP4_init(ctx->params, &ctx->sat);
|
||||
else
|
||||
SGP4_init(ctx->params, &ctx->sat);
|
||||
|
||||
ctx->epoch_jd = ctx->sat.epoch;
|
||||
ctx->start_ts = start_ts;
|
||||
ctx->step_usec = step_usec;
|
||||
|
||||
funcctx->max_calls = nsteps;
|
||||
funcctx->user_fctx = ctx;
|
||||
|
||||
/* Build the output tuple descriptor: (timestamptz, eci_position) */
|
||||
tupdesc = CreateTemplateTupleDesc(7);
|
||||
TupleDescInitEntry(tupdesc, 1, "t", TIMESTAMPTZOID, -1, 0);
|
||||
TupleDescInitEntry(tupdesc, 2, "x", FLOAT8OID, -1, 0);
|
||||
TupleDescInitEntry(tupdesc, 3, "y", FLOAT8OID, -1, 0);
|
||||
TupleDescInitEntry(tupdesc, 4, "z", FLOAT8OID, -1, 0);
|
||||
TupleDescInitEntry(tupdesc, 5, "vx", FLOAT8OID, -1, 0);
|
||||
TupleDescInitEntry(tupdesc, 6, "vy", FLOAT8OID, -1, 0);
|
||||
TupleDescInitEntry(tupdesc, 7, "vz", FLOAT8OID, -1, 0);
|
||||
|
||||
funcctx->tuple_desc = BlessTupleDesc(tupdesc);
|
||||
|
||||
MemoryContextSwitchTo(oldctx);
|
||||
}
|
||||
|
||||
funcctx = SRF_PERCALL_SETUP();
|
||||
ctx = (propagate_series_ctx *) funcctx->user_fctx;
|
||||
|
||||
if (funcctx->call_cntr < funcctx->max_calls)
|
||||
{
|
||||
int64 ts;
|
||||
double jd;
|
||||
double tsince;
|
||||
double pos[3];
|
||||
double vel[3];
|
||||
int err;
|
||||
Datum values[7];
|
||||
bool nulls[7];
|
||||
HeapTuple tuple;
|
||||
|
||||
ts = ctx->start_ts + (int64) funcctx->call_cntr * ctx->step_usec;
|
||||
jd = timestamptz_to_jd(ts);
|
||||
tsince = jd_to_minutes_since_epoch(jd, ctx->epoch_jd);
|
||||
|
||||
if (ctx->is_deep)
|
||||
err = SDP4(tsince, &ctx->sat, ctx->params, pos, vel);
|
||||
else
|
||||
err = SGP4(tsince, &ctx->sat, ctx->params, pos, vel);
|
||||
|
||||
check_propagation_result(err, ctx->sat.norad_number, tsince);
|
||||
|
||||
memset(nulls, 0, sizeof(nulls));
|
||||
|
||||
values[0] = Int64GetDatum(ts);
|
||||
values[1] = Float8GetDatum(pos[0]);
|
||||
values[2] = Float8GetDatum(pos[1]);
|
||||
values[3] = Float8GetDatum(pos[2]);
|
||||
values[4] = Float8GetDatum(vel[0] / 60.0); /* km/min -> km/s */
|
||||
values[5] = Float8GetDatum(vel[1] / 60.0);
|
||||
values[6] = Float8GetDatum(vel[2] / 60.0);
|
||||
|
||||
tuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
|
||||
|
||||
SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple));
|
||||
}
|
||||
|
||||
SRF_RETURN_DONE(funcctx);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* tle_distance(tle_a, tle_b, timestamptz) -> float8
|
||||
*
|
||||
* Euclidean distance in km between two TLEs at a reference time.
|
||||
* Useful for conjunction screening and cluster analysis.
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
Datum
|
||||
tle_distance(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle_a = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
pg_tle *tle_b = (pg_tle *) PG_GETARG_POINTER(1);
|
||||
int64 ts = PG_GETARG_INT64(2);
|
||||
|
||||
tle_t sat_a, sat_b;
|
||||
double jd;
|
||||
double tsince_a, tsince_b;
|
||||
double pos_a[3], vel_a[3];
|
||||
double pos_b[3], vel_b[3];
|
||||
int err;
|
||||
double dx, dy, dz;
|
||||
|
||||
pg_tle_to_sat_code(tle_a, &sat_a);
|
||||
pg_tle_to_sat_code(tle_b, &sat_b);
|
||||
|
||||
jd = timestamptz_to_jd(ts);
|
||||
|
||||
tsince_a = jd_to_minutes_since_epoch(jd, sat_a.epoch);
|
||||
tsince_b = jd_to_minutes_since_epoch(jd, sat_b.epoch);
|
||||
|
||||
err = propagate_tle(&sat_a, tsince_a, pos_a, vel_a);
|
||||
check_propagation_result(err, sat_a.norad_number, tsince_a);
|
||||
|
||||
err = propagate_tle(&sat_b, tsince_b, pos_b, vel_b);
|
||||
check_propagation_result(err, sat_b.norad_number, tsince_b);
|
||||
|
||||
dx = pos_a[0] - pos_b[0];
|
||||
dy = pos_a[1] - pos_b[1];
|
||||
dz = pos_a[2] - pos_b[2];
|
||||
|
||||
PG_RETURN_FLOAT8(sqrt(dx * dx + dy * dy + dz * dz));
|
||||
}
|
||||
451
src/tle_type.c
Normal file
451
src/tle_type.c
Normal file
@ -0,0 +1,451 @@
|
||||
/*
|
||||
* tle_type.c -- TLE custom type for PostgreSQL
|
||||
*
|
||||
* Implements input/output, binary I/O, and accessor functions for the
|
||||
* Two-Line Element type. Parsing and formatting delegate to sat_code's
|
||||
* parse_elements() and write_elements_in_tle_format().
|
||||
*
|
||||
* Angular elements are stored internally in radians (matching sat_code).
|
||||
* Accessor functions convert to degrees and revs/day for human consumption.
|
||||
*/
|
||||
|
||||
#include "postgres.h"
|
||||
#include "fmgr.h"
|
||||
#include "utils/builtins.h"
|
||||
#include "libpq/pqformat.h"
|
||||
#include "norad.h"
|
||||
#include "types.h"
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
|
||||
PG_FUNCTION_INFO_V1(tle_in);
|
||||
PG_FUNCTION_INFO_V1(tle_out);
|
||||
PG_FUNCTION_INFO_V1(tle_recv);
|
||||
PG_FUNCTION_INFO_V1(tle_send);
|
||||
PG_FUNCTION_INFO_V1(tle_epoch);
|
||||
PG_FUNCTION_INFO_V1(tle_norad_id);
|
||||
PG_FUNCTION_INFO_V1(tle_inclination);
|
||||
PG_FUNCTION_INFO_V1(tle_eccentricity);
|
||||
PG_FUNCTION_INFO_V1(tle_raan);
|
||||
PG_FUNCTION_INFO_V1(tle_arg_perigee);
|
||||
PG_FUNCTION_INFO_V1(tle_mean_anomaly);
|
||||
PG_FUNCTION_INFO_V1(tle_mean_motion);
|
||||
PG_FUNCTION_INFO_V1(tle_bstar);
|
||||
PG_FUNCTION_INFO_V1(tle_period);
|
||||
PG_FUNCTION_INFO_V1(tle_age);
|
||||
PG_FUNCTION_INFO_V1(tle_perigee);
|
||||
PG_FUNCTION_INFO_V1(tle_apogee);
|
||||
PG_FUNCTION_INFO_V1(tle_intl_desig);
|
||||
|
||||
#define RAD_TO_DEG (180.0 / M_PI)
|
||||
#define RAD_MIN_TO_REV_DAY (1440.0 / (2.0 * M_PI))
|
||||
|
||||
/*
|
||||
* Copy parsed fields from sat_code's tle_t into our pg_tle.
|
||||
* The sat_code parser stores angles in radians and mean motion in
|
||||
* radians/minute, so no conversion is needed here.
|
||||
*/
|
||||
static void
|
||||
tle_t_to_pg_tle(const tle_t *sat, pg_tle *tle)
|
||||
{
|
||||
tle->epoch = sat->epoch;
|
||||
tle->inclination = sat->xincl;
|
||||
tle->raan = sat->xnodeo;
|
||||
tle->eccentricity = sat->eo;
|
||||
tle->arg_perigee = sat->omegao;
|
||||
tle->mean_anomaly = sat->xmo;
|
||||
tle->mean_motion = sat->xno;
|
||||
tle->mean_motion_dot = sat->xndt2o;
|
||||
tle->mean_motion_ddot = sat->xndd6o;
|
||||
tle->bstar = sat->bstar;
|
||||
tle->norad_id = sat->norad_number;
|
||||
tle->elset_num = sat->bulletin_number;
|
||||
tle->rev_num = sat->revolution_number;
|
||||
tle->classification = sat->classification;
|
||||
tle->ephemeris_type = sat->ephemeris_type;
|
||||
memcpy(tle->intl_desig, sat->intl_desig, 9);
|
||||
tle->_pad = '\0';
|
||||
}
|
||||
|
||||
/*
|
||||
* Reverse: pg_tle back to sat_code's tle_t for write_elements_in_tle_format.
|
||||
*/
|
||||
static void
|
||||
pg_tle_to_tle_t(const pg_tle *tle, tle_t *sat)
|
||||
{
|
||||
sat->epoch = tle->epoch;
|
||||
sat->xincl = tle->inclination;
|
||||
sat->xnodeo = tle->raan;
|
||||
sat->eo = tle->eccentricity;
|
||||
sat->omegao = tle->arg_perigee;
|
||||
sat->xmo = tle->mean_anomaly;
|
||||
sat->xno = tle->mean_motion;
|
||||
sat->xndt2o = tle->mean_motion_dot;
|
||||
sat->xndd6o = tle->mean_motion_ddot;
|
||||
sat->bstar = tle->bstar;
|
||||
sat->norad_number = tle->norad_id;
|
||||
sat->bulletin_number = tle->elset_num;
|
||||
sat->revolution_number = tle->rev_num;
|
||||
sat->classification = tle->classification;
|
||||
sat->ephemeris_type = tle->ephemeris_type;
|
||||
memcpy(sat->intl_desig, tle->intl_desig, 9);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* tle_in -- parse a two-line element set from text
|
||||
*
|
||||
* Expects two 69-character lines separated by '\n'.
|
||||
*/
|
||||
Datum
|
||||
tle_in(PG_FUNCTION_ARGS)
|
||||
{
|
||||
char *str = PG_GETARG_CSTRING(0);
|
||||
pg_tle *result;
|
||||
tle_t sat;
|
||||
char *newline;
|
||||
char *line1;
|
||||
char *line2;
|
||||
int parse_rc;
|
||||
|
||||
/* Split on first newline */
|
||||
newline = strchr(str, '\n');
|
||||
if (newline == NULL)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
|
||||
errmsg("invalid TLE: expected two lines separated by newline")));
|
||||
|
||||
/*
|
||||
* Make a mutable copy of line1 so we can null-terminate it without
|
||||
* modifying the caller's input buffer.
|
||||
*/
|
||||
line1 = pnstrdup(str, newline - str);
|
||||
line2 = newline + 1;
|
||||
|
||||
/* Strip trailing whitespace from line2 */
|
||||
{
|
||||
size_t len2 = strlen(line2);
|
||||
char *line2_copy = pnstrdup(line2, len2);
|
||||
|
||||
while (len2 > 0 && (line2_copy[len2 - 1] == '\n' ||
|
||||
line2_copy[len2 - 1] == '\r' ||
|
||||
line2_copy[len2 - 1] == ' '))
|
||||
line2_copy[--len2] = '\0';
|
||||
|
||||
memset(&sat, 0, sizeof(tle_t));
|
||||
parse_rc = parse_elements(line1, line2_copy, &sat);
|
||||
|
||||
pfree(line2_copy);
|
||||
}
|
||||
|
||||
pfree(line1);
|
||||
|
||||
if (parse_rc < 0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
|
||||
errmsg("invalid TLE: parse_elements returned %d", parse_rc)));
|
||||
|
||||
result = (pg_tle *) palloc0(sizeof(pg_tle));
|
||||
tle_t_to_pg_tle(&sat, result);
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* tle_out -- format a TLE as the standard two-line text representation
|
||||
*
|
||||
* Reconstructs the canonical 69+69 character format via sat_code's
|
||||
* write_elements_in_tle_format(). The output has lines separated by
|
||||
* a newline and is suitable for round-tripping through tle_in().
|
||||
*
|
||||
* write_elements_in_tle_format() writes two 71-char lines (each ending
|
||||
* CR+LF+NUL internally). We strip the trailing CR from each line and
|
||||
* join them with a single '\n' for PostgreSQL text output.
|
||||
*/
|
||||
Datum
|
||||
tle_out(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
tle_t sat;
|
||||
char buff[200];
|
||||
char *result;
|
||||
char *line1_end;
|
||||
char *line2_start;
|
||||
size_t line1_len;
|
||||
size_t line2_len;
|
||||
|
||||
pg_tle_to_tle_t(tle, &sat);
|
||||
memset(buff, 0, sizeof(buff));
|
||||
write_elements_in_tle_format(buff, &sat);
|
||||
|
||||
/*
|
||||
* sat_code writes: line1 (69 chars) + checksum + CR + LF + NUL
|
||||
* line2 (69 chars) + checksum + CR + LF + NUL
|
||||
* Total is two 71-byte strings placed back-to-back.
|
||||
* Find the boundary and strip trailing CR/LF from each line.
|
||||
*/
|
||||
line1_end = strchr(buff, '\n');
|
||||
if (line1_end == NULL)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_INTERNAL_ERROR),
|
||||
errmsg("write_elements_in_tle_format produced malformed output")));
|
||||
|
||||
/* Back up over CR if present */
|
||||
line1_len = line1_end - buff;
|
||||
if (line1_len > 0 && buff[line1_len - 1] == '\r')
|
||||
line1_len--;
|
||||
|
||||
/* line2 starts after the LF (and possibly NUL) */
|
||||
line2_start = line1_end + 1;
|
||||
if (*line2_start == '\0')
|
||||
line2_start++;
|
||||
|
||||
line2_len = strlen(line2_start);
|
||||
while (line2_len > 0 && (line2_start[line2_len - 1] == '\n' ||
|
||||
line2_start[line2_len - 1] == '\r'))
|
||||
line2_len--;
|
||||
|
||||
/* Assemble: line1 + '\n' + line2 */
|
||||
result = palloc(line1_len + 1 + line2_len + 1);
|
||||
memcpy(result, buff, line1_len);
|
||||
result[line1_len] = '\n';
|
||||
memcpy(result + line1_len + 1, line2_start, line2_len);
|
||||
result[line1_len + 1 + line2_len] = '\0';
|
||||
|
||||
PG_RETURN_CSTRING(result);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* tle_recv -- binary input
|
||||
*/
|
||||
Datum
|
||||
tle_recv(PG_FUNCTION_ARGS)
|
||||
{
|
||||
StringInfo buf = (StringInfo) PG_GETARG_POINTER(0);
|
||||
pg_tle *result = (pg_tle *) palloc0(sizeof(pg_tle));
|
||||
|
||||
result->epoch = pq_getmsgfloat8(buf);
|
||||
result->inclination = pq_getmsgfloat8(buf);
|
||||
result->raan = pq_getmsgfloat8(buf);
|
||||
result->eccentricity = pq_getmsgfloat8(buf);
|
||||
result->arg_perigee = pq_getmsgfloat8(buf);
|
||||
result->mean_anomaly = pq_getmsgfloat8(buf);
|
||||
result->mean_motion = pq_getmsgfloat8(buf);
|
||||
result->mean_motion_dot = pq_getmsgfloat8(buf);
|
||||
result->mean_motion_ddot = pq_getmsgfloat8(buf);
|
||||
result->bstar = pq_getmsgfloat8(buf);
|
||||
result->norad_id = pq_getmsgint(buf, 4);
|
||||
result->elset_num = pq_getmsgint(buf, 4);
|
||||
result->rev_num = pq_getmsgint(buf, 4);
|
||||
result->classification = pq_getmsgbyte(buf);
|
||||
result->ephemeris_type = pq_getmsgbyte(buf);
|
||||
memcpy(result->intl_desig, pq_getmsgbytes(buf, 9), 9);
|
||||
result->_pad = '\0';
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* tle_send -- binary output
|
||||
*/
|
||||
Datum
|
||||
tle_send(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
StringInfoData buf;
|
||||
|
||||
pq_begintypsend(&buf);
|
||||
|
||||
pq_sendfloat8(&buf, tle->epoch);
|
||||
pq_sendfloat8(&buf, tle->inclination);
|
||||
pq_sendfloat8(&buf, tle->raan);
|
||||
pq_sendfloat8(&buf, tle->eccentricity);
|
||||
pq_sendfloat8(&buf, tle->arg_perigee);
|
||||
pq_sendfloat8(&buf, tle->mean_anomaly);
|
||||
pq_sendfloat8(&buf, tle->mean_motion);
|
||||
pq_sendfloat8(&buf, tle->mean_motion_dot);
|
||||
pq_sendfloat8(&buf, tle->mean_motion_ddot);
|
||||
pq_sendfloat8(&buf, tle->bstar);
|
||||
pq_sendint32(&buf, tle->norad_id);
|
||||
pq_sendint32(&buf, tle->elset_num);
|
||||
pq_sendint32(&buf, tle->rev_num);
|
||||
pq_sendbyte(&buf, tle->classification);
|
||||
pq_sendbyte(&buf, tle->ephemeris_type);
|
||||
pq_sendbytes(&buf, tle->intl_desig, 9);
|
||||
|
||||
PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
|
||||
}
|
||||
|
||||
|
||||
/* --- Accessor functions --- */
|
||||
|
||||
Datum
|
||||
tle_epoch(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_FLOAT8(tle->epoch);
|
||||
}
|
||||
|
||||
Datum
|
||||
tle_norad_id(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_INT32(tle->norad_id);
|
||||
}
|
||||
|
||||
Datum
|
||||
tle_inclination(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_FLOAT8(tle->inclination * RAD_TO_DEG);
|
||||
}
|
||||
|
||||
Datum
|
||||
tle_eccentricity(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_FLOAT8(tle->eccentricity);
|
||||
}
|
||||
|
||||
Datum
|
||||
tle_raan(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_FLOAT8(tle->raan * RAD_TO_DEG);
|
||||
}
|
||||
|
||||
Datum
|
||||
tle_arg_perigee(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_FLOAT8(tle->arg_perigee * RAD_TO_DEG);
|
||||
}
|
||||
|
||||
Datum
|
||||
tle_mean_anomaly(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_FLOAT8(tle->mean_anomaly * RAD_TO_DEG);
|
||||
}
|
||||
|
||||
/*
|
||||
* Mean motion in revolutions/day.
|
||||
* Internally stored as radians/minute.
|
||||
*/
|
||||
Datum
|
||||
tle_mean_motion(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_FLOAT8(tle->mean_motion * RAD_MIN_TO_REV_DAY);
|
||||
}
|
||||
|
||||
Datum
|
||||
tle_bstar(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_FLOAT8(tle->bstar);
|
||||
}
|
||||
|
||||
/*
|
||||
* Orbital period in minutes.
|
||||
* period = 2*pi / mean_motion, where mean_motion is radians/minute.
|
||||
*/
|
||||
Datum
|
||||
tle_period(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
double period;
|
||||
|
||||
if (tle->mean_motion <= 0.0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
|
||||
errmsg("mean motion must be positive")));
|
||||
|
||||
period = (2.0 * M_PI) / tle->mean_motion;
|
||||
PG_RETURN_FLOAT8(period);
|
||||
}
|
||||
|
||||
/*
|
||||
* TLE age in days relative to a given timestamptz.
|
||||
* Positive = TLE is older than the timestamp (past epoch).
|
||||
* Negative = TLE epoch is in the future relative to the timestamp.
|
||||
*/
|
||||
Datum
|
||||
tle_age(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
int64 ts = PG_GETARG_INT64(1);
|
||||
double jd = timestamptz_to_jd(ts);
|
||||
double age_days = jd - tle->epoch;
|
||||
|
||||
PG_RETURN_FLOAT8(age_days);
|
||||
}
|
||||
|
||||
/*
|
||||
* Perigee altitude in km above the WGS-72 ellipsoid.
|
||||
*
|
||||
* Semi-major axis from Kepler's third law using WGS-72 KE:
|
||||
* a = (KE / n)^(2/3) [earth radii]
|
||||
* perigee_alt = a * (1 - e) * AE_km - AE_km
|
||||
*/
|
||||
Datum
|
||||
tle_perigee(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
double a_er; /* semi-major axis in earth radii */
|
||||
double alt;
|
||||
|
||||
if (tle->mean_motion <= 0.0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
|
||||
errmsg("mean motion must be positive")));
|
||||
|
||||
a_er = pow(WGS72_KE / tle->mean_motion, 2.0 / 3.0);
|
||||
alt = a_er * (1.0 - tle->eccentricity) * WGS72_AE - WGS72_AE;
|
||||
PG_RETURN_FLOAT8(alt);
|
||||
}
|
||||
|
||||
/*
|
||||
* Apogee altitude in km above the WGS-72 ellipsoid.
|
||||
*
|
||||
* apogee_alt = a * (1 + e) * AE_km - AE_km
|
||||
*/
|
||||
Datum
|
||||
tle_apogee(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
double a_er;
|
||||
double alt;
|
||||
|
||||
if (tle->mean_motion <= 0.0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
|
||||
errmsg("mean motion must be positive")));
|
||||
|
||||
a_er = pow(WGS72_KE / tle->mean_motion, 2.0 / 3.0);
|
||||
alt = a_er * (1.0 + tle->eccentricity) * WGS72_AE - WGS72_AE;
|
||||
PG_RETURN_FLOAT8(alt);
|
||||
}
|
||||
|
||||
/*
|
||||
* International designator as a text datum.
|
||||
*/
|
||||
Datum
|
||||
tle_intl_desig(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
|
||||
PG_RETURN_TEXT_P(cstring_to_text(tle->intl_desig));
|
||||
}
|
||||
190
src/types.h
Normal file
190
src/types.h
Normal file
@ -0,0 +1,190 @@
|
||||
/*
|
||||
* types.h -- Shared type definitions for pg_orbit
|
||||
*
|
||||
* All orbital mechanics types stored in PostgreSQL tuples.
|
||||
* Positions in TEME frame, propagated with WGS-72.
|
||||
* Coordinate output converted to WGS-84.
|
||||
*/
|
||||
|
||||
#ifndef PG_ORBIT_TYPES_H
|
||||
#define PG_ORBIT_TYPES_H
|
||||
|
||||
#include "postgres.h"
|
||||
|
||||
/*
|
||||
* WGS-72 constants (for SGP4 propagation ONLY)
|
||||
* Source: Hoots & Roehrich, "Models for Propagation of NORAD Element Sets"
|
||||
* Spacetrack Report No. 3, 1980
|
||||
*/
|
||||
#define WGS72_MU 398600.8 /* km^3/s^2 */
|
||||
#define WGS72_AE 6378.135 /* km (equatorial radius) */
|
||||
#define WGS72_J2 0.001082616
|
||||
#define WGS72_J3 -0.00000253881
|
||||
#define WGS72_J4 -0.00000165597
|
||||
#define WGS72_KE 0.0743669161331734132 /* (min)^(-1) */
|
||||
#define WGS72_XPDOTP (1440.0 / (2.0 * M_PI)) /* min/rev */
|
||||
|
||||
/*
|
||||
* WGS-84 constants (for coordinate output ONLY)
|
||||
* Source: NIMA TR8350.2, "Department of Defense World Geodetic System 1984"
|
||||
*/
|
||||
#define WGS84_A 6378.137 /* km (equatorial radius) */
|
||||
#define WGS84_F (1.0 / 298.257223563)
|
||||
#define WGS84_E2 (WGS84_F * (2.0 - WGS84_F))
|
||||
#define WGS84_A_M 6378137.0 /* meters */
|
||||
|
||||
/*
|
||||
* Julian date constants
|
||||
*/
|
||||
#define J2000_JD 2451545.0 /* 2000 Jan 1.5 TT */
|
||||
#define JD_UNIX_EPOCH 2440587.5 /* 1970 Jan 1.0 UTC */
|
||||
#define MJD_OFFSET 2400000.5
|
||||
|
||||
/*
|
||||
* TLE type -- parsed mean orbital elements
|
||||
*
|
||||
* Stored as a fixed-size struct, not raw text.
|
||||
* The epoch is a Julian date (UTC).
|
||||
* Angular elements are in radians.
|
||||
* Mean motion is in radians/minute.
|
||||
*/
|
||||
typedef struct pg_tle
|
||||
{
|
||||
/* Epoch as Julian date, UTC */
|
||||
double epoch;
|
||||
|
||||
/* Mean orbital elements (radians, radians/min) */
|
||||
double inclination; /* xincl */
|
||||
double raan; /* xnodeo - right ascension of ascending node */
|
||||
double eccentricity; /* eo */
|
||||
double arg_perigee; /* omegao */
|
||||
double mean_anomaly; /* xmo */
|
||||
double mean_motion; /* xno - radians/minute */
|
||||
|
||||
/* Drag terms */
|
||||
double mean_motion_dot; /* xndt2o - first derivative / 2 */
|
||||
double mean_motion_ddot; /* xndd6o - second derivative / 6 */
|
||||
double bstar; /* drag coefficient */
|
||||
|
||||
/* Identification */
|
||||
int32 norad_id;
|
||||
int32 elset_num; /* bulletin_number in sat_code */
|
||||
int32 rev_num; /* revolution number at epoch */
|
||||
|
||||
/* Metadata */
|
||||
char classification; /* U = unclassified */
|
||||
char ephemeris_type; /* 0 = SGP4/SDP4 default */
|
||||
char intl_desig[9]; /* international designator, null-terminated */
|
||||
char _pad; /* alignment */
|
||||
} pg_tle;
|
||||
|
||||
/* Size: 11 doubles (88 bytes) + 3 int32 (12 bytes) + 12 chars = 112 bytes */
|
||||
|
||||
|
||||
/*
|
||||
* ECI position -- True Equator Mean Equinox (TEME) frame
|
||||
*
|
||||
* Position in km, velocity in km/s.
|
||||
* SGP4 outputs velocity in km/min; we convert to km/s.
|
||||
*/
|
||||
typedef struct pg_eci
|
||||
{
|
||||
double x, y, z; /* position, km */
|
||||
double vx, vy, vz; /* velocity, km/s */
|
||||
} pg_eci;
|
||||
|
||||
|
||||
/*
|
||||
* Geodetic coordinates -- WGS-84 ellipsoid
|
||||
*
|
||||
* Latitude and longitude in radians (internal), degrees (display).
|
||||
* Altitude above WGS-84 ellipsoid in km.
|
||||
*/
|
||||
typedef struct pg_geodetic
|
||||
{
|
||||
double lat; /* radians, -pi/2 to +pi/2 */
|
||||
double lon; /* radians, -pi to +pi */
|
||||
double alt; /* km above WGS-84 ellipsoid */
|
||||
} pg_geodetic;
|
||||
|
||||
|
||||
/*
|
||||
* Topocentric coordinates -- observer-relative
|
||||
*
|
||||
* Azimuth: 0=N, 90=E, 180=S, 270=W (degrees for display, radians internal)
|
||||
* Elevation: 0=horizon, 90=zenith
|
||||
* Range in km, range rate in km/s (positive = receding)
|
||||
*/
|
||||
typedef struct pg_topocentric
|
||||
{
|
||||
double azimuth; /* radians */
|
||||
double elevation; /* radians */
|
||||
double range_km;
|
||||
double range_rate; /* km/s */
|
||||
} pg_topocentric;
|
||||
|
||||
|
||||
/*
|
||||
* Observer location -- ground station
|
||||
*
|
||||
* Latitude/longitude in radians (internal).
|
||||
* Altitude in meters above WGS-84 ellipsoid.
|
||||
*/
|
||||
typedef struct pg_observer
|
||||
{
|
||||
double lat; /* radians */
|
||||
double lon; /* radians */
|
||||
double alt_m; /* meters above WGS-84 */
|
||||
} pg_observer;
|
||||
|
||||
|
||||
/*
|
||||
* Pass event -- satellite visibility window
|
||||
*
|
||||
* Times are PostgreSQL TimestampTz (microseconds since 2000-01-01).
|
||||
* Elevation in degrees, azimuth in degrees.
|
||||
*/
|
||||
typedef struct pg_pass_event
|
||||
{
|
||||
int64 aos_time; /* acquisition of signal */
|
||||
int64 max_el_time; /* maximum elevation */
|
||||
int64 los_time; /* loss of signal */
|
||||
double max_elevation; /* degrees */
|
||||
double aos_azimuth; /* degrees, 0=N */
|
||||
double los_azimuth; /* degrees, 0=N */
|
||||
} pg_pass_event;
|
||||
|
||||
|
||||
/*
|
||||
* Utility: convert PostgreSQL TimestampTz to Julian date
|
||||
*
|
||||
* PG epoch: 2000-01-01 00:00:00 UTC = JD 2451544.5
|
||||
* TimestampTz is microseconds since PG epoch.
|
||||
*/
|
||||
#define PG_EPOCH_JD 2451544.5
|
||||
#ifndef USECS_PER_DAY
|
||||
#define USECS_PER_DAY 86400000000LL
|
||||
#endif
|
||||
|
||||
static inline double
|
||||
timestamptz_to_jd(int64 ts)
|
||||
{
|
||||
return PG_EPOCH_JD + ((double)ts / (double)USECS_PER_DAY);
|
||||
}
|
||||
|
||||
static inline int64
|
||||
jd_to_timestamptz(double jd)
|
||||
{
|
||||
return (int64)((jd - PG_EPOCH_JD) * (double)USECS_PER_DAY);
|
||||
}
|
||||
|
||||
/*
|
||||
* Utility: minutes since TLE epoch
|
||||
*/
|
||||
static inline double
|
||||
jd_to_minutes_since_epoch(double jd, double tle_epoch_jd)
|
||||
{
|
||||
return (jd - tle_epoch_jd) * 1440.0;
|
||||
}
|
||||
|
||||
#endif /* PG_ORBIT_TYPES_H */
|
||||
110
test/expected/coord_transforms.out
Normal file
110
test/expected/coord_transforms.out
Normal file
@ -0,0 +1,110 @@
|
||||
-- Test coordinate transform functions
|
||||
CREATE EXTENSION IF NOT EXISTS pg_orbit;
|
||||
NOTICE: extension "pg_orbit" already exists, skipping
|
||||
-- Subsatellite point at epoch
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(geodetic_lat(subsatellite_point(t, '2024-01-01 12:00:00+00'))::numeric, 2) AS lat,
|
||||
round(geodetic_lon(subsatellite_point(t, '2024-01-01 12:00:00+00'))::numeric, 2) AS lon,
|
||||
round(geodetic_alt(subsatellite_point(t, '2024-01-01 12:00:00+00'))::numeric, 0) AS alt_km
|
||||
FROM iss;
|
||||
lat | lon | alt_km
|
||||
-------+-------+--------
|
||||
51.75 | 21.48 | 420
|
||||
(1 row)
|
||||
|
||||
-- Subsatellite point 30 minutes later
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(geodetic_lat(subsatellite_point(t, '2024-01-01 12:30:00+00'))::numeric, 2) AS lat_30m,
|
||||
round(geodetic_lon(subsatellite_point(t, '2024-01-01 12:30:00+00'))::numeric, 2) AS lon_30m,
|
||||
round(geodetic_alt(subsatellite_point(t, '2024-01-01 12:30:00+00'))::numeric, 0) AS alt_30m
|
||||
FROM iss;
|
||||
lat_30m | lon_30m | alt_30m
|
||||
---------+---------+---------
|
||||
-21.98 | 119.17 | 427
|
||||
(1 row)
|
||||
|
||||
-- eci_to_geodetic: propagate ISS then convert
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(geodetic_lat(eci_to_geodetic(sgp4_propagate(t, '2024-01-01 12:00:00+00'), '2024-01-01 12:00:00+00'))::numeric, 2) AS eci_lat,
|
||||
round(geodetic_lon(eci_to_geodetic(sgp4_propagate(t, '2024-01-01 12:00:00+00'), '2024-01-01 12:00:00+00'))::numeric, 2) AS eci_lon,
|
||||
round(geodetic_alt(eci_to_geodetic(sgp4_propagate(t, '2024-01-01 12:00:00+00'), '2024-01-01 12:00:00+00'))::numeric, 0) AS eci_alt
|
||||
FROM iss;
|
||||
eci_lat | eci_lon | eci_alt
|
||||
---------+---------+---------
|
||||
51.75 | 21.48 | 420
|
||||
(1 row)
|
||||
|
||||
-- Topocentric from Boulder, CO
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(topo_azimuth(eci_to_topocentric(sgp4_propagate(t, '2024-01-01 12:00:00+00'), '40.0N 105.3W 1655m'::observer, '2024-01-01 12:00:00+00'))::numeric, 1) AS az_deg,
|
||||
round(topo_elevation(eci_to_topocentric(sgp4_propagate(t, '2024-01-01 12:00:00+00'), '40.0N 105.3W 1655m'::observer, '2024-01-01 12:00:00+00'))::numeric, 1) AS el_deg,
|
||||
round(topo_range(eci_to_topocentric(sgp4_propagate(t, '2024-01-01 12:00:00+00'), '40.0N 105.3W 1655m'::observer, '2024-01-01 12:00:00+00'))::numeric, 0) AS range_km
|
||||
FROM iss;
|
||||
az_deg | el_deg | range_km
|
||||
--------+--------+----------
|
||||
30.6 | -36.4 | 8245
|
||||
(1 row)
|
||||
|
||||
-- Ground track: 5 points over 40 minutes
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
count(*) AS track_points,
|
||||
round(min(lat)::numeric, 0) AS min_lat,
|
||||
round(max(lat)::numeric, 0) AS max_lat
|
||||
FROM iss, ground_track(t, '2024-01-01 12:00:00+00', '2024-01-01 12:40:00+00', '10 minutes');
|
||||
track_points | min_lat | max_lat
|
||||
--------------+---------+---------
|
||||
5 | -46 | 52
|
||||
(1 row)
|
||||
|
||||
-- Geodetic type I/O round-trip
|
||||
SELECT geodetic_lat('(51.75, 21.48, 420.0)'::geodetic) AS lat,
|
||||
geodetic_lon('(51.75, 21.48, 420.0)'::geodetic) AS lon,
|
||||
geodetic_alt('(51.75, 21.48, 420.0)'::geodetic) AS alt;
|
||||
lat | lon | alt
|
||||
-------+-------+-----
|
||||
51.75 | 21.48 | 420
|
||||
(1 row)
|
||||
|
||||
-- Topocentric type I/O round-trip
|
||||
SELECT round(topo_azimuth('(45.0, 30.0, 500.0, -2.5)'::topocentric)::numeric, 1) AS az,
|
||||
round(topo_elevation('(45.0, 30.0, 500.0, -2.5)'::topocentric)::numeric, 1) AS el,
|
||||
round(topo_range('(45.0, 30.0, 500.0, -2.5)'::topocentric)::numeric, 1) AS rng,
|
||||
round(topo_range_rate('(45.0, 30.0, 500.0, -2.5)'::topocentric)::numeric, 1) AS rr;
|
||||
az | el | rng | rr
|
||||
------+------+-------+------
|
||||
45.0 | 30.0 | 500.0 | -2.5
|
||||
(1 row)
|
||||
|
||||
-- ISS latitude should stay within inclination bounds (51.64 deg)
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
abs(geodetic_lat(subsatellite_point(t, '2024-01-01 12:00:00+00'))) <= 52.0 AS within_inc
|
||||
FROM iss;
|
||||
within_inc
|
||||
------------
|
||||
t
|
||||
(1 row)
|
||||
|
||||
83
test/expected/gist_index.out
Normal file
83
test/expected/gist_index.out
Normal file
@ -0,0 +1,83 @@
|
||||
-- Test GiST index and operators
|
||||
CREATE EXTENSION IF NOT EXISTS pg_orbit;
|
||||
NOTICE: extension "pg_orbit" already exists, skipping
|
||||
-- Create test table with mixed orbit types
|
||||
CREATE TABLE test_orbits (
|
||||
id serial,
|
||||
name text,
|
||||
tle tle
|
||||
);
|
||||
-- ISS (LEO, ~400km)
|
||||
INSERT INTO test_orbits (name, tle) VALUES ('ISS',
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001');
|
||||
-- Hubble (LEO, ~540km)
|
||||
INSERT INTO test_orbits (name, tle) VALUES ('Hubble',
|
||||
'1 20580U 90037B 24001.50000000 .00000790 00000+0 39573-4 0 9992
|
||||
2 20580 28.4705 61.4398 0002797 317.3115 42.7577 15.09395228 00008');
|
||||
-- GPS IIR-M (MEO, ~20200km)
|
||||
INSERT INTO test_orbits (name, tle) VALUES ('GPS-IIR',
|
||||
'1 28874U 05038A 24001.50000000 .00000012 00000+0 00000+0 0 9993
|
||||
2 28874 55.4408 300.3467 0117034 51.6543 309.5420 2.00557079 00006');
|
||||
-- Create GiST index
|
||||
CREATE INDEX test_orbits_gist ON test_orbits USING gist (tle);
|
||||
-- Overlap: ISS && Hubble = false (different altitude bands: ~400km vs ~540km)
|
||||
SELECT a.name AS sat_a, b.name AS sat_b, a.tle && b.tle AS overlaps
|
||||
FROM test_orbits a, test_orbits b
|
||||
WHERE a.id < b.id
|
||||
ORDER BY a.name, b.name;
|
||||
sat_a | sat_b | overlaps
|
||||
--------+---------+----------
|
||||
Hubble | GPS-IIR | f
|
||||
ISS | GPS-IIR | f
|
||||
ISS | Hubble | f
|
||||
(3 rows)
|
||||
|
||||
-- Altitude distance between different orbit regimes
|
||||
SELECT a.name AS sat_a, b.name AS sat_b,
|
||||
round((a.tle <-> b.tle)::numeric, 0) AS alt_dist_km
|
||||
FROM test_orbits a, test_orbits b
|
||||
WHERE a.id < b.id
|
||||
ORDER BY a.name, b.name;
|
||||
sat_a | sat_b | alt_dist_km
|
||||
--------+---------+-------------
|
||||
Hubble | GPS-IIR | 19332
|
||||
ISS | GPS-IIR | 19451
|
||||
ISS | Hubble | 115
|
||||
(3 rows)
|
||||
|
||||
-- GiST index scan: find all LEO sats (overlap with ISS)
|
||||
SET enable_seqscan = off;
|
||||
SELECT name FROM test_orbits
|
||||
WHERE tle && (SELECT tle FROM test_orbits WHERE name = 'ISS')
|
||||
ORDER BY name;
|
||||
name
|
||||
------
|
||||
ISS
|
||||
(1 row)
|
||||
|
||||
RESET enable_seqscan;
|
||||
-- Nearest-neighbor via GiST: order by altitude distance to ISS
|
||||
SET enable_seqscan = off;
|
||||
SELECT name, round((tle <-> (SELECT tle FROM test_orbits WHERE name = 'ISS'))::numeric, 0) AS dist
|
||||
FROM test_orbits
|
||||
WHERE name != 'ISS'
|
||||
ORDER BY tle <-> (SELECT tle FROM test_orbits WHERE name = 'ISS');
|
||||
name | dist
|
||||
---------+-------
|
||||
Hubble | 115
|
||||
GPS-IIR | 19451
|
||||
(2 rows)
|
||||
|
||||
RESET enable_seqscan;
|
||||
-- Self-overlap is always true
|
||||
SELECT name, tle && tle AS self_overlap FROM test_orbits ORDER BY name;
|
||||
name | self_overlap
|
||||
---------+--------------
|
||||
GPS-IIR | t
|
||||
Hubble | t
|
||||
ISS | t
|
||||
(3 rows)
|
||||
|
||||
-- Cleanup
|
||||
DROP TABLE test_orbits;
|
||||
91
test/expected/pass_prediction.out
Normal file
91
test/expected/pass_prediction.out
Normal file
@ -0,0 +1,91 @@
|
||||
-- Test pass prediction functions
|
||||
CREATE EXTENSION IF NOT EXISTS pg_orbit;
|
||||
NOTICE: extension "pg_orbit" already exists, skipping
|
||||
-- Predict ISS passes over Boulder in 24-hour window
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT count(*) AS pass_count
|
||||
FROM iss, predict_passes(t, '40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00', '2024-01-02 12:00:00+00');
|
||||
pass_count
|
||||
------------
|
||||
7
|
||||
(1 row)
|
||||
|
||||
-- Pass details: max elevation should be positive, duration reasonable
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(pass_max_elevation(p)::numeric, 1) AS max_el,
|
||||
pass_aos_time(p) < pass_max_el_time(p) AS aos_before_max,
|
||||
pass_max_el_time(p) < pass_los_time(p) AS max_before_los,
|
||||
pass_aos_azimuth(p) >= 0 AND pass_aos_azimuth(p) <= 360 AS az_valid,
|
||||
extract(epoch from pass_duration(p)) BETWEEN 60 AND 900 AS duration_ok
|
||||
FROM iss, predict_passes(t, '40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00', '2024-01-02 12:00:00+00') AS p
|
||||
LIMIT 3;
|
||||
max_el | aos_before_max | max_before_los | az_valid | duration_ok
|
||||
--------+----------------+----------------+----------+-------------
|
||||
3.3 | t | t | t | t
|
||||
46.4 | t | t | t | t
|
||||
24.4 | t | t | t | t
|
||||
(3 rows)
|
||||
|
||||
-- next_pass returns same first pass as predict_passes
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
),
|
||||
np AS (
|
||||
SELECT next_pass(t, '40.0N 105.3W 1655m'::observer, '2024-01-01 12:00:00+00') AS p FROM iss
|
||||
),
|
||||
pp AS (
|
||||
SELECT p AS pass FROM iss, predict_passes(t, '40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00', '2024-01-02 12:00:00+00') AS p
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
pass_aos_time(np.p) = pass_aos_time(pp.pass) AS same_aos
|
||||
FROM np, pp;
|
||||
same_aos
|
||||
----------
|
||||
t
|
||||
(1 row)
|
||||
|
||||
-- pass_visible should be true for ISS over Boulder in a 24h window
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT pass_visible(t, '40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00', '2024-01-02 12:00:00+00') AS visible
|
||||
FROM iss;
|
||||
visible
|
||||
---------
|
||||
t
|
||||
(1 row)
|
||||
|
||||
-- Minimum elevation filter should reduce pass count
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
),
|
||||
all_passes AS (
|
||||
SELECT count(*) AS total FROM iss, predict_passes(t, '40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00', '2024-01-02 12:00:00+00')
|
||||
),
|
||||
high_passes AS (
|
||||
SELECT count(*) AS high FROM iss, predict_passes(t, '40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00', '2024-01-02 12:00:00+00', 30.0)
|
||||
)
|
||||
SELECT high <= total AS filter_works
|
||||
FROM all_passes, high_passes;
|
||||
filter_works
|
||||
--------------
|
||||
t
|
||||
(1 row)
|
||||
|
||||
92
test/expected/sgp4_propagate.out
Normal file
92
test/expected/sgp4_propagate.out
Normal file
@ -0,0 +1,92 @@
|
||||
-- Test SGP4/SDP4 propagation functions
|
||||
CREATE EXTENSION IF NOT EXISTS pg_orbit;
|
||||
NOTICE: extension "pg_orbit" already exists, skipping
|
||||
-- ISS TLE (LEO, near-earth -> SGP4)
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(eci_x(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 1) AS x_km,
|
||||
round(eci_y(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 1) AS y_km,
|
||||
round(eci_z(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 1) AS z_km,
|
||||
round(eci_speed(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 3) AS speed_kms,
|
||||
round(eci_altitude(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 0) AS alt_km
|
||||
FROM iss;
|
||||
x_km | y_km | z_km | speed_kms | alt_km
|
||||
--------+---------+--------+-----------+--------
|
||||
2242.3 | -3571.3 | 5315.9 | 7.667 | 407
|
||||
(1 row)
|
||||
|
||||
-- Propagation 1 hour after epoch
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(eci_speed(sgp4_propagate(t, '2024-01-01 13:00:00+00'))::numeric, 3) AS speed_1h,
|
||||
round(eci_altitude(sgp4_propagate(t, '2024-01-01 13:00:00+00'))::numeric, 0) AS alt_1h
|
||||
FROM iss;
|
||||
speed_1h | alt_1h
|
||||
----------+--------
|
||||
7.659 | 418
|
||||
(1 row)
|
||||
|
||||
-- GPS satellite (MEO, deep-space -> SDP4)
|
||||
WITH gps AS (
|
||||
SELECT '1 28874U 05038A 24001.50000000 .00000012 00000+0 00000+0 0 9993
|
||||
2 28874 55.4408 300.3467 0117034 51.6543 309.5420 2.00557079 00006'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(eci_speed(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 3) AS gps_speed,
|
||||
round(eci_altitude(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 0) AS gps_alt
|
||||
FROM gps;
|
||||
gps_speed | gps_alt
|
||||
-----------+---------
|
||||
3.903 | 19988
|
||||
(1 row)
|
||||
|
||||
-- Propagation series: ISS, 10 minute steps over 1 orbit (~93 min)
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
count(*) AS num_points,
|
||||
round(min(sqrt(x*x + y*y + z*z) - 6378.135)::numeric, 0) AS min_alt,
|
||||
round(max(sqrt(x*x + y*y + z*z) - 6378.135)::numeric, 0) AS max_alt
|
||||
FROM iss, sgp4_propagate_series(t, '2024-01-01 12:00:00+00', '2024-01-01 13:33:00+00', '10 minutes');
|
||||
num_points | min_alt | max_alt
|
||||
------------+---------+---------
|
||||
10 | 407 | 425
|
||||
(1 row)
|
||||
|
||||
-- Distance between ISS and GPS at epoch
|
||||
WITH sats AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS iss,
|
||||
'1 28874U 05038A 24001.50000000 .00000012 00000+0 00000+0 0 9993
|
||||
2 28874 55.4408 300.3467 0117034 51.6543 309.5420 2.00557079 00006'::tle AS gps
|
||||
)
|
||||
SELECT
|
||||
round(tle_distance(iss, gps, '2024-01-01 12:00:00+00')::numeric, 0) AS dist_km,
|
||||
round(tle_distance(iss, iss, '2024-01-01 12:00:00+00')::numeric, 6) AS self_dist
|
||||
FROM sats;
|
||||
dist_km | self_dist
|
||||
---------+-----------
|
||||
22768 | 0.000000
|
||||
(1 row)
|
||||
|
||||
-- Distance to self should be zero
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
tle_distance(t, t, '2024-01-01 12:00:00+00') = 0.0 AS self_is_zero
|
||||
FROM iss;
|
||||
self_is_zero
|
||||
--------------
|
||||
t
|
||||
(1 row)
|
||||
|
||||
116
test/expected/tle_parse.out
Normal file
116
test/expected/tle_parse.out
Normal file
@ -0,0 +1,116 @@
|
||||
-- Test TLE type: parsing, round-trip, accessors
|
||||
CREATE EXTENSION IF NOT EXISTS pg_orbit;
|
||||
-- Parse a valid ISS TLE
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle IS NOT NULL AS parse_ok;
|
||||
parse_ok
|
||||
----------
|
||||
t
|
||||
(1 row)
|
||||
|
||||
-- Accessor functions
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
tle_norad_id(t) AS norad_id,
|
||||
round(tle_inclination(t)::numeric, 4) AS inc_deg,
|
||||
round(tle_eccentricity(t)::numeric, 7) AS ecc,
|
||||
round(tle_mean_motion(t)::numeric, 8) AS mm_rev_day,
|
||||
round(tle_period(t)::numeric, 2) AS period_min,
|
||||
round(tle_perigee(t)::numeric, 1) AS perigee_km,
|
||||
round(tle_apogee(t)::numeric, 1) AS apogee_km,
|
||||
tle_intl_desig(t) AS cospar
|
||||
FROM iss;
|
||||
norad_id | inc_deg | ecc | mm_rev_day | period_min | perigee_km | apogee_km | cospar
|
||||
----------+---------+-----------+-------------+------------+------------+-----------+----------
|
||||
25544 | 51.6400 | 0.0006703 | 15.50100486 | 92.90 | 411.9 | 421.0 | 98067A
|
||||
(1 row)
|
||||
|
||||
-- Observer type parsing
|
||||
SELECT '40.0N 105.3W 1655m'::observer IS NOT NULL AS observer_ok;
|
||||
observer_ok
|
||||
-------------
|
||||
t
|
||||
(1 row)
|
||||
|
||||
SELECT observer_lat('40.0N 105.3W 1655m'::observer) AS lat,
|
||||
observer_lon('40.0N 105.3W 1655m'::observer) AS lon;
|
||||
lat | lon
|
||||
-----+--------
|
||||
40 | -105.3
|
||||
(1 row)
|
||||
|
||||
-- ECI type parsing
|
||||
SELECT '(1000.0,2000.0,3000.0,1.0,2.0,3.0)'::eci_position IS NOT NULL AS eci_ok;
|
||||
eci_ok
|
||||
--------
|
||||
t
|
||||
(1 row)
|
||||
|
||||
SELECT eci_x('(1000.0,2000.0,3000.0,1.0,2.0,3.0)'::eci_position) AS x;
|
||||
x
|
||||
------
|
||||
1000
|
||||
(1 row)
|
||||
|
||||
-- SGP4 propagation at epoch
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(eci_speed(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 2) AS speed_kms,
|
||||
round(eci_altitude(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 0) AS alt_km
|
||||
FROM iss;
|
||||
speed_kms | alt_km
|
||||
-----------+--------
|
||||
7.67 | 407
|
||||
(1 row)
|
||||
|
||||
-- Altitude overlap operator
|
||||
WITH sats AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS iss,
|
||||
'1 28874U 05038A 24001.50000000 .00000012 00000+0 00000+0 0 9993
|
||||
2 28874 55.4408 300.3467 0117034 51.6543 309.5420 2.00557079 00006'::tle AS gps
|
||||
)
|
||||
SELECT
|
||||
iss && iss AS same_overlap,
|
||||
iss && gps AS different_no_overlap
|
||||
FROM sats;
|
||||
same_overlap | different_no_overlap
|
||||
--------------+----------------------
|
||||
t | f
|
||||
(1 row)
|
||||
|
||||
-- Subsatellite point
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(geodetic_lat(subsatellite_point(t, '2024-01-01 12:00:00+00'))::numeric, 2) AS lat,
|
||||
round(geodetic_alt(subsatellite_point(t, '2024-01-01 12:00:00+00'))::numeric, 0) AS alt_km
|
||||
FROM iss;
|
||||
lat | alt_km
|
||||
-------+--------
|
||||
51.75 | 420
|
||||
(1 row)
|
||||
|
||||
-- GiST index creation
|
||||
CREATE TABLE test_sats (id serial, tle tle);
|
||||
INSERT INTO test_sats (tle) VALUES (
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001');
|
||||
CREATE INDEX test_sats_gist ON test_sats USING gist (tle);
|
||||
SELECT count(*) FROM test_sats WHERE tle && (
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle);
|
||||
count
|
||||
-------
|
||||
1
|
||||
(1 row)
|
||||
|
||||
DROP TABLE test_sats;
|
||||
77
test/sql/coord_transforms.sql
Normal file
77
test/sql/coord_transforms.sql
Normal file
@ -0,0 +1,77 @@
|
||||
-- Test coordinate transform functions
|
||||
CREATE EXTENSION IF NOT EXISTS pg_orbit;
|
||||
|
||||
-- Subsatellite point at epoch
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(geodetic_lat(subsatellite_point(t, '2024-01-01 12:00:00+00'))::numeric, 2) AS lat,
|
||||
round(geodetic_lon(subsatellite_point(t, '2024-01-01 12:00:00+00'))::numeric, 2) AS lon,
|
||||
round(geodetic_alt(subsatellite_point(t, '2024-01-01 12:00:00+00'))::numeric, 0) AS alt_km
|
||||
FROM iss;
|
||||
|
||||
-- Subsatellite point 30 minutes later
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(geodetic_lat(subsatellite_point(t, '2024-01-01 12:30:00+00'))::numeric, 2) AS lat_30m,
|
||||
round(geodetic_lon(subsatellite_point(t, '2024-01-01 12:30:00+00'))::numeric, 2) AS lon_30m,
|
||||
round(geodetic_alt(subsatellite_point(t, '2024-01-01 12:30:00+00'))::numeric, 0) AS alt_30m
|
||||
FROM iss;
|
||||
|
||||
-- eci_to_geodetic: propagate ISS then convert
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(geodetic_lat(eci_to_geodetic(sgp4_propagate(t, '2024-01-01 12:00:00+00'), '2024-01-01 12:00:00+00'))::numeric, 2) AS eci_lat,
|
||||
round(geodetic_lon(eci_to_geodetic(sgp4_propagate(t, '2024-01-01 12:00:00+00'), '2024-01-01 12:00:00+00'))::numeric, 2) AS eci_lon,
|
||||
round(geodetic_alt(eci_to_geodetic(sgp4_propagate(t, '2024-01-01 12:00:00+00'), '2024-01-01 12:00:00+00'))::numeric, 0) AS eci_alt
|
||||
FROM iss;
|
||||
|
||||
-- Topocentric from Boulder, CO
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(topo_azimuth(eci_to_topocentric(sgp4_propagate(t, '2024-01-01 12:00:00+00'), '40.0N 105.3W 1655m'::observer, '2024-01-01 12:00:00+00'))::numeric, 1) AS az_deg,
|
||||
round(topo_elevation(eci_to_topocentric(sgp4_propagate(t, '2024-01-01 12:00:00+00'), '40.0N 105.3W 1655m'::observer, '2024-01-01 12:00:00+00'))::numeric, 1) AS el_deg,
|
||||
round(topo_range(eci_to_topocentric(sgp4_propagate(t, '2024-01-01 12:00:00+00'), '40.0N 105.3W 1655m'::observer, '2024-01-01 12:00:00+00'))::numeric, 0) AS range_km
|
||||
FROM iss;
|
||||
|
||||
-- Ground track: 5 points over 40 minutes
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
count(*) AS track_points,
|
||||
round(min(lat)::numeric, 0) AS min_lat,
|
||||
round(max(lat)::numeric, 0) AS max_lat
|
||||
FROM iss, ground_track(t, '2024-01-01 12:00:00+00', '2024-01-01 12:40:00+00', '10 minutes');
|
||||
|
||||
-- Geodetic type I/O round-trip
|
||||
SELECT geodetic_lat('(51.75, 21.48, 420.0)'::geodetic) AS lat,
|
||||
geodetic_lon('(51.75, 21.48, 420.0)'::geodetic) AS lon,
|
||||
geodetic_alt('(51.75, 21.48, 420.0)'::geodetic) AS alt;
|
||||
|
||||
-- Topocentric type I/O round-trip
|
||||
SELECT round(topo_azimuth('(45.0, 30.0, 500.0, -2.5)'::topocentric)::numeric, 1) AS az,
|
||||
round(topo_elevation('(45.0, 30.0, 500.0, -2.5)'::topocentric)::numeric, 1) AS el,
|
||||
round(topo_range('(45.0, 30.0, 500.0, -2.5)'::topocentric)::numeric, 1) AS rng,
|
||||
round(topo_range_rate('(45.0, 30.0, 500.0, -2.5)'::topocentric)::numeric, 1) AS rr;
|
||||
|
||||
-- ISS latitude should stay within inclination bounds (51.64 deg)
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
abs(geodetic_lat(subsatellite_point(t, '2024-01-01 12:00:00+00'))) <= 52.0 AS within_inc
|
||||
FROM iss;
|
||||
61
test/sql/gist_index.sql
Normal file
61
test/sql/gist_index.sql
Normal file
@ -0,0 +1,61 @@
|
||||
-- Test GiST index and operators
|
||||
CREATE EXTENSION IF NOT EXISTS pg_orbit;
|
||||
|
||||
-- Create test table with mixed orbit types
|
||||
CREATE TABLE test_orbits (
|
||||
id serial,
|
||||
name text,
|
||||
tle tle
|
||||
);
|
||||
|
||||
-- ISS (LEO, ~400km)
|
||||
INSERT INTO test_orbits (name, tle) VALUES ('ISS',
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001');
|
||||
|
||||
-- Hubble (LEO, ~540km)
|
||||
INSERT INTO test_orbits (name, tle) VALUES ('Hubble',
|
||||
'1 20580U 90037B 24001.50000000 .00000790 00000+0 39573-4 0 9992
|
||||
2 20580 28.4705 61.4398 0002797 317.3115 42.7577 15.09395228 00008');
|
||||
|
||||
-- GPS IIR-M (MEO, ~20200km)
|
||||
INSERT INTO test_orbits (name, tle) VALUES ('GPS-IIR',
|
||||
'1 28874U 05038A 24001.50000000 .00000012 00000+0 00000+0 0 9993
|
||||
2 28874 55.4408 300.3467 0117034 51.6543 309.5420 2.00557079 00006');
|
||||
|
||||
-- Create GiST index
|
||||
CREATE INDEX test_orbits_gist ON test_orbits USING gist (tle);
|
||||
|
||||
-- Overlap: ISS && Hubble = false (different altitude bands: ~400km vs ~540km)
|
||||
SELECT a.name AS sat_a, b.name AS sat_b, a.tle && b.tle AS overlaps
|
||||
FROM test_orbits a, test_orbits b
|
||||
WHERE a.id < b.id
|
||||
ORDER BY a.name, b.name;
|
||||
|
||||
-- Altitude distance between different orbit regimes
|
||||
SELECT a.name AS sat_a, b.name AS sat_b,
|
||||
round((a.tle <-> b.tle)::numeric, 0) AS alt_dist_km
|
||||
FROM test_orbits a, test_orbits b
|
||||
WHERE a.id < b.id
|
||||
ORDER BY a.name, b.name;
|
||||
|
||||
-- GiST index scan: find all LEO sats (overlap with ISS)
|
||||
SET enable_seqscan = off;
|
||||
SELECT name FROM test_orbits
|
||||
WHERE tle && (SELECT tle FROM test_orbits WHERE name = 'ISS')
|
||||
ORDER BY name;
|
||||
RESET enable_seqscan;
|
||||
|
||||
-- Nearest-neighbor via GiST: order by altitude distance to ISS
|
||||
SET enable_seqscan = off;
|
||||
SELECT name, round((tle <-> (SELECT tle FROM test_orbits WHERE name = 'ISS'))::numeric, 0) AS dist
|
||||
FROM test_orbits
|
||||
WHERE name != 'ISS'
|
||||
ORDER BY tle <-> (SELECT tle FROM test_orbits WHERE name = 'ISS');
|
||||
RESET enable_seqscan;
|
||||
|
||||
-- Self-overlap is always true
|
||||
SELECT name, tle && tle AS self_overlap FROM test_orbits ORDER BY name;
|
||||
|
||||
-- Cleanup
|
||||
DROP TABLE test_orbits;
|
||||
68
test/sql/pass_prediction.sql
Normal file
68
test/sql/pass_prediction.sql
Normal file
@ -0,0 +1,68 @@
|
||||
-- Test pass prediction functions
|
||||
CREATE EXTENSION IF NOT EXISTS pg_orbit;
|
||||
|
||||
-- Predict ISS passes over Boulder in 24-hour window
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT count(*) AS pass_count
|
||||
FROM iss, predict_passes(t, '40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00', '2024-01-02 12:00:00+00');
|
||||
|
||||
-- Pass details: max elevation should be positive, duration reasonable
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(pass_max_elevation(p)::numeric, 1) AS max_el,
|
||||
pass_aos_time(p) < pass_max_el_time(p) AS aos_before_max,
|
||||
pass_max_el_time(p) < pass_los_time(p) AS max_before_los,
|
||||
pass_aos_azimuth(p) >= 0 AND pass_aos_azimuth(p) <= 360 AS az_valid,
|
||||
extract(epoch from pass_duration(p)) BETWEEN 60 AND 900 AS duration_ok
|
||||
FROM iss, predict_passes(t, '40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00', '2024-01-02 12:00:00+00') AS p
|
||||
LIMIT 3;
|
||||
|
||||
-- next_pass returns same first pass as predict_passes
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
),
|
||||
np AS (
|
||||
SELECT next_pass(t, '40.0N 105.3W 1655m'::observer, '2024-01-01 12:00:00+00') AS p FROM iss
|
||||
),
|
||||
pp AS (
|
||||
SELECT p AS pass FROM iss, predict_passes(t, '40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00', '2024-01-02 12:00:00+00') AS p
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT
|
||||
pass_aos_time(np.p) = pass_aos_time(pp.pass) AS same_aos
|
||||
FROM np, pp;
|
||||
|
||||
-- pass_visible should be true for ISS over Boulder in a 24h window
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT pass_visible(t, '40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00', '2024-01-02 12:00:00+00') AS visible
|
||||
FROM iss;
|
||||
|
||||
-- Minimum elevation filter should reduce pass count
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
),
|
||||
all_passes AS (
|
||||
SELECT count(*) AS total FROM iss, predict_passes(t, '40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00', '2024-01-02 12:00:00+00')
|
||||
),
|
||||
high_passes AS (
|
||||
SELECT count(*) AS high FROM iss, predict_passes(t, '40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00', '2024-01-02 12:00:00+00', 30.0)
|
||||
)
|
||||
SELECT high <= total AS filter_works
|
||||
FROM all_passes, high_passes;
|
||||
67
test/sql/sgp4_propagate.sql
Normal file
67
test/sql/sgp4_propagate.sql
Normal file
@ -0,0 +1,67 @@
|
||||
-- Test SGP4/SDP4 propagation functions
|
||||
CREATE EXTENSION IF NOT EXISTS pg_orbit;
|
||||
|
||||
-- ISS TLE (LEO, near-earth -> SGP4)
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(eci_x(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 1) AS x_km,
|
||||
round(eci_y(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 1) AS y_km,
|
||||
round(eci_z(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 1) AS z_km,
|
||||
round(eci_speed(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 3) AS speed_kms,
|
||||
round(eci_altitude(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 0) AS alt_km
|
||||
FROM iss;
|
||||
|
||||
-- Propagation 1 hour after epoch
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(eci_speed(sgp4_propagate(t, '2024-01-01 13:00:00+00'))::numeric, 3) AS speed_1h,
|
||||
round(eci_altitude(sgp4_propagate(t, '2024-01-01 13:00:00+00'))::numeric, 0) AS alt_1h
|
||||
FROM iss;
|
||||
|
||||
-- GPS satellite (MEO, deep-space -> SDP4)
|
||||
WITH gps AS (
|
||||
SELECT '1 28874U 05038A 24001.50000000 .00000012 00000+0 00000+0 0 9993
|
||||
2 28874 55.4408 300.3467 0117034 51.6543 309.5420 2.00557079 00006'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(eci_speed(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 3) AS gps_speed,
|
||||
round(eci_altitude(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 0) AS gps_alt
|
||||
FROM gps;
|
||||
|
||||
-- Propagation series: ISS, 10 minute steps over 1 orbit (~93 min)
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
count(*) AS num_points,
|
||||
round(min(sqrt(x*x + y*y + z*z) - 6378.135)::numeric, 0) AS min_alt,
|
||||
round(max(sqrt(x*x + y*y + z*z) - 6378.135)::numeric, 0) AS max_alt
|
||||
FROM iss, sgp4_propagate_series(t, '2024-01-01 12:00:00+00', '2024-01-01 13:33:00+00', '10 minutes');
|
||||
|
||||
-- Distance between ISS and GPS at epoch
|
||||
WITH sats AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS iss,
|
||||
'1 28874U 05038A 24001.50000000 .00000012 00000+0 00000+0 0 9993
|
||||
2 28874 55.4408 300.3467 0117034 51.6543 309.5420 2.00557079 00006'::tle AS gps
|
||||
)
|
||||
SELECT
|
||||
round(tle_distance(iss, gps, '2024-01-01 12:00:00+00')::numeric, 0) AS dist_km,
|
||||
round(tle_distance(iss, iss, '2024-01-01 12:00:00+00')::numeric, 6) AS self_dist
|
||||
FROM sats;
|
||||
|
||||
-- Distance to self should be zero
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
tle_distance(t, t, '2024-01-01 12:00:00+00') = 0.0 AS self_is_zero
|
||||
FROM iss;
|
||||
74
test/sql/tle_parse.sql
Normal file
74
test/sql/tle_parse.sql
Normal file
@ -0,0 +1,74 @@
|
||||
-- Test TLE type: parsing, round-trip, accessors
|
||||
CREATE EXTENSION IF NOT EXISTS pg_orbit;
|
||||
|
||||
-- Parse a valid ISS TLE
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle IS NOT NULL AS parse_ok;
|
||||
|
||||
-- Accessor functions
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
tle_norad_id(t) AS norad_id,
|
||||
round(tle_inclination(t)::numeric, 4) AS inc_deg,
|
||||
round(tle_eccentricity(t)::numeric, 7) AS ecc,
|
||||
round(tle_mean_motion(t)::numeric, 8) AS mm_rev_day,
|
||||
round(tle_period(t)::numeric, 2) AS period_min,
|
||||
round(tle_perigee(t)::numeric, 1) AS perigee_km,
|
||||
round(tle_apogee(t)::numeric, 1) AS apogee_km,
|
||||
tle_intl_desig(t) AS cospar
|
||||
FROM iss;
|
||||
|
||||
-- Observer type parsing
|
||||
SELECT '40.0N 105.3W 1655m'::observer IS NOT NULL AS observer_ok;
|
||||
SELECT observer_lat('40.0N 105.3W 1655m'::observer) AS lat,
|
||||
observer_lon('40.0N 105.3W 1655m'::observer) AS lon;
|
||||
|
||||
-- ECI type parsing
|
||||
SELECT '(1000.0,2000.0,3000.0,1.0,2.0,3.0)'::eci_position IS NOT NULL AS eci_ok;
|
||||
SELECT eci_x('(1000.0,2000.0,3000.0,1.0,2.0,3.0)'::eci_position) AS x;
|
||||
|
||||
-- SGP4 propagation at epoch
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(eci_speed(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 2) AS speed_kms,
|
||||
round(eci_altitude(sgp4_propagate(t, '2024-01-01 12:00:00+00'))::numeric, 0) AS alt_km
|
||||
FROM iss;
|
||||
|
||||
-- Altitude overlap operator
|
||||
WITH sats AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS iss,
|
||||
'1 28874U 05038A 24001.50000000 .00000012 00000+0 00000+0 0 9993
|
||||
2 28874 55.4408 300.3467 0117034 51.6543 309.5420 2.00557079 00006'::tle AS gps
|
||||
)
|
||||
SELECT
|
||||
iss && iss AS same_overlap,
|
||||
iss && gps AS different_no_overlap
|
||||
FROM sats;
|
||||
|
||||
-- Subsatellite point
|
||||
WITH iss AS (
|
||||
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
|
||||
)
|
||||
SELECT
|
||||
round(geodetic_lat(subsatellite_point(t, '2024-01-01 12:00:00+00'))::numeric, 2) AS lat,
|
||||
round(geodetic_alt(subsatellite_point(t, '2024-01-01 12:00:00+00'))::numeric, 0) AS alt_km
|
||||
FROM iss;
|
||||
|
||||
-- GiST index creation
|
||||
CREATE TABLE test_sats (id serial, tle tle);
|
||||
INSERT INTO test_sats (tle) VALUES (
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001');
|
||||
CREATE INDEX test_sats_gist ON test_sats USING gist (tle);
|
||||
SELECT count(*) FROM test_sats WHERE tle && (
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025
|
||||
2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle);
|
||||
DROP TABLE test_sats;
|
||||
Loading…
x
Reference in New Issue
Block a user