Add v0.9.0 apparent position features: equatorial type, refraction, proper motion, light-time

New equatorial type (24 bytes: RA/Dec/distance) captures apparent coordinates
of date — what the observation pipeline computes at precession step 3 but was
discarding before hour angle conversion. Matches telescope GoTo mount conventions.

24 new SQL functions (82 → 106 total):
- equatorial type I/O + 3 accessors (eq_ra, eq_dec, eq_distance)
- Satellite RA/Dec: eci_to_equatorial (topocentric), eci_to_equatorial_geo (geocentric)
- Solar system equatorial: planet/sun/moon/small_body_equatorial
- Atmospheric refraction: Bennett (1982) with domain clamp at -1 deg
- Refracted pass prediction: predict_passes_refracted (horizon at -0.569 deg)
- Stellar proper motion: star_observe_pm, star_equatorial_pm (Hipparcos/Gaia convention)
- Light-time correction: planet/sun/small_body_observe_apparent, *_equatorial_apparent
- DE equatorial variants: planet_equatorial_de, moon_equatorial_de

Also includes v0.8.0 orbital_elements type (MPC parser, small_body_observe),
GiST 0-based indexing fix, llms.txt updates, and doc improvements.

All 18 regression suites pass. Zero build warnings (GCC + Clang).
This commit is contained in:
Ryan Malloy 2026-02-21 15:31:46 -07:00
parent b79f6948c6
commit b33d63034b
39 changed files with 6204 additions and 115 deletions

View File

@ -5,7 +5,9 @@ DATA = sql/pg_orrery--0.1.0.sql sql/pg_orrery--0.2.0.sql sql/pg_orrery--0.1.0--0
sql/pg_orrery--0.4.0.sql sql/pg_orrery--0.3.0--0.4.0.sql \ sql/pg_orrery--0.4.0.sql sql/pg_orrery--0.3.0--0.4.0.sql \
sql/pg_orrery--0.5.0.sql sql/pg_orrery--0.4.0--0.5.0.sql \ sql/pg_orrery--0.5.0.sql sql/pg_orrery--0.4.0--0.5.0.sql \
sql/pg_orrery--0.6.0.sql sql/pg_orrery--0.5.0--0.6.0.sql \ sql/pg_orrery--0.6.0.sql sql/pg_orrery--0.5.0--0.6.0.sql \
sql/pg_orrery--0.7.0.sql sql/pg_orrery--0.6.0--0.7.0.sql sql/pg_orrery--0.7.0.sql sql/pg_orrery--0.6.0--0.7.0.sql \
sql/pg_orrery--0.8.0.sql sql/pg_orrery--0.7.0--0.8.0.sql \
sql/pg_orrery--0.9.0.sql sql/pg_orrery--0.8.0--0.9.0.sql
# Our extension C sources # Our extension C sources
OBJS = src/pg_orrery.o src/tle_type.o src/eci_type.o src/observer_type.o \ OBJS = src/pg_orrery.o src/tle_type.o src/eci_type.o src/observer_type.o \
@ -18,7 +20,10 @@ OBJS = src/pg_orrery.o src/tle_type.o src/eci_type.o src/observer_type.o \
src/lambert.o src/transfer_funcs.o \ src/lambert.o src/transfer_funcs.o \
src/de_reader.o src/eph_provider.o src/de_funcs.o \ src/de_reader.o src/eph_provider.o src/de_funcs.o \
src/od_math.o src/od_iod.o src/od_solver.o src/od_funcs.o \ src/od_math.o src/od_iod.o src/od_solver.o src/od_funcs.o \
src/spgist_tle.o src/spgist_tle.o \
src/orbital_elements_type.o \
src/equatorial_funcs.o \
src/refraction_funcs.o
# Vendored SGP4/SDP4 sources (pure C, from Bill Gray's sat_code, MIT license) # Vendored SGP4/SDP4 sources (pure C, from Bill Gray's sat_code, MIT license)
SGP4_DIR = src/sgp4 SGP4_DIR = src/sgp4
@ -33,7 +38,7 @@ OBJS += $(SGP4_OBJS)
# Regression tests # Regression tests
REGRESS = tle_parse sgp4_propagate coord_transforms pass_prediction gist_index convenience \ REGRESS = tle_parse sgp4_propagate coord_transforms pass_prediction gist_index convenience \
star_observe kepler_comet planet_observe moon_observe lambert_transfer \ star_observe kepler_comet planet_observe moon_observe lambert_transfer \
de_ephemeris od_fit spgist_tle vallado_518 de_ephemeris od_fit spgist_tle orbital_elements equatorial refraction vallado_518
REGRESS_OPTS = --inputdir=test REGRESS_OPTS = --inputdir=test
# Pure C — no C++ runtime needed. LAPACK for OD solver (dgelss_). # Pure C — no C++ runtime needed. LAPACK for OD solver (dgelss_).

Binary file not shown.

View File

@ -1,6 +1,6 @@
# pg_orrery — Complete LLM Reference # pg_orrery — Complete LLM Reference
> Celestial mechanics types and functions for PostgreSQL. Native C extension (v0.8.0) with 82 SQL functions, 8 custom types + 1 composite, GiST/SP-GiST indexing. All functions PARALLEL SAFE. > Celestial mechanics types and functions for PostgreSQL. Native C extension (v0.9.0) with 106 SQL functions, 9 custom types + 1 composite, GiST/SP-GiST indexing. All functions PARALLEL SAFE.
- Source: https://git.supported.systems/warehack.ing/pg_orrery - Source: https://git.supported.systems/warehack.ing/pg_orrery
- Docs: https://pg-orrery.warehack.ing - Docs: https://pg-orrery.warehack.ing
@ -110,6 +110,17 @@ SELECT oe_from_mpc('00001 3.52 0.15 K249V 14.81198 ...fixed-width MPC line
Accessors: `oe_epoch` (JD), `oe_perihelion` (AU), `oe_eccentricity`, `oe_inclination` (degrees), `oe_arg_perihelion` (degrees), `oe_raan` (degrees), `oe_tp` (JD), `oe_h_mag` (NaN if unknown), `oe_g_slope` (NaN if unknown), `oe_semi_major_axis` (AU, NULL if e≥1), `oe_period_years` (NULL if e≥1). Accessors: `oe_epoch` (JD), `oe_perihelion` (AU), `oe_eccentricity`, `oe_inclination` (degrees), `oe_arg_perihelion` (degrees), `oe_raan` (degrees), `oe_tp` (JD), `oe_h_mag` (NaN if unknown), `oe_g_slope` (NaN if unknown), `oe_semi_major_axis` (AU, NULL if e≥1), `oe_period_years` (NULL if e≥1).
### equatorial (24 bytes)
Apparent equatorial coordinates of date: RA, Dec, distance. Solar system bodies: J2000 precessed via IAU 1976. Satellites: TEME frame (~of-date to ~arcsecond).
```sql
-- Output format: (ra_hours, dec_degrees, distance_km)
-- Example: (4.29220000,20.60000000,885412345.678)
```
Accessors: `eq_ra(equatorial) → float8` (hours [0,24)), `eq_dec(equatorial) → float8` (degrees [-90,90]), `eq_distance(equatorial) → float8` (km; 0 for stars without parallax).
### observer_window (composite) ### observer_window (composite)
Query parameter bundle for SP-GiST visibility cone operator. Query parameter bundle for SP-GiST visibility cone operator.
@ -156,7 +167,7 @@ Fields: `obs` (observer), `t_start` (timestamptz), `t_end` (timestamptz), `min_e
## Functions by Domain ## Functions by Domain
### Satellite — SGP4/SDP4 Propagation (22 functions) ### Satellite — SGP4/SDP4 Propagation (25 functions)
``` ```
sgp4_propagate(tle, timestamptz) → eci_position IMMUTABLE sgp4_propagate(tle, timestamptz) → eci_position IMMUTABLE
@ -166,6 +177,8 @@ tle_distance(tle, tle, timestamptz) → float8 IMMUTAB
eci_to_geodetic(eci_position, timestamptz) → geodetic IMMUTABLE eci_to_geodetic(eci_position, timestamptz) → geodetic IMMUTABLE
eci_to_topocentric(eci_position, observer, timestamptz) → topocentric IMMUTABLE eci_to_topocentric(eci_position, observer, timestamptz) → topocentric IMMUTABLE
eci_to_equatorial(eci_position, observer, timestamptz) → equatorial IMMUTABLE -- topocentric RA/Dec (parallax-corrected)
eci_to_equatorial_geo(eci_position, timestamptz) → equatorial IMMUTABLE -- geocentric RA/Dec (observer-independent)
subsatellite_point(tle, timestamptz) → geodetic IMMUTABLE subsatellite_point(tle, timestamptz) → geodetic IMMUTABLE
ground_track(tle, start, end, step) → SETOF (t, lat, lon, alt) IMMUTABLE ground_track(tle, start, end, step) → SETOF (t, lat, lon, alt) IMMUTABLE
@ -174,6 +187,7 @@ observe_safe(tle, observer, timestamptz) → topocentric IMMUTAB
next_pass(tle, observer, timestamptz) → pass_event STABLE -- searches up to 7 days next_pass(tle, observer, timestamptz) → pass_event STABLE -- searches up to 7 days
predict_passes(tle, observer, start, end, min_el DEFAULT 0.0) → SETOF pass_event STABLE predict_passes(tle, observer, start, end, min_el DEFAULT 0.0) → SETOF pass_event STABLE
predict_passes_refracted(tle, observer, start, end, min_el DEFAULT 0.0) → SETOF pass_event STABLE -- refracted horizon (-0.569°)
pass_visible(tle, observer, start, end) → boolean STABLE pass_visible(tle, observer, start, end) → boolean STABLE
tle_from_lines(text, text) → tle IMMUTABLE tle_from_lines(text, text) → tle IMMUTABLE
@ -182,13 +196,24 @@ observer_from_geodetic(lat_deg, lon_deg, alt_m DEFAULT 0.0) → observer IMMUTAB
TLE accessors (15): `tle_epoch`, `tle_norad_id`, `tle_inclination`, `tle_eccentricity`, `tle_raan`, `tle_arg_perigee`, `tle_mean_anomaly`, `tle_mean_motion`, `tle_bstar`, `tle_period`, `tle_age`, `tle_perigee`, `tle_apogee`, `tle_intl_desig`, `tle_from_lines`. TLE accessors (15): `tle_epoch`, `tle_norad_id`, `tle_inclination`, `tle_eccentricity`, `tle_raan`, `tle_arg_perigee`, `tle_mean_anomaly`, `tle_mean_motion`, `tle_bstar`, `tle_period`, `tle_age`, `tle_perigee`, `tle_apogee`, `tle_intl_desig`, `tle_from_lines`.
### Solar System — VSOP87 + ELP2000-82B (5 functions) ### Solar System — VSOP87 + ELP2000-82B (14 functions)
``` ```
planet_heliocentric(body_id int4, timestamptz) → heliocentric IMMUTABLE -- IDs 0-8 planet_heliocentric(body_id int4, timestamptz) → heliocentric IMMUTABLE -- IDs 0-8
planet_observe(body_id int4, observer, timestamptz) → topocentric IMMUTABLE -- IDs 1-8 planet_observe(body_id int4, observer, timestamptz) → topocentric IMMUTABLE -- IDs 1-8
sun_observe(observer, timestamptz) → topocentric IMMUTABLE sun_observe(observer, timestamptz) → topocentric IMMUTABLE
moon_observe(observer, timestamptz) → topocentric IMMUTABLE moon_observe(observer, timestamptz) → topocentric IMMUTABLE
-- Equatorial RA/Dec (apparent, of date)
planet_equatorial(body_id int4, timestamptz) → equatorial IMMUTABLE -- geocentric
sun_equatorial(timestamptz) → equatorial IMMUTABLE
moon_equatorial(timestamptz) → equatorial IMMUTABLE
-- Light-time corrected (body at retarded time, Earth at observation time)
planet_observe_apparent(body_id int4, observer, timestamptz) → topocentric IMMUTABLE
sun_observe_apparent(observer, timestamptz) → topocentric IMMUTABLE
planet_equatorial_apparent(body_id int4, timestamptz) → equatorial IMMUTABLE
moon_equatorial_apparent(timestamptz) → equatorial IMMUTABLE
``` ```
### Planetary Moons (4 functions) ### Planetary Moons (4 functions)
@ -200,16 +225,21 @@ uranus_moon_observe(moon_id int4, observer, timestamptz) → topocentric IMMUTAB
mars_moon_observe(moon_id int4, observer, timestamptz) → topocentric IMMUTABLE -- MarsSat, IDs 0-1 mars_moon_observe(moon_id int4, observer, timestamptz) → topocentric IMMUTABLE -- MarsSat, IDs 0-1
``` ```
### Stars (2 functions) ### Stars (5 functions)
``` ```
star_observe(ra_hours float8, dec_degrees float8, observer, timestamptz) → topocentric IMMUTABLE star_observe(ra_hours float8, dec_degrees float8, observer, timestamptz) → topocentric IMMUTABLE
star_observe_safe(ra_hours float8, dec_degrees float8, observer, timestamptz) → topocentric IMMUTABLE -- NULL on error star_observe_safe(ra_hours float8, dec_degrees float8, observer, timestamptz) → topocentric IMMUTABLE -- NULL on error
star_equatorial(ra_hours, dec_degrees, timestamptz) → equatorial IMMUTABLE -- precesses J2000 to date
-- Proper motion (Hipparcos/Gaia convention: pm_ra = mu_alpha * cos(delta) in mas/yr)
star_observe_pm(ra_h, dec_deg, pm_ra_masyr, pm_dec_masyr, parallax_mas, rv_kms, observer, timestamptz) → topocentric IMMUTABLE
star_equatorial_pm(ra_h, dec_deg, pm_ra_masyr, pm_dec_masyr, parallax_mas, rv_kms, timestamptz) → equatorial IMMUTABLE
``` ```
RA in hours [0,24), Dec in degrees [-90,90]. Range returned as 0 (infinite distance). RA in hours [0,24), Dec in degrees [-90,90]. Range returned as 0 (infinite distance) unless parallax > 0 in _pm variants.
### Comets & Asteroids — Keplerian + MPC (5 functions) ### Comets & Asteroids — Keplerian + MPC (9 functions)
``` ```
kepler_propagate(q_au, eccentricity, inc_deg, arg_peri_deg, raan_deg, perihelion_jd, timestamptz) → heliocentric IMMUTABLE kepler_propagate(q_au, eccentricity, inc_deg, arg_peri_deg, raan_deg, perihelion_jd, timestamptz) → heliocentric IMMUTABLE
@ -217,6 +247,9 @@ comet_observe(q_au, e, inc, omega, Omega, tp_jd, earth_x, earth_y, earth_z, obse
oe_from_mpc(text) → orbital_elements IMMUTABLE -- parse MPC MPCORB.DAT line oe_from_mpc(text) → orbital_elements IMMUTABLE -- parse MPC MPCORB.DAT line
small_body_heliocentric(orbital_elements, timestamptz) → heliocentric IMMUTABLE small_body_heliocentric(orbital_elements, timestamptz) → heliocentric IMMUTABLE
small_body_observe(orbital_elements, observer, timestamptz) → topocentric IMMUTABLE -- auto-fetches Earth via VSOP87 small_body_observe(orbital_elements, observer, timestamptz) → topocentric IMMUTABLE -- auto-fetches Earth via VSOP87
small_body_equatorial(orbital_elements, timestamptz) → equatorial IMMUTABLE -- geocentric RA/Dec
small_body_observe_apparent(orbital_elements, observer, timestamptz) → topocentric IMMUTABLE -- light-time corrected
small_body_equatorial_apparent(orbital_elements, timestamptz) → equatorial IMMUTABLE -- light-time corrected RA/Dec
``` ```
orbital_elements accessors (11): `oe_epoch`, `oe_perihelion`, `oe_eccentricity`, `oe_inclination`, `oe_arg_perihelion`, `oe_raan`, `oe_tp`, `oe_h_mag`, `oe_g_slope`, `oe_semi_major_axis`, `oe_period_years`. orbital_elements accessors (11): `oe_epoch`, `oe_perihelion`, `oe_eccentricity`, `oe_inclination`, `oe_arg_perihelion`, `oe_raan`, `oe_tp`, `oe_h_mag`, `oe_g_slope`, `oe_semi_major_axis`, `oe_period_years`.
@ -239,7 +272,18 @@ lambert_c3(dep_body int4, arr_body int4, dep_time, arr_time) → float8 IMMUTA
Body IDs 18 (MercuryNeptune). C3 in km²/s², v_inf in km/s, TOF in days, SMA in AU. Body IDs 18 (MercuryNeptune). C3 in km²/s², v_inf in km/s, TOF in days, SMA in AU.
### DE Ephemeris — Optional High-Precision (11 functions) ### Atmospheric Refraction — Bennett 1982 (4 functions)
```
atmospheric_refraction(elevation_deg float8) → float8 IMMUTABLE -- degrees; standard atmosphere P=1010, T=10°C
atmospheric_refraction_ext(elevation_deg, pressure_mbar, temp_celsius) → float8 IMMUTABLE -- with Meeus P/T correction
topo_elevation_apparent(topocentric) → float8 IMMUTABLE -- geometric + refraction, in degrees
predict_passes_refracted(tle, observer, start, end, min_el DEFAULT 0.0) → SETOF pass_event STABLE -- horizon at -0.569° geometric
```
Bennett formula: `R = 1/tan(h + 7.31/(h + 4.4))` arcminutes. Domain guard: clamps at -1°, returns 0.0 below. At horizon (0°) refraction is ~0.57°, meaning satellites become visible ~35 seconds earlier.
### DE Ephemeris — Optional High-Precision (13 functions)
All _de() functions fall back to VSOP87/ELP2000-82B when DE is unavailable. All STABLE (external file dependency). All _de() functions fall back to VSOP87/ELP2000-82B when DE is unavailable. All STABLE (external file dependency).
@ -254,6 +298,8 @@ galilean_observe_de(moon_id, observer, timestamptz) → topocentric STABLE
saturn_moon_observe_de(moon_id, observer, timestamptz) → topocentric STABLE saturn_moon_observe_de(moon_id, observer, timestamptz) → topocentric STABLE
uranus_moon_observe_de(moon_id, observer, timestamptz) → topocentric STABLE uranus_moon_observe_de(moon_id, observer, timestamptz) → topocentric STABLE
mars_moon_observe_de(moon_id, observer, timestamptz) → topocentric STABLE mars_moon_observe_de(moon_id, observer, timestamptz) → topocentric STABLE
planet_equatorial_de(body_id int4, timestamptz) → equatorial STABLE -- geocentric RA/Dec via DE
moon_equatorial_de(timestamptz) → equatorial STABLE -- geocentric RA/Dec via DE
pg_orrery_ephemeris_info() → (provider, file_path, start_jd, end_jd, version, au_km) STABLE pg_orrery_ephemeris_info() → (provider, file_path, start_jd, end_jd, version, au_km) STABLE
``` ```
@ -431,6 +477,42 @@ SELECT (tle_from_eci(
-- Returns: fitted_tle, iterations, rms_final, rms_initial, status, condition_number, covariance, nstate -- Returns: fitted_tle, iterations, rms_final, rms_initial, status, condition_number, covariance, nstate
``` ```
### Get RA/Dec for telescope GoTo
```sql
-- Planet RA/Dec (apparent, of date — what telescope mounts expect)
SELECT eq_ra(planet_equatorial(5, NOW())) AS jupiter_ra_hours,
eq_dec(planet_equatorial(5, NOW())) AS jupiter_dec_deg;
-- With light-time correction (Jupiter light-travel ~35-52 min)
SELECT eq_ra(planet_equatorial_apparent(5, NOW())) AS ra_h,
eq_dec(planet_equatorial_apparent(5, NOW())) AS dec_deg;
-- Star with proper motion (Barnard's Star from Hipparcos/Gaia catalog)
SELECT eq_ra(star_equatorial_pm(17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51, NOW())) AS ra_h,
eq_dec(star_equatorial_pm(17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51, NOW())) AS dec_deg;
```
### Apparent elevation with atmospheric refraction
```sql
-- Compare geometric vs apparent elevation
SELECT topo_elevation(obs) AS geometric_el,
topo_elevation_apparent(obs) AS apparent_el,
atmospheric_refraction(topo_elevation(obs)) AS refraction
FROM planet_observe(5, '40.0N 105.3W'::observer, NOW()) AS obs;
```
### Refracted satellite passes (extended visibility windows)
```sql
SELECT pass_aos_time(p), pass_max_elevation(p), pass_duration(p)
FROM satellites,
LATERAL predict_passes_refracted(elements, '40.0N 105.3W 1655m'::observer,
NOW(), NOW() + '3 days'::interval, 10.0) AS p
WHERE name = 'ISS';
```
## Error Handling ## Error Handling
### _safe() variants ### _safe() variants
@ -480,6 +562,7 @@ AU = 149597870.7 km (IAU 2012)
Gauss k = 0.01720209895 AU^(3/2)/day Gauss k = 0.01720209895 AU^(3/2)/day
Obliquity J2000 = 23.4392911° Obliquity J2000 = 23.4392911°
J2000 epoch = JD 2451545.0 (2000 Jan 1.5 TT) J2000 epoch = JD 2451545.0 (2000 Jan 1.5 TT)
c (light) = 173.1446327 AU/day (for light-time correction)
``` ```
### Critical rule ### Critical rule

View File

@ -1,6 +1,6 @@
# pg_orrery # pg_orrery
> Celestial mechanics types and functions for PostgreSQL. Native C extension with 82 SQL functions, 8 custom types, GiST/SP-GiST indexing. Covers satellites (SGP4/SDP4), planets (VSOP87), Moon (ELP2000-82B), 19 planetary moons, stars, comets, asteroids (MPC catalog), Jupiter radio bursts, orbit determination, and interplanetary Lambert transfers. Optional JPL DE440/441 ephemeris for sub-arcsecond accuracy. > Celestial mechanics types and functions for PostgreSQL. Native C extension with 106 SQL functions, 9 custom types, GiST/SP-GiST indexing. Covers satellites (SGP4/SDP4), planets (VSOP87), Moon (ELP2000-82B), 19 planetary moons, stars (with proper motion), comets, asteroids (MPC catalog), Jupiter radio bursts, orbit determination, interplanetary Lambert transfers, equatorial RA/Dec coordinates, atmospheric refraction, and light-time correction. Optional JPL DE440/441 ephemeris for sub-arcsecond accuracy.
- [Source code](https://git.supported.systems/warehack.ing/pg_orrery) - [Source code](https://git.supported.systems/warehack.ing/pg_orrery)
- [Full LLM reference](https://pg-orrery.warehack.ing/llms-full.txt): All function signatures, types, body IDs, operators, and query patterns inline - [Full LLM reference](https://pg-orrery.warehack.ing/llms-full.txt): All function signatures, types, body IDs, operators, and query patterns inline
@ -39,14 +39,15 @@
## Reference ## Reference
- [Types](https://pg-orrery.warehack.ing/reference/types/): 8 fixed-size types — tle (112B), eci_position (48B), geodetic (24B), topocentric (32B), observer (24B), pass_event (48B), heliocentric (24B), orbital_elements (72B), plus observer_window composite - [Types](https://pg-orrery.warehack.ing/reference/types/): 9 fixed-size types — tle (112B), eci_position (48B), geodetic (24B), topocentric (32B), observer (24B), pass_event (48B), heliocentric (24B), orbital_elements (72B), equatorial (24B), plus observer_window composite
- [Functions: Satellite](https://pg-orrery.warehack.ing/reference/functions-satellite/): 22 functions — SGP4/SDP4 propagation, coordinate transforms, pass prediction, observation - [Functions: Satellite](https://pg-orrery.warehack.ing/reference/functions-satellite/): 22 functions — SGP4/SDP4 propagation, coordinate transforms, pass prediction, observation, satellite RA/Dec (topocentric + geocentric)
- [Functions: Solar System](https://pg-orrery.warehack.ing/reference/functions-solar-system/): VSOP87 planets, Sun, Moon observation and heliocentric positions - [Functions: Solar System](https://pg-orrery.warehack.ing/reference/functions-solar-system/): VSOP87 planets, Sun, Moon — observation, heliocentric positions, equatorial RA/Dec, light-time corrected _apparent() variants
- [Functions: Moons](https://pg-orrery.warehack.ing/reference/functions-moons/): Galilean, Saturn, Uranus, Mars moon observation via analytical theories - [Functions: Moons](https://pg-orrery.warehack.ing/reference/functions-moons/): Galilean, Saturn, Uranus, Mars moon observation via analytical theories
- [Functions: Stars & Comets](https://pg-orrery.warehack.ing/reference/functions-stars-comets/): Star observation, Keplerian propagation, comet/asteroid observation, MPC parsing, orbital_elements functions - [Functions: Stars & Comets](https://pg-orrery.warehack.ing/reference/functions-stars-comets/): Star observation with proper motion, Keplerian propagation, comet/asteroid observation + RA/Dec, MPC parsing, orbital_elements functions
- [Functions: Radio](https://pg-orrery.warehack.ing/reference/functions-radio/): Jupiter decametric radio burst prediction — Io phase, CML, burst probability - [Functions: Radio](https://pg-orrery.warehack.ing/reference/functions-radio/): Jupiter decametric radio burst prediction — Io phase, CML, burst probability
- [Functions: Transfers](https://pg-orrery.warehack.ing/reference/functions-transfers/): Lambert transfer solver for interplanetary trajectory design - [Functions: Transfers](https://pg-orrery.warehack.ing/reference/functions-transfers/): Lambert transfer solver for interplanetary trajectory design
- [Functions: DE Ephemeris](https://pg-orrery.warehack.ing/reference/functions-de/): Optional JPL DE440/441 variants of all observation functions - [Functions: Refraction](https://pg-orrery.warehack.ing/reference/functions-refraction/): Bennett (1982) atmospheric refraction, P/T correction, apparent elevation, refracted pass prediction
- [Functions: DE Ephemeris](https://pg-orrery.warehack.ing/reference/functions-de/): Optional JPL DE440/441 variants of observation and equatorial functions
- [Functions: Orbit Determination](https://pg-orrery.warehack.ing/reference/functions-od/): TLE fitting from ECI, topocentric, and angles-only observations - [Functions: Orbit Determination](https://pg-orrery.warehack.ing/reference/functions-od/): TLE fitting from ECI, topocentric, and angles-only observations
- [Operators & Indexes](https://pg-orrery.warehack.ing/reference/operators-gist/): GiST (&&, <->) and SP-GiST (&?) operator classes for orbital indexing - [Operators & Indexes](https://pg-orrery.warehack.ing/reference/operators-gist/): GiST (&&, <->) and SP-GiST (&?) operator classes for orbital indexing
- [Body ID Reference](https://pg-orrery.warehack.ing/reference/body-ids/): Planet IDs 010, Galilean 03, Saturn 07, Uranus 04, Mars 01 - [Body ID Reference](https://pg-orrery.warehack.ing/reference/body-ids/): Planet IDs 010, Galilean 03, Saturn 07, Uranus 04, Mars 01

View File

@ -32,9 +32,9 @@ The two operators:
| Operator | Type | What it checks | | Operator | Type | What it checks |
|---|---|---| |---|---|---|
| `tle && tle` | boolean | Altitude band AND inclination range overlap | | `tle && tle` | boolean | Altitude band AND inclination range overlap |
| `tle <-> tle` | float8 | Minimum altitude-band separation in km | | `tle <-> tle` | float8 | 2-D orbital distance in km (altitude + inclination) |
The `&&` operator is used for overlap queries (find all objects in the same shell). The `<->` operator is used for nearest-neighbor queries (find the N closest objects by altitude separation). The `&&` operator is used for overlap queries (find all objects in the same shell). The `<->` operator is used for nearest-neighbor queries (find the N closest objects by orbital distance, combining altitude gap with inclination gap converted to km).
## What pg_orrery does not replace ## What pg_orrery does not replace
@ -44,7 +44,7 @@ GiST-based conjunction screening is a coarse filter. It finds candidates that sh
- **Not a probability of collision.** pg_orrery does not compute Pc (probability of collision). It identifies objects in overlapping orbital shells and computes distances at discrete time steps. For Pc calculation, use CARA (Conjunction Assessment Risk Analysis) methods. - **Not a probability of collision.** pg_orrery does not compute Pc (probability of collision). It identifies objects in overlapping orbital shells and computes distances at discrete time steps. For Pc calculation, use CARA (Conjunction Assessment Risk Analysis) methods.
- **No covariance propagation.** SGP4 does not produce covariance matrices. The distance values have no uncertainty bounds. For operational conjunction assessment, use SP ephemerides with covariance (from CDMs or owner/operator data). - **No covariance propagation.** SGP4 does not produce covariance matrices. The distance values have no uncertainty bounds. For operational conjunction assessment, use SP ephemerides with covariance (from CDMs or owner/operator data).
- **Altitude-band approximation.** The GiST key uses perigee-to-apogee altitude as a 1-D range and inclination as a second dimension. Two TLEs can share an altitude shell and never approach because their RAANs or phases are far apart. Always follow GiST filtering with full propagation. - **Orbital envelope approximation.** The GiST key uses perigee-to-apogee altitude and inclination as a 2-D bounding box. The `<->` distance combines both dimensions. Two TLEs can still be close in this metric and never approach because their RAANs or phases are far apart. Always follow GiST filtering with full propagation.
- **No maneuver planning.** pg_orrery identifies close approaches. It does not compute avoidance maneuvers (delta-v, timing, constraints). - **No maneuver planning.** pg_orrery identifies close approaches. It does not compute avoidance maneuvers (delta-v, timing, constraints).
The workflow is: GiST narrows → `tle_distance()` verifies → operator/analyst decides. The workflow is: GiST narrows → `tle_distance()` verifies → operator/analyst decides.
@ -120,20 +120,20 @@ ORDER BY a.name, b.name;
Key insight: ISS and Equatorial-LEO are at the same altitude but different inclinations. The `&&` operator returns **false** for this pair because the 2-D key requires overlap in BOTH altitude AND inclination. Two objects at the same altitude but in very different orbital planes are unlikely to conjunct. Key insight: ISS and Equatorial-LEO are at the same altitude but different inclinations. The `&&` operator returns **false** for this pair because the 2-D key requires overlap in BOTH altitude AND inclination. Two objects at the same altitude but in very different orbital planes are unlikely to conjunct.
### Altitude-band distance with `<->` ### Orbital distance with `<->`
The `<->` operator returns the minimum separation between altitude bands, in km: The `<->` operator returns the 2-D orbital distance in km, combining altitude-band separation with inclination gap (converted to km via Earth radius):
```sql ```sql
SELECT a.name AS sat_a, SELECT a.name AS sat_a,
b.name AS sat_b, b.name AS sat_b,
round((a.tle <-> b.tle)::numeric, 0) AS alt_separation_km round((a.tle <-> b.tle)::numeric, 0) AS orbital_dist_km
FROM catalog a, catalog b FROM catalog a, catalog b
WHERE a.norad_id < b.norad_id WHERE a.norad_id < b.norad_id
ORDER BY a.tle <-> b.tle; ORDER BY a.tle <-> b.tle;
``` ```
ISS and Equatorial-LEO should show ~0 km separation (same altitude shell). ISS and GPS should show ~19,800 km (vastly different orbits). ISS and Equatorial-LEO show ~5192 km (0 km altitude gap, but 47° inclination difference × 6378 km/rad). ISS and Hubble show ~2582 km (115 km altitude gap + 23° inclination difference). ISS and GPS show ~19,456 km (altitude gap dominates).
### GiST index scan: find overlapping orbits ### GiST index scan: find overlapping orbits
@ -152,22 +152,22 @@ RESET enable_seqscan;
This should return only ISS itself (and not Equatorial-LEO, which has a different inclination). The GiST index scan avoids checking every object in the catalog. This should return only ISS itself (and not Equatorial-LEO, which has a different inclination). The GiST index scan avoids checking every object in the catalog.
### K-nearest-neighbor by altitude ### K-nearest-neighbor by orbital distance
Find the 3 closest objects to the ISS by altitude band separation, ordered by distance: Find the 3 closest objects to the ISS by 2-D orbital distance, ordered by distance:
```sql ```sql
-- Scalar subquery probe enables GiST index-ordered scan -- Scalar subquery probe enables GiST index-ordered scan
SELECT name, SELECT name,
round((tle <-> (SELECT tle FROM catalog WHERE norad_id = 25544 LIMIT 1))::numeric, 0) round((tle <-> (SELECT tle FROM catalog WHERE norad_id = 25544 LIMIT 1))::numeric, 0)
AS alt_dist_km AS orbital_dist_km
FROM catalog FROM catalog
WHERE norad_id != 25544 WHERE norad_id != 25544
ORDER BY tle <-> (SELECT tle FROM catalog WHERE norad_id = 25544 LIMIT 1) ORDER BY tle <-> (SELECT tle FROM catalog WHERE norad_id = 25544 LIMIT 1)
LIMIT 3; LIMIT 3;
``` ```
This uses the GiST distance operator for efficient ordering. PostgreSQL's KNN-GiST infrastructure traverses the tree by increasing distance without computing all distances upfront. On a 66,440-object catalog, this completes in 2.1 ms for 10 neighbors. This uses the GiST distance operator for efficient ordering. The 2-D metric means satellites at the same altitude but wildly different inclinations no longer tie at distance 0 --- Hubble (inc 28°, 115 km altitude gap) ranks ahead of an equatorial LEO object (inc 5°, 0 km altitude gap but 47° inclination difference). PostgreSQL's KNN-GiST infrastructure traverses the tree by increasing distance without computing all distances upfront. On a 66,440-object catalog, this completes in 2.1 ms for 10 neighbors.
<Aside type="caution" title="Use scalar subqueries, not CTEs"> <Aside type="caution" title="Use scalar subqueries, not CTEs">
GiST index-ordered scans require the probe value to be visible to the planner as a constant. A `WITH iss AS (...)` CTE makes the probe opaque, forcing a full sequential scan and sort. Always use `(SELECT tle FROM ... LIMIT 1)` as the probe argument for KNN queries on large catalogs. GiST index-ordered scans require the probe value to be visible to the planner as a constant. A `WITH iss AS (...)` CTE makes the probe opaque, forcing a full sequential scan and sort. Always use `(SELECT tle FROM ... LIMIT 1)` as the probe argument for KNN queries on large catalogs.

View File

@ -19,7 +19,7 @@ All benchmarks use PostgreSQL's `EXPLAIN (ANALYZE, BUFFERS)` for timing. The num
| TLE propagation (SGP4) | 12,000 | 17 ms | 706K/sec | Mixed LEO/MEO/GEO | | TLE propagation (SGP4) | 12,000 | 17 ms | 706K/sec | Mixed LEO/MEO/GEO |
| Visibility cone filter (`&?`) | 66,440 | 12.1 ms | 5.5M/sec | 84% pruned (2h, 10°), no SGP4 | | Visibility cone filter (`&?`) | 66,440 | 12.1 ms | 5.5M/sec | 84% pruned (2h, 10°), no SGP4 |
| Conjunction screening (`&&`) | 66,440 | 4.6 ms | — | ISS: 9 co-orbital objects found | | Conjunction screening (`&&`) | 66,440 | 4.6 ms | — | ISS: 9 co-orbital objects found |
| KNN altitude ordering (`<->`) | 66,440 | 2.1 ms | — | 10 nearest to ISS, index-ordered | | KNN orbital distance (`<->`) | 66,440 | 2.1 ms | — | 10 nearest to ISS, 2-D index-ordered |
| Planet observation (VSOP87) | 875 | 57 ms | 15.4K/sec | All 7 non-Earth planets, 125 times each | | Planet observation (VSOP87) | 875 | 57 ms | 15.4K/sec | All 7 non-Earth planets, 125 times each |
| Galilean moon observation | 1,000 | 63 ms | 15.9K/sec | L1.2 + VSOP87 pipeline | | Galilean moon observation | 1,000 | 63 ms | 15.9K/sec | L1.2 + VSOP87 pipeline |
| Saturn moon observation | 800 | 53 ms | 15.1K/sec | TASS17 + VSOP87 | | Saturn moon observation | 800 | 53 ms | 15.1K/sec | TASS17 + VSOP87 |
@ -317,17 +317,17 @@ The GiST index provides the largest speedup for queries that return few matches,
| Index size | 15 MB (237 bytes/object) | | Index size | 15 MB (237 bytes/object) |
| Consistency | 0 false positives, 0 false negatives (verified against seqscan) | | Consistency | 0 false positives, 0 false negatives (verified against seqscan) |
## KNN altitude ordering (`<->` operator) ## KNN orbital distance (`<->` operator)
The `<->` operator computes altitude-band separation in km. With a GiST index, it supports index-ordered KNN queries --- PostgreSQL traverses the tree by increasing distance without computing all distances upfront. The `<->` operator computes 2-D orbital distance in km, combining altitude-band separation with inclination gap (converted to km via Earth radius). With a GiST index, it supports index-ordered KNN queries --- PostgreSQL traverses the tree by increasing distance without computing all distances upfront.
```sql ```sql
-- Benchmark: 10 nearest orbits to the ISS by altitude separation -- Benchmark: 10 nearest orbits to the ISS by 2-D orbital distance
EXPLAIN (ANALYZE, BUFFERS) EXPLAIN (ANALYZE, BUFFERS)
SELECT name, SELECT name,
round((tle <-> (SELECT tle FROM satellite_catalog round((tle <-> (SELECT tle FROM satellite_catalog
WHERE tle_norad_id(tle) = 25544 LIMIT 1))::numeric, 1) WHERE tle_norad_id(tle) = 25544 LIMIT 1))::numeric, 1)
AS alt_sep_km AS orbital_dist_km
FROM satellite_catalog FROM satellite_catalog
ORDER BY tle <-> (SELECT tle FROM satellite_catalog ORDER BY tle <-> (SELECT tle FROM satellite_catalog
WHERE tle_norad_id(tle) = 25544 LIMIT 1) WHERE tle_norad_id(tle) = 25544 LIMIT 1)
@ -340,7 +340,7 @@ LIMIT 10;
| Query | Time | Buffers | Notes | | Query | Time | Buffers | Notes |
|-------|------|---------|-------| |-------|------|---------|-------|
| 10 nearest to ISS (LEO) | 2.1 ms | 982 | Dense regime, more nodes traversed | | 10 nearest to ISS (LEO) | 2.1 ms | 982 | Dense regime, 2-D distance breaks altitude ties |
| 10 nearest to SYNCOM 2 (GEO) | 0.2 ms | 40 | Sparse regime, fewer nodes | | 10 nearest to SYNCOM 2 (GEO) | 0.2 ms | 40 | Sparse regime, fewer nodes |
| 100 nearest to ISS | 1.4 ms | 1,062 | Marginal cost per additional neighbor | | 100 nearest to ISS | 1.4 ms | 1,062 | Marginal cost per additional neighbor |
| All within 50 km of ISS | 16.0 ms | 4,014 | 12,496 matches | | All within 50 km of ISS | 16.0 ms | 4,014 | 12,496 matches |
@ -414,6 +414,6 @@ The benchmarks demonstrate that pg_orrery's computation cost is low enough to tr
The visibility cone filter (`&?`) is the fastest operation per evaluation --- three floating-point comparisons vs. the full SGP4 pipeline --- and its 84--90% pruning rate means the most expensive operation in a pass prediction pipeline (SGP4 propagation) only runs on the small fraction of the catalog that could actually produce a visible pass. The visibility cone filter (`&?`) is the fastest operation per evaluation --- three floating-point comparisons vs. the full SGP4 pipeline --- and its 84--90% pruning rate means the most expensive operation in a pass prediction pipeline (SGP4 propagation) only runs on the small fraction of the catalog that could actually produce a visible pass.
The GiST index provides the clearest speedup for conjunction screening: 5.8x faster than sequential scan for ISS `&&` queries, with 0 false positives or negatives verified against exhaustive sequential evaluation. KNN queries find the nearest orbits in 2 ms via index-ordered traversal, which would otherwise require computing and sorting all 66,440 distances. The GiST index provides the clearest speedup for conjunction screening: 5.8x faster than sequential scan for ISS `&&` queries, with 0 false positives or negatives verified against exhaustive sequential evaluation. KNN queries find the nearest orbits in 2 ms via index-ordered traversal using 2-D orbital distance (altitude + inclination), which would otherwise require computing and sorting all 66,440 distances.
The numbers also show where the bottlenecks are: VSOP87 series evaluation dominates everything except star observation, raw SGP4 propagation, and the geometric filters. If a future optimization effort targets one component, it should be the VSOP87 evaluation loop. The numbers also show where the bottlenecks are: VSOP87 series evaluation dominates everything except star observation, raw SGP4 propagation, and the geometric filters. If a future optimization effort targets one component, it should be the VSOP87 evaluation loop.

View File

@ -51,7 +51,7 @@ WHERE satellite_catalog.tle && iss.tle;
### `<->` (Distance) ### `<->` (Distance)
Computes the minimum separation between the altitude bands of two TLEs, in kilometers. If the altitude bands overlap, returns 0. Computes the 2-D orbital distance between two TLEs, in kilometers. Combines altitude-band separation with inclination gap (converted to km via Earth radius), returning the L2 norm. Returns 0 only when both altitude bands AND inclination ranges overlap.
#### Signature #### Signature
@ -61,16 +61,16 @@ tle <-> tle → float8
#### Description #### Description
This is an altitude-only metric. It computes: The distance metric combines two components:
- `max(0, perigee_a - apogee_b)` and `max(0, perigee_b - apogee_a)` - **Altitude gap:** minimum separation between perigee-to-apogee bands, in km
- Returns the minimum of these two values - **Inclination gap:** angular difference in radians, converted to km by multiplying by Earth's radius (WGS-72: 6378.135 km)
The result is the minimum possible radial separation. A result of 0 means the altitude bands overlap (but the satellites may still be far apart in along-track or cross-track distance). The result is `sqrt(alt_gap² + inc_km²)`. Two satellites at the same altitude but with a 90° inclination difference report ~6378 km distance. Two satellites at vastly different altitudes but similar inclinations are dominated by the altitude gap. A result of 0 means both the altitude bands and inclination ranges overlap.
#### Example #### Example
```sql ```sql
-- Altitude band separation between ISS and a GEO satellite -- Orbital distance between ISS and a GEO satellite
WITH iss AS ( WITH iss AS (
SELECT '1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025 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 tle 2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS tle
@ -79,17 +79,17 @@ geo AS (
SELECT '1 28884U 05041A 24001.50000000 -.00000089 00000-0 00000-0 0 9997 SELECT '1 28884U 05041A 24001.50000000 -.00000089 00000-0 00000-0 0 9997
2 28884 0.0153 93.0424 0001699 138.1498 336.5718 1.00271128 67481'::tle AS tle 2 28884 0.0153 93.0424 0001699 138.1498 336.5718 1.00271128 67481'::tle AS tle
) )
SELECT round((iss.tle <-> geo.tle)::numeric, 1) AS separation_km SELECT round((iss.tle <-> geo.tle)::numeric, 1) AS orbital_dist_km
FROM iss, geo; FROM iss, geo;
``` ```
```sql ```sql
-- Order catalog by altitude proximity to a target satellite -- Order catalog by orbital proximity to a target satellite
WITH target AS ( WITH target AS (
SELECT tle FROM satellite_catalog WHERE norad_id = 25544 SELECT tle FROM satellite_catalog WHERE norad_id = 25544
) )
SELECT norad_id, name, SELECT norad_id, name,
round((satellite_catalog.tle <-> target.tle)::numeric, 1) AS alt_sep_km round((satellite_catalog.tle <-> target.tle)::numeric, 1) AS orbital_dist_km
FROM satellite_catalog, target FROM satellite_catalog, target
ORDER BY satellite_catalog.tle <-> target.tle ORDER BY satellite_catalog.tle <-> target.tle
LIMIT 20; LIMIT 20;
@ -131,16 +131,16 @@ WHERE c.tle && iss.tle
AND c.norad_id != 25544; AND c.norad_id != 25544;
``` ```
</TabItem> </TabItem>
<TabItem label="kNN by altitude"> <TabItem label="kNN by orbital distance">
```sql ```sql
-- Find the 10 satellites with the closest altitude bands to the ISS -- Find the 10 satellites with the closest orbits to the ISS
-- The <-> operator supports GiST index ordering (ORDER BY ... <-> ...) -- The <-> operator supports GiST index ordering (ORDER BY ... <-> ...)
-- IMPORTANT: use a scalar subquery for the probe TLE so the planner -- IMPORTANT: use a scalar subquery for the probe TLE so the planner
-- can see it as a constant and activate index-ordered scan. -- can see it as a constant and activate index-ordered scan.
SELECT c.name, SELECT c.name,
round((c.tle <-> (SELECT tle FROM satellite_catalog round((c.tle <-> (SELECT tle FROM satellite_catalog
WHERE tle_norad_id(tle) = 25544 LIMIT 1))::numeric, 1) WHERE tle_norad_id(tle) = 25544 LIMIT 1))::numeric, 1)
AS alt_sep_km AS orbital_dist_km
FROM satellite_catalog c FROM satellite_catalog c
WHERE tle_norad_id(c.tle) != 25544 WHERE tle_norad_id(c.tle) != 25544
ORDER BY c.tle <-> (SELECT tle FROM satellite_catalog ORDER BY c.tle <-> (SELECT tle FROM satellite_catalog
@ -182,7 +182,7 @@ Benchmarked against a 66,440-object catalog (Space-Track + CelesTrak + SatNOGS):
| Query | GiST | Seqscan | Matches | Speedup | | Query | GiST | Seqscan | Matches | Speedup |
|-------|------|---------|---------|---------| |-------|------|---------|---------|---------|
| ISS conjunction (`&&`) | 4.6 ms | 63.3 ms | 9 | 5.8x | | ISS conjunction (`&&`) | 4.6 ms | 63.3 ms | 9 | 5.8x |
| 10 nearest to ISS (`<->` KNN) | 2.1 ms | — | 10 | Index-ordered | | 10 nearest to ISS (`<->` KNN) | 2.1 ms | — | 10 | Index-ordered (2-D orbital distance) |
| 10 nearest to GEO sat (`<->` KNN) | 0.2 ms | — | 10 | Sparse regime | | 10 nearest to GEO sat (`<->` KNN) | 0.2 ms | — | 10 | Sparse regime |
The GiST index (15 MB, 93 ms build) provides the clearest speedup for conjunction screening. The `&&` operator reduces the search from 1,338 buffer hits (sequential scan) to 237 buffer hits (index scan). KNN queries traverse the tree by increasing distance without computing all distances upfront. The GiST index (15 MB, 93 ms build) provides the clearest speedup for conjunction screening. The `&&` operator reduces the search from 1,338 buffer hits (sequential scan) to 237 buffer hits (index scan). KNN queries traverse the tree by increasing distance without computing all distances upfront.

View File

@ -1,4 +1,4 @@
comment = 'A database orrery — celestial mechanics types and functions for PostgreSQL' comment = 'A database orrery — celestial mechanics types and functions for PostgreSQL'
default_version = '0.7.0' default_version = '0.9.0'
module_pathname = '$libdir/pg_orrery' module_pathname = '$libdir/pg_orrery'
relocatable = true relocatable = true

View File

@ -470,7 +470,7 @@ CREATE OPERATOR <-> (
COMMUTATOR = <-> COMMUTATOR = <->
); );
COMMENT ON OPERATOR <-> (tle, tle) IS 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.'; COMMENT ON OPERATOR <-> (tle, tle) IS '2-D orbital distance in km: L2 norm of altitude-band gap and inclination gap (radians × Earth radius). Returns 0 when both dimensions overlap.';
-- ============================================================ -- ============================================================

View File

@ -470,7 +470,7 @@ CREATE OPERATOR <-> (
COMMUTATOR = <-> COMMUTATOR = <->
); );
COMMENT ON OPERATOR <-> (tle, tle) IS 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.'; COMMENT ON OPERATOR <-> (tle, tle) IS '2-D orbital distance in km: L2 norm of altitude-band gap and inclination gap (radians × Earth radius). Returns 0 when both dimensions overlap.';
-- ============================================================ -- ============================================================

View File

@ -470,7 +470,7 @@ CREATE OPERATOR <-> (
COMMUTATOR = <-> COMMUTATOR = <->
); );
COMMENT ON OPERATOR <-> (tle, tle) IS 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.'; COMMENT ON OPERATOR <-> (tle, tle) IS '2-D orbital distance in km: L2 norm of altitude-band gap and inclination gap (radians × Earth radius). Returns 0 when both dimensions overlap.';
-- ============================================================ -- ============================================================

View File

@ -470,7 +470,7 @@ CREATE OPERATOR <-> (
COMMUTATOR = <-> COMMUTATOR = <->
); );
COMMENT ON OPERATOR <-> (tle, tle) IS 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.'; COMMENT ON OPERATOR <-> (tle, tle) IS '2-D orbital distance in km: L2 norm of altitude-band gap and inclination gap (radians × Earth radius). Returns 0 when both dimensions overlap.';
-- ============================================================ -- ============================================================

View File

@ -470,7 +470,7 @@ CREATE OPERATOR <-> (
COMMUTATOR = <-> COMMUTATOR = <->
); );
COMMENT ON OPERATOR <-> (tle, tle) IS 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.'; COMMENT ON OPERATOR <-> (tle, tle) IS '2-D orbital distance in km: L2 norm of altitude-band gap and inclination gap (radians × Earth radius). Returns 0 when both dimensions overlap.';
-- ============================================================ -- ============================================================

View File

@ -470,7 +470,7 @@ CREATE OPERATOR <-> (
COMMUTATOR = <-> COMMUTATOR = <->
); );
COMMENT ON OPERATOR <-> (tle, tle) IS 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.'; COMMENT ON OPERATOR <-> (tle, tle) IS '2-D orbital distance in km: L2 norm of altitude-band gap and inclination gap (radians × Earth radius). Returns 0 when both dimensions overlap.';
-- ============================================================ -- ============================================================

View File

@ -0,0 +1,109 @@
-- pg_orrery 0.7.0 -> 0.8.0 migration
--
-- Adds orbital_elements type for comets/asteroids, MPC MPCORB.DAT parser,
-- and small_body_observe()/small_body_heliocentric() observation functions.
-- ============================================================
-- orbital_elements type
-- ============================================================
CREATE TYPE orbital_elements;
CREATE FUNCTION orbital_elements_in(cstring) RETURNS orbital_elements
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION orbital_elements_out(orbital_elements) RETURNS cstring
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION orbital_elements_recv(internal) RETURNS orbital_elements
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION orbital_elements_send(orbital_elements) RETURNS bytea
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE TYPE orbital_elements (
INPUT = orbital_elements_in,
OUTPUT = orbital_elements_out,
RECEIVE = orbital_elements_recv,
SEND = orbital_elements_send,
INTERNALLENGTH = 72,
ALIGNMENT = double,
STORAGE = plain
);
COMMENT ON TYPE orbital_elements IS
'Classical Keplerian orbital elements for comets and asteroids (epoch, q, e, inc, omega, Omega, tp, H, G). 72 bytes, fixed-size.';
-- ============================================================
-- Accessor functions
-- ============================================================
CREATE FUNCTION oe_epoch(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_epoch(orbital_elements) IS 'Osculation epoch (Julian date)';
CREATE FUNCTION oe_perihelion(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_perihelion(orbital_elements) IS 'Perihelion distance q (AU)';
CREATE FUNCTION oe_eccentricity(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_eccentricity(orbital_elements) IS 'Eccentricity';
CREATE FUNCTION oe_inclination(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_inclination(orbital_elements) IS 'Inclination (degrees)';
CREATE FUNCTION oe_arg_perihelion(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_arg_perihelion(orbital_elements) IS 'Argument of perihelion (degrees)';
CREATE FUNCTION oe_raan(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_raan(orbital_elements) IS 'Longitude of ascending node (degrees)';
CREATE FUNCTION oe_tp(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_tp(orbital_elements) IS 'Time of perihelion passage (Julian date)';
CREATE FUNCTION oe_h_mag(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_h_mag(orbital_elements) IS 'Absolute magnitude H (NaN if unknown)';
CREATE FUNCTION oe_g_slope(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_g_slope(orbital_elements) IS 'Slope parameter G (NaN if unknown)';
CREATE FUNCTION oe_semi_major_axis(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_semi_major_axis(orbital_elements) IS 'Semi-major axis a = q/(1-e) in AU. NULL for parabolic/hyperbolic orbits (e >= 1).';
CREATE FUNCTION oe_period_years(orbital_elements) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_period_years(orbital_elements) IS 'Orbital period in years = a^1.5 (Kepler third law). NULL for parabolic/hyperbolic orbits (e >= 1).';
-- ============================================================
-- MPC MPCORB.DAT parser
-- ============================================================
CREATE FUNCTION oe_from_mpc(text) RETURNS orbital_elements
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION oe_from_mpc(text) IS
'Parse one MPCORB.DAT fixed-width line into orbital_elements. Converts MPC packed epoch, computes perihelion distance and tp from (a, e, M).';
-- ============================================================
-- Observation functions
-- ============================================================
CREATE FUNCTION small_body_heliocentric(orbital_elements, timestamptz) RETURNS heliocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION small_body_heliocentric(orbital_elements, timestamptz) IS
'Heliocentric ecliptic J2000 position of a comet/asteroid from its orbital elements at a given time.';
CREATE FUNCTION small_body_observe(orbital_elements, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION small_body_observe(orbital_elements, observer, timestamptz) IS
'Observe a comet/asteroid from orbital elements. Auto-fetches Earth via VSOP87. Returns topocentric az/el with geocentric range in km.';

View File

@ -470,7 +470,7 @@ CREATE OPERATOR <-> (
COMMUTATOR = <-> COMMUTATOR = <->
); );
COMMENT ON OPERATOR <-> (tle, tle) IS 'Minimum altitude-band separation in km (0 if overlapping). Altitude-only — does not account for inclination. Use && for 2-D filtering.'; COMMENT ON OPERATOR <-> (tle, tle) IS '2-D orbital distance in km: L2 norm of altitude-band gap and inclination gap (radians × Earth radius). Returns 0 when both dimensions overlap.';
-- ============================================================ -- ============================================================

View File

@ -0,0 +1,204 @@
-- pg_orrery 0.8.0 -> 0.9.0 migration
--
-- Adds equatorial type (apparent RA/Dec of date), atmospheric refraction,
-- stellar proper motion, and light-time corrected _apparent() functions.
-- ============================================================
-- equatorial type — apparent RA/Dec of date
-- ============================================================
CREATE TYPE equatorial;
CREATE FUNCTION equatorial_in(cstring) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION equatorial_out(equatorial) RETURNS cstring
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION equatorial_recv(internal) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE FUNCTION equatorial_send(equatorial) RETURNS bytea
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
CREATE TYPE equatorial (
INPUT = equatorial_in,
OUTPUT = equatorial_out,
RECEIVE = equatorial_recv,
SEND = equatorial_send,
INTERNALLENGTH = 24,
ALIGNMENT = double,
STORAGE = plain
);
COMMENT ON TYPE equatorial IS
'Apparent equatorial coordinates of date: RA (hours), Dec (degrees), distance (km). Solar system: J2000 precessed via IAU 1976. Satellites: TEME frame (~of-date to ~arcsecond). 24 bytes, fixed-size.';
-- ============================================================
-- Equatorial accessor functions
-- ============================================================
CREATE FUNCTION eq_ra(equatorial) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eq_ra(equatorial) IS 'Right ascension in hours [0, 24)';
CREATE FUNCTION eq_dec(equatorial) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eq_dec(equatorial) IS 'Declination in degrees [-90, 90]';
CREATE FUNCTION eq_distance(equatorial) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eq_distance(equatorial) IS 'Distance in km (0 for stars without parallax)';
-- ============================================================
-- Satellite RA/Dec functions
-- ============================================================
CREATE FUNCTION eci_to_equatorial(eci_position, observer, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eci_to_equatorial(eci_position, observer, timestamptz) IS
'Topocentric apparent RA/Dec from ECI position. Observer parallax-corrected — LEO parallax is ~1 degree.';
CREATE FUNCTION eci_to_equatorial_geo(eci_position, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION eci_to_equatorial_geo(eci_position, timestamptz) IS
'Geocentric apparent RA/Dec from ECI position. Observer-independent — the direction of the TEME position vector.';
-- ============================================================
-- Solar system equatorial functions (VSOP87)
-- ============================================================
CREATE FUNCTION planet_equatorial(int4, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION planet_equatorial(int4, timestamptz) IS
'Geocentric apparent RA/Dec of a planet via VSOP87. Body IDs: 1=Mercury through 8=Neptune.';
CREATE FUNCTION sun_equatorial(timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION sun_equatorial(timestamptz) IS
'Geocentric apparent RA/Dec of the Sun via VSOP87.';
CREATE FUNCTION moon_equatorial(timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION moon_equatorial(timestamptz) IS
'Geocentric apparent RA/Dec of the Moon via ELP2000-82B.';
CREATE FUNCTION small_body_equatorial(orbital_elements, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION small_body_equatorial(orbital_elements, timestamptz) IS
'Geocentric apparent RA/Dec of a comet/asteroid from orbital elements. Earth via VSOP87.';
CREATE FUNCTION star_equatorial(float8, float8, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION star_equatorial(float8, float8, timestamptz) IS
'Apparent RA/Dec of a star at a given time. Precesses J2000 catalog coordinates (RA hours, Dec degrees) to date via IAU 1976.';
-- ============================================================
-- Atmospheric refraction (Bennett 1982)
-- ============================================================
CREATE FUNCTION atmospheric_refraction(float8) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION atmospheric_refraction(float8) IS
'Atmospheric refraction correction in degrees for a given geometric elevation (degrees). Standard atmosphere: P=1010 mbar, T=10C. Bennett (1982) formula with domain guard at -1 degree.';
CREATE FUNCTION atmospheric_refraction_ext(float8, float8, float8) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION atmospheric_refraction_ext(float8, float8, float8) IS
'Atmospheric refraction with pressure/temperature correction. Args: elevation_deg, pressure_mbar, temperature_celsius. Meeus P/T factor applied to Bennett formula.';
CREATE FUNCTION topo_elevation_apparent(topocentric) RETURNS float8
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION topo_elevation_apparent(topocentric) IS
'Apparent elevation in degrees — geometric elevation plus atmospheric refraction correction.';
-- ============================================================
-- Refracted pass prediction
-- ============================================================
CREATE FUNCTION predict_passes_refracted(
tle, observer, timestamptz, timestamptz, float8 DEFAULT 0.0
) RETURNS SETOF pass_event
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE
ROWS 20;
COMMENT ON FUNCTION predict_passes_refracted(tle, observer, timestamptz, timestamptz, float8) IS
'Predict satellite passes using a refracted horizon threshold (-0.569 deg geometric). Atmospheric refraction makes satellites visible ~35 seconds earlier at AOS and later at LOS.';
-- ============================================================
-- Stellar proper motion
-- ============================================================
CREATE FUNCTION star_observe_pm(
float8, float8, float8, float8, float8, float8, observer, timestamptz
) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION star_observe_pm(float8, float8, float8, float8, float8, float8, observer, timestamptz) IS
'Observe a star with proper motion. Args: ra_hours, dec_deg, pm_ra_masyr (mu_alpha*cos(delta)), pm_dec_masyr, parallax_mas, rv_kms, observer, time. Hipparcos/Gaia convention for pm_ra.';
CREATE FUNCTION star_equatorial_pm(
float8, float8, float8, float8, float8, float8, timestamptz
) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION star_equatorial_pm(float8, float8, float8, float8, float8, float8, timestamptz) IS
'Apparent RA/Dec of a star with proper motion. Args: ra_hours, dec_deg, pm_ra_masyr, pm_dec_masyr, parallax_mas, rv_kms, time. Distance from parallax if > 0.';
-- ============================================================
-- Light-time corrected observation functions
-- ============================================================
CREATE FUNCTION planet_observe_apparent(int4, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION planet_observe_apparent(int4, observer, timestamptz) IS
'Observe a planet with single-iteration light-time correction. Body at retarded time, Earth at observation time. VSOP87.';
CREATE FUNCTION sun_observe_apparent(observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION sun_observe_apparent(observer, timestamptz) IS
'Observe the Sun with light-time correction (~8.3 min). VSOP87.';
CREATE FUNCTION small_body_observe_apparent(orbital_elements, observer, timestamptz) RETURNS topocentric
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION small_body_observe_apparent(orbital_elements, observer, timestamptz) IS
'Observe a comet/asteroid with single-iteration light-time correction. Kepler propagation at retarded time, Earth via VSOP87 at observation time.';
-- ============================================================
-- Light-time corrected equatorial functions
-- ============================================================
CREATE FUNCTION planet_equatorial_apparent(int4, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION planet_equatorial_apparent(int4, timestamptz) IS
'Geocentric apparent RA/Dec of a planet with light-time correction. VSOP87.';
CREATE FUNCTION moon_equatorial_apparent(timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION moon_equatorial_apparent(timestamptz) IS
'Geocentric apparent RA/Dec of the Moon with light-time correction (~1.3 sec). ELP2000-82B.';
CREATE FUNCTION small_body_equatorial_apparent(orbital_elements, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION small_body_equatorial_apparent(orbital_elements, timestamptz) IS
'Geocentric apparent RA/Dec of a comet/asteroid with light-time correction.';
-- ============================================================
-- DE ephemeris equatorial variants (STABLE)
-- ============================================================
CREATE FUNCTION planet_equatorial_de(int4, timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION planet_equatorial_de(int4, timestamptz) IS
'Geocentric apparent RA/Dec of a planet via JPL DE ephemeris (falls back to VSOP87 + equatorial).';
CREATE FUNCTION moon_equatorial_de(timestamptz) RETURNS equatorial
AS 'MODULE_PATHNAME' LANGUAGE C STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION moon_equatorial_de(timestamptz) IS
'Geocentric apparent RA/Dec of the Moon via JPL DE ephemeris (falls back to ELP2000-82B + equatorial).';

1072
sql/pg_orrery--0.8.0.sql Normal file

File diff suppressed because it is too large Load Diff

1276
sql/pg_orrery--0.9.0.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@ -217,4 +217,95 @@ observe_from_geocentric(const double geo_ecl_au[3], double jd,
result->range_rate = 0.0; /* no velocity computation yet */ result->range_rate = 0.0; /* no velocity computation yet */
} }
/*
* Geocentric ecliptic J2000 (AU) -> equatorial RA/Dec of date.
*
* Captures the intermediate result that observe_from_geocentric() computes
* and discards. Stops after precession -- no hour angle or az/el.
* Distance is converted to km (AU_KM) to match topocentric.range_km.
*/
static inline void
geocentric_to_equatorial(const double geo_ecl_au[3], double jd,
pg_equatorial *result)
{
double geo_equ[3];
double ra_j2000, dec_j2000, geo_dist;
double ra_date, dec_date;
ecliptic_to_equatorial(geo_ecl_au, geo_equ);
cartesian_to_spherical(geo_equ, &ra_j2000, &dec_j2000, &geo_dist);
precess_j2000_to_date(jd, ra_j2000, dec_j2000, &ra_date, &dec_date);
result->ra = ra_date;
result->dec = dec_date;
result->distance = geo_dist * AU_KM;
}
/*
* TEME position (km) to equatorial RA/Dec, geocentric.
*
* TEME is "True Equator, Mean Equinox" -- approximately the mean
* equatorial frame of date. For geocentric (no observer parallax),
* RA/Dec is just the direction of the position vector in TEME.
* Accuracy vs true-of-date: ~arcsecond (nutation residual).
*/
static inline void
teme_to_equatorial_geo(const double pos_teme[3], pg_equatorial *result)
{
double rm = sqrt(pos_teme[0]*pos_teme[0] +
pos_teme[1]*pos_teme[1] +
pos_teme[2]*pos_teme[2]);
result->dec = asin(pos_teme[2] / rm);
result->ra = atan2(pos_teme[1], pos_teme[0]);
if (result->ra < 0.0)
result->ra += 2.0 * M_PI;
result->distance = rm;
}
/*
* TEME position (km) to equatorial RA/Dec, topocentric (observer-corrected).
*
* Subtracts observer position (via ECEF) to get the observer-relative
* range vector in TEME, then computes RA/Dec from that vector.
* For LEO satellites, the topocentric vs geocentric parallax is ~1 deg.
*
* Lifted from od_math.c:od_teme_to_radec() logic.
*/
static inline void
teme_to_equatorial_topo(const double pos_teme[3], double gmst,
const double obs_ecef[3], pg_equatorial *result)
{
double cg = cos(gmst), sg = sin(gmst);
double pos_ecef[3], range_ecef[3], range_teme[3], rm;
/* TEME -> ECEF */
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];
/* Observer-relative range in ECEF */
range_ecef[0] = pos_ecef[0] - obs_ecef[0];
range_ecef[1] = pos_ecef[1] - obs_ecef[1];
range_ecef[2] = pos_ecef[2] - obs_ecef[2];
/* Back to TEME (inertial) for RA/Dec */
range_teme[0] = cg * range_ecef[0] - sg * range_ecef[1];
range_teme[1] = sg * range_ecef[0] + cg * range_ecef[1];
range_teme[2] = range_ecef[2];
rm = sqrt(range_teme[0]*range_teme[0] +
range_teme[1]*range_teme[1] +
range_teme[2]*range_teme[2]);
result->dec = asin(range_teme[2] / rm);
result->ra = atan2(range_teme[1], range_teme[0]);
if (result->ra < 0.0)
result->ra += 2.0 * M_PI;
result->distance = rm;
}
#endif /* PG_ORRERY_ASTRO_MATH_H */ #endif /* PG_ORRERY_ASTRO_MATH_H */

View File

@ -47,6 +47,8 @@ PG_FUNCTION_INFO_V1(galilean_observe_de);
PG_FUNCTION_INFO_V1(saturn_moon_observe_de); PG_FUNCTION_INFO_V1(saturn_moon_observe_de);
PG_FUNCTION_INFO_V1(uranus_moon_observe_de); PG_FUNCTION_INFO_V1(uranus_moon_observe_de);
PG_FUNCTION_INFO_V1(mars_moon_observe_de); PG_FUNCTION_INFO_V1(mars_moon_observe_de);
PG_FUNCTION_INFO_V1(planet_equatorial_de);
PG_FUNCTION_INFO_V1(moon_equatorial_de);
PG_FUNCTION_INFO_V1(pg_orrery_ephemeris_info); PG_FUNCTION_INFO_V1(pg_orrery_ephemeris_info);
@ -610,6 +612,98 @@ mars_moon_observe_de(PG_FUNCTION_ARGS)
} }
/* ================================================================
* planet_equatorial_de(body_id int, timestamptz) -> equatorial
*
* DE variant of planet_equatorial(). STABLE.
* Rule 7: both planet and Earth from the same provider.
* ================================================================
*/
Datum
planet_equatorial_de(PG_FUNCTION_ARGS)
{
int32 body_id = PG_GETARG_INT32(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double earth_xyz[6];
double planet_xyz[6];
double geo_ecl[3];
pg_equatorial *result;
if (body_id < BODY_MERCURY || body_id > BODY_NEPTUNE)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("planet_equatorial_de: body_id %d must be 1-8 (Mercury-Neptune)",
body_id)));
if (body_id == BODY_EARTH)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot observe Earth from Earth")));
jd = timestamptz_to_jd(ts);
/* Rule 7: both planet and Earth from same provider */
if (eph_de_planet(body_id, jd, planet_xyz) &&
eph_de_earth(jd, earth_xyz))
{
/* DE succeeded */
}
else
{
int vsop_body = body_id - 1;
if (eph_de_available())
ereport(NOTICE,
(errmsg("DE ephemeris unavailable for this query, falling back to VSOP87")));
GetVsop87Coor(jd, 2, earth_xyz);
GetVsop87Coor(jd, vsop_body, planet_xyz);
}
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(geo_ecl, jd, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* moon_equatorial_de(timestamptz) -> equatorial
*
* DE variant of moon_equatorial(). STABLE.
* ================================================================
*/
Datum
moon_equatorial_de(PG_FUNCTION_ARGS)
{
int64 ts = PG_GETARG_INT64(0);
double jd;
double moon_ecl[3];
pg_equatorial *result;
jd = timestamptz_to_jd(ts);
if (!eph_de_moon(jd, moon_ecl))
{
if (eph_de_available())
ereport(NOTICE,
(errmsg("DE ephemeris unavailable, falling back to ELP2000-82B")));
GetElp82bCoor(jd, moon_ecl);
}
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(moon_ecl, jd, result);
PG_RETURN_POINTER(result);
}
/* ================================================================ /* ================================================================
* pg_orrery_ephemeris_info() -> RECORD * pg_orrery_ephemeris_info() -> RECORD
* *

366
src/equatorial_funcs.c Normal file
View File

@ -0,0 +1,366 @@
/*
* equatorial_funcs.c -- Equatorial coordinate type and observation functions
*
* Provides the equatorial PostgreSQL type (RA/Dec/distance) and functions
* to compute equatorial coordinates for satellites, planets, Sun, Moon,
* small bodies, and stars.
*
* The type stores 3 doubles (24 bytes): ra (radians), dec (radians),
* distance (km). RA is displayed in hours [0,24), dec in degrees [-90,90].
*
* Two satellite paths:
* - Topocentric (with observer): TEME -> ECEF -> subtract observer -> back
* to TEME -> RA/Dec from the range vector.
* - Geocentric (no observer): RA/Dec is the direction of the TEME position.
*
* Solar system path: VSOP87/ELP82B/Kepler heliocentric ecliptic J2000 ->
* subtract Earth -> ecliptic-to-equatorial -> precess to date.
*/
#include "postgres.h"
#include "fmgr.h"
#include "utils/timestamp.h"
#include "utils/builtins.h"
#include "libpq/pqformat.h"
#include "types.h"
#include "astro_math.h"
#include "vsop87.h"
#include "elp82b.h"
#include <math.h>
/* Type I/O */
PG_FUNCTION_INFO_V1(equatorial_in);
PG_FUNCTION_INFO_V1(equatorial_out);
PG_FUNCTION_INFO_V1(equatorial_recv);
PG_FUNCTION_INFO_V1(equatorial_send);
/* Accessors */
PG_FUNCTION_INFO_V1(eq_ra);
PG_FUNCTION_INFO_V1(eq_dec);
PG_FUNCTION_INFO_V1(eq_distance);
/* Computation functions */
PG_FUNCTION_INFO_V1(eci_to_equatorial);
PG_FUNCTION_INFO_V1(eci_to_equatorial_geo);
PG_FUNCTION_INFO_V1(planet_equatorial);
PG_FUNCTION_INFO_V1(sun_equatorial);
PG_FUNCTION_INFO_V1(moon_equatorial);
/* ----------------------------------------------------------------
* Static helper -- observer geodetic to ECEF.
*
* Duplicated from pass_funcs.c / coord_funcs.c because both files
* define it as static. Too small to warrant a shared module, and
* keeping it static preserves the no-cross-TU-symbol convention.
* ----------------------------------------------------------------
*/
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;
}
/* ================================================================
* Type I/O
*
* Text format: (ra_hours, dec_degrees, distance_km)
*
* RA displayed in hours [0,24), stored in radians [0, 2*pi).
* Dec displayed in degrees [-90,90], stored in radians.
* Distance in km.
* ================================================================
*/
Datum
equatorial_in(PG_FUNCTION_ARGS)
{
char *str = PG_GETARG_CSTRING(0);
pg_equatorial *result;
double ra_hours, dec_deg, distance;
int nfields;
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
nfields = sscanf(str, " ( %lf , %lf , %lf )",
&ra_hours, &dec_deg, &distance);
if (nfields != 3)
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid input syntax for type equatorial: \"%s\"", str),
errhint("Expected (ra_hours,dec_degrees,distance_km).")));
if (ra_hours < 0.0 || ra_hours >= 24.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("right ascension out of range: %.6f", ra_hours),
errhint("RA must be in [0, 24) hours.")));
if (dec_deg < -90.0 || dec_deg > 90.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("declination out of range: %.6f", dec_deg),
errhint("Declination must be between -90 and +90 degrees.")));
result->ra = ra_hours * (M_PI / 12.0);
result->dec = dec_deg * DEG_TO_RAD;
result->distance = distance;
PG_RETURN_POINTER(result);
}
Datum
equatorial_out(PG_FUNCTION_ARGS)
{
pg_equatorial *eq = (pg_equatorial *) PG_GETARG_POINTER(0);
PG_RETURN_CSTRING(psprintf("(%.8f,%.8f,%.3f)",
eq->ra * (12.0 / M_PI),
eq->dec * RAD_TO_DEG,
eq->distance));
}
Datum
equatorial_recv(PG_FUNCTION_ARGS)
{
StringInfo buf = (StringInfo) PG_GETARG_POINTER(0);
pg_equatorial *result;
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
result->ra = pq_getmsgfloat8(buf);
result->dec = pq_getmsgfloat8(buf);
result->distance = pq_getmsgfloat8(buf);
PG_RETURN_POINTER(result);
}
Datum
equatorial_send(PG_FUNCTION_ARGS)
{
pg_equatorial *eq = (pg_equatorial *) PG_GETARG_POINTER(0);
StringInfoData buf;
pq_begintypsend(&buf);
pq_sendfloat8(&buf, eq->ra);
pq_sendfloat8(&buf, eq->dec);
pq_sendfloat8(&buf, eq->distance);
PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
}
/* ================================================================
* Accessor functions
*
* RA returns hours, Dec returns degrees (matching display convention).
* ================================================================
*/
Datum
eq_ra(PG_FUNCTION_ARGS)
{
pg_equatorial *eq = (pg_equatorial *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(eq->ra * (12.0 / M_PI));
}
Datum
eq_dec(PG_FUNCTION_ARGS)
{
pg_equatorial *eq = (pg_equatorial *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(eq->dec * RAD_TO_DEG);
}
Datum
eq_distance(PG_FUNCTION_ARGS)
{
pg_equatorial *eq = (pg_equatorial *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(eq->distance);
}
/* ================================================================
* eci_to_equatorial(eci_position, observer, timestamptz) -> equatorial
*
* Topocentric satellite RA/Dec. Subtracts observer parallax via
* ECEF round-trip, then extracts RA/Dec from the range vector.
* For LEO, topocentric vs geocentric parallax is ~1 degree.
* ================================================================
*/
Datum
eci_to_equatorial(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 pos_teme[3];
double obs_ecef[3];
double gmst;
pg_equatorial *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;
observer_to_ecef(obs, obs_ecef);
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
teme_to_equatorial_topo(pos_teme, gmst, obs_ecef, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* eci_to_equatorial_geo(eci_position, timestamptz) -> equatorial
*
* Geocentric satellite RA/Dec. No observer subtraction -- the
* position vector direction in TEME is the RA/Dec.
* ================================================================
*/
Datum
eci_to_equatorial_geo(PG_FUNCTION_ARGS)
{
pg_eci *eci = (pg_eci *) PG_GETARG_POINTER(0);
int64 ts = PG_GETARG_INT64(1);
double pos_teme[3];
pg_equatorial *result;
/* ts used to establish the equatorial frame; teme_to_equatorial_geo()
* doesn't need jd because TEME is already ~equatorial of date. */
(void) ts;
pos_teme[0] = eci->x;
pos_teme[1] = eci->y;
pos_teme[2] = eci->z;
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
teme_to_equatorial_geo(pos_teme, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* planet_equatorial(body_id int, timestamptz) -> equatorial
*
* Geocentric RA/Dec of a planet via VSOP87.
* Body IDs: 1=Mercury, ..., 8=Neptune (not Sun, Earth, or Moon).
* ================================================================
*/
Datum
planet_equatorial(PG_FUNCTION_ARGS)
{
int32 body_id = PG_GETARG_INT32(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double earth_xyz[6];
double planet_xyz[6];
double geo_ecl[3];
int vsop_body;
pg_equatorial *result;
if (body_id < BODY_MERCURY || body_id > BODY_NEPTUNE)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("planet_equatorial: body_id %d must be 1-8 (Mercury-Neptune)",
body_id)));
if (body_id == BODY_EARTH)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot observe Earth from Earth")));
jd = timestamptz_to_jd(ts);
/* Earth's heliocentric position */
GetVsop87Coor(jd, 2, earth_xyz); /* VSOP87 body 2 = Earth */
/* Target planet heliocentric position */
vsop_body = body_id - 1;
GetVsop87Coor(jd, vsop_body, planet_xyz);
/* Geocentric ecliptic = planet - Earth */
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(geo_ecl, jd, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* sun_equatorial(timestamptz) -> equatorial
*
* Geocentric RA/Dec of the Sun. The Sun's geocentric position is
* the negation of Earth's heliocentric VSOP87 position.
* ================================================================
*/
Datum
sun_equatorial(PG_FUNCTION_ARGS)
{
int64 ts = PG_GETARG_INT64(0);
double jd;
double earth_xyz[6];
double geo_ecl[3];
pg_equatorial *result;
jd = timestamptz_to_jd(ts);
GetVsop87Coor(jd, 2, earth_xyz);
geo_ecl[0] = -earth_xyz[0];
geo_ecl[1] = -earth_xyz[1];
geo_ecl[2] = -earth_xyz[2];
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(geo_ecl, jd, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* moon_equatorial(timestamptz) -> equatorial
*
* Geocentric RA/Dec of the Moon via ELP2000-82B.
* ELP returns geocentric ecliptic J2000 in AU -- no Earth subtraction.
* ================================================================
*/
Datum
moon_equatorial(PG_FUNCTION_ARGS)
{
int64 ts = PG_GETARG_INT64(0);
double jd;
double moon_ecl[3];
pg_equatorial *result;
jd = timestamptz_to_jd(ts);
GetElp82bCoor(jd, moon_ecl);
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(moon_ecl, jd, result);
PG_RETURN_POINTER(result);
}

View File

@ -44,6 +44,10 @@ PG_FUNCTION_INFO_V1(gist_tle_distance);
/* Floating-point comparison tolerance (km and radians) */ /* Floating-point comparison tolerance (km and radians) */
#define KEY_EPSILON 1.0e-9 #define KEY_EPSILON 1.0e-9
/* Domain widths for normalizing penalty across dimensions */
#define ALT_DOMAIN 50000.0 /* km — covers GEO + HEO margins */
#define INC_DOMAIN M_PI /* radians — full inclination range */
/* sizeof(pg_tle) == 112, matching INTERNALLENGTH in CREATE TYPE. */ /* sizeof(pg_tle) == 112, matching INTERNALLENGTH in CREATE TYPE. */
/* /*
@ -156,7 +160,7 @@ key_merge(tle_orbital_key *dst, const tle_orbital_key *src)
* alt_separation -- minimum altitude gap between two keys * alt_separation -- minimum altitude gap between two keys
* *
* Returns 0 if the altitude bands overlap. * Returns 0 if the altitude bands overlap.
* Used for KNN distance (altitude-dominant ordering). * Used as one component of the 2-D orbital distance metric.
* ---------------------------------------------------------------- * ----------------------------------------------------------------
*/ */
static inline double static inline double
@ -170,6 +174,48 @@ alt_separation(const tle_orbital_key *a, const tle_orbital_key *b)
} }
/* ----------------------------------------------------------------
* inc_separation -- minimum inclination gap between two keys
*
* Analogous to alt_separation, but for the inclination dimension.
* Returns 0 if the inclination ranges overlap.
* ----------------------------------------------------------------
*/
static inline double
inc_separation(const tle_orbital_key *a, const tle_orbital_key *b)
{
if (a->inc_high < b->inc_low)
return b->inc_low - a->inc_high;
if (b->inc_high < a->inc_low)
return a->inc_low - b->inc_high;
return 0.0;
}
/* ----------------------------------------------------------------
* orbital_distance -- 2-D distance combining altitude and inclination
*
* Converts the inclination gap (radians) to km via Earth radius,
* then returns L2 norm. At Earth's surface, 1 radian of inclination
* difference corresponds to ~6378 km of cross-track separation.
*
* For internal nodes, both alt_separation and inc_separation are
* lower bounds on the gap to any child. Since sqrt(a^2 + b^2) is
* monotonically increasing, the L2 combination is also a valid
* lower bound satisfying GiST's distance contract.
* ----------------------------------------------------------------
*/
static inline double
orbital_distance(const tle_orbital_key *a, const tle_orbital_key *b)
{
double alt_gap = alt_separation(a, b);
double inc_gap = inc_separation(a, b);
double inc_km = inc_gap * WGS72_AE; /* radians → km via Earth radius */
return sqrt(alt_gap * alt_gap + inc_km * inc_km);
}
/* ================================================================ /* ================================================================
* SQL-callable operators * SQL-callable operators
* ================================================================ * ================================================================
@ -200,13 +246,12 @@ tle_overlap(PG_FUNCTION_ARGS)
/* /*
* tle_alt_distance(tle, tle) -> float8 [the <-> operator] * tle_alt_distance(tle, tle) -> float8 [the <-> operator]
* *
* Minimum altitude-band separation in km. Returns 0 if the bands * 2-D orbital distance in km: combines altitude-band separation
* overlap. This is not the physical distance between the objects -- * with inclination gap (converted to km via Earth radius).
* it is the gap between their orbital shells, useful for ordering * Returns 0 only if both altitude bands AND inclination ranges overlap.
* nearest-neighbor queries without propagation.
* *
* Altitude-only: inclination weighting adds complexity without * The C symbol name is kept as tle_alt_distance to avoid SQL migration
* meaningful benefit for KNN conjunction screening. * churn the SQL FUNCTION and OPERATOR definitions are unchanged.
*/ */
Datum Datum
tle_alt_distance(PG_FUNCTION_ARGS) tle_alt_distance(PG_FUNCTION_ARGS)
@ -218,7 +263,7 @@ tle_alt_distance(PG_FUNCTION_ARGS)
tle_to_orbital_key(a, &ka); tle_to_orbital_key(a, &ka);
tle_to_orbital_key(b, &kb); tle_to_orbital_key(b, &kb);
PG_RETURN_FLOAT8(alt_separation(&ka, &kb)); PG_RETURN_FLOAT8(orbital_distance(&ka, &kb));
} }
@ -311,20 +356,6 @@ gist_tle_consistent(PG_FUNCTION_ARGS)
result = key_overlaps(key, &query_key); result = key_overlaps(key, &query_key);
break; break;
case RTContainedByStrategyNumber: /* <@ */
if (GIST_LEAF(entry))
result = key_contains(&query_key, key);
else
result = key_overlaps(key, &query_key);
break;
case RTContainsStrategyNumber: /* @> */
if (GIST_LEAF(entry))
result = key_contains(key, &query_key);
else
result = key_overlaps(key, &query_key);
break;
default: default:
elog(ERROR, "gist_tle_consistent: unrecognized strategy %d", elog(ERROR, "gist_tle_consistent: unrecognized strategy %d",
strategy); strategy);
@ -388,10 +419,10 @@ gist_tle_penalty(PG_FUNCTION_ARGS)
double orig_margin; double orig_margin;
double merged_margin; double merged_margin;
orig_margin = (orig->alt_high - orig->alt_low) orig_margin = (orig->alt_high - orig->alt_low) / ALT_DOMAIN
+ (orig->inc_high - orig->inc_low); + (orig->inc_high - orig->inc_low) / INC_DOMAIN;
merged_margin = (fmax(orig->alt_high, add->alt_high) - fmin(orig->alt_low, add->alt_low)) merged_margin = (fmax(orig->alt_high, add->alt_high) - fmin(orig->alt_low, add->alt_low)) / ALT_DOMAIN
+ (fmax(orig->inc_high, add->inc_high) - fmin(orig->inc_low, add->inc_low)); + (fmax(orig->inc_high, add->inc_high) - fmin(orig->inc_low, add->inc_low)) / INC_DOMAIN;
*penalty = (float)(merged_margin - orig_margin); *penalty = (float)(merged_margin - orig_margin);
@ -573,12 +604,13 @@ gist_tle_same(PG_FUNCTION_ARGS)
/* /*
* gist_tle_distance -- GiST distance function for KNN ordering * gist_tle_distance -- GiST distance function for KNN ordering
* *
* Returns the minimum altitude-band separation in km. * Returns the 2-D orbital distance: L2 norm of altitude separation
* For overlapping ranges this is 0, making the entry a candidate. * and inclination gap (in km). For entries where both dimensions
* The planner uses this to drive ORDER BY <-> queries. * overlap this is 0, making the entry a candidate.
* *
* Altitude-only: conjunction screening is altitude-dominant. * Both alt_separation and inc_separation are lower bounds for
* Inclination weighting can be added later if needed. * internal nodes, so the L2 combination is also a valid lower
* bound satisfying GiST's distance contract for correct KNN.
*/ */
Datum Datum
gist_tle_distance(PG_FUNCTION_ARGS) gist_tle_distance(PG_FUNCTION_ARGS)
@ -591,5 +623,5 @@ gist_tle_distance(PG_FUNCTION_ARGS)
tle_to_orbital_key(query, &query_key); tle_to_orbital_key(query, &query_key);
PG_RETURN_FLOAT8(alt_separation(key, &query_key)); PG_RETURN_FLOAT8(orbital_distance(key, &query_key));
} }

24
src/kepler.h Normal file
View File

@ -0,0 +1,24 @@
/*
* kepler.h -- Two-body Keplerian position from classical elements
*
* Exposes kepler_position() for use by orbital_elements_type.c
* and any future code that needs raw Keplerian propagation.
*/
#ifndef PG_ORRERY_KEPLER_H
#define PG_ORRERY_KEPLER_H
/*
* Two-body Keplerian position from classical orbital elements.
*
* q (AU), e, inc/omega/Omega (radians), T_peri (JD), jd (JD).
* Output: pos[3] in AU, ecliptic J2000 frame.
*
* Handles elliptic (e<0.99), near-parabolic (0.99<=e<=1.01),
* and hyperbolic (e>1.01) orbits.
*/
extern void kepler_position(double q, double e, double inc,
double omega, double Omega,
double T_peri, double jd, double pos[3]);
#endif /* PG_ORRERY_KEPLER_H */

View File

@ -17,6 +17,7 @@
#include "libpq/pqformat.h" #include "libpq/pqformat.h"
#include "types.h" #include "types.h"
#include "astro_math.h" #include "astro_math.h"
#include "kepler.h"
#include <math.h> #include <math.h>
/* Heliocentric type I/O */ /* Heliocentric type I/O */
@ -120,7 +121,7 @@ solve_kepler_parabolic(double M)
* Output: pos[3] in AU, ecliptic J2000 frame * Output: pos[3] in AU, ecliptic J2000 frame
* ================================================================ * ================================================================
*/ */
static void void
kepler_position(double q, double e, double inc, double omega, double Omega, kepler_position(double q, double e, double inc, double omega, double Omega,
double T_peri, double jd, double pos[3]) double T_peri, double jd, double pos[3])
{ {

683
src/orbital_elements_type.c Normal file
View File

@ -0,0 +1,683 @@
/*
* orbital_elements_type.c -- Classical Keplerian orbital elements type
*
* Provides the orbital_elements PostgreSQL type for comets and asteroids,
* a parser for MPC MPCORB.DAT fixed-width format, and small_body_observe()
* / small_body_heliocentric() which auto-fetch Earth's VSOP87 position.
*
* The type stores 9 doubles (72 bytes): epoch, q, e, inc, arg_peri, raan,
* tp, h_mag, g_slope. Angular elements stored in radians, displayed in
* degrees (same convention as the tle type).
*/
#include "postgres.h"
#include "fmgr.h"
#include "utils/timestamp.h"
#include "utils/builtins.h"
#include "libpq/pqformat.h"
#include "types.h"
#include "astro_math.h"
#include "kepler.h"
#include "vsop87.h"
#include <math.h>
#include <ctype.h>
/* Type I/O */
PG_FUNCTION_INFO_V1(orbital_elements_in);
PG_FUNCTION_INFO_V1(orbital_elements_out);
PG_FUNCTION_INFO_V1(orbital_elements_recv);
PG_FUNCTION_INFO_V1(orbital_elements_send);
/* Accessors */
PG_FUNCTION_INFO_V1(oe_epoch);
PG_FUNCTION_INFO_V1(oe_perihelion);
PG_FUNCTION_INFO_V1(oe_eccentricity);
PG_FUNCTION_INFO_V1(oe_inclination);
PG_FUNCTION_INFO_V1(oe_arg_perihelion);
PG_FUNCTION_INFO_V1(oe_raan);
PG_FUNCTION_INFO_V1(oe_tp);
PG_FUNCTION_INFO_V1(oe_h_mag);
PG_FUNCTION_INFO_V1(oe_g_slope);
PG_FUNCTION_INFO_V1(oe_semi_major_axis);
PG_FUNCTION_INFO_V1(oe_period_years);
/* MPC parser */
PG_FUNCTION_INFO_V1(oe_from_mpc);
/* Observation functions */
PG_FUNCTION_INFO_V1(small_body_heliocentric);
PG_FUNCTION_INFO_V1(small_body_observe);
PG_FUNCTION_INFO_V1(small_body_observe_apparent);
PG_FUNCTION_INFO_V1(small_body_equatorial);
PG_FUNCTION_INFO_V1(small_body_equatorial_apparent);
/* ================================================================
* MPC packed date decoding
*
* MPC format: 5 characters, e.g. "K24AM"
* [0] = century: I=1800, J=1900, K=2000
* [1-2] = year within century (00-99)
* [3] = month: 1-9 or A=10, B=11, C=12
* [4] = day: 1-9 or A=10, ..., V=31
*
* Returns Julian date at 0h UTC of that date.
* ================================================================
*/
static double
mpc_packed_to_jd(const char *p)
{
int year, month, day;
int a, b;
double jd;
switch (p[0])
{
case 'I': year = 1800; break;
case 'J': year = 1900; break;
case 'K': year = 2000; break;
default:
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid MPC century code '%c'", p[0])));
return 0.0; /* unreachable */
}
year += (p[1] - '0') * 10 + (p[2] - '0');
/* Month: 1-9 as digit, A=10, B=11, C=12 */
if (p[3] >= '1' && p[3] <= '9')
month = p[3] - '0';
else if (p[3] >= 'A' && p[3] <= 'C')
month = p[3] - 'A' + 10;
else
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid MPC month code '%c'", p[3])));
/* Day: 1-9 as digit, A=10, ..., V=31 */
if (p[4] >= '1' && p[4] <= '9')
day = p[4] - '0';
else if (p[4] >= 'A' && p[4] <= 'V')
day = p[4] - 'A' + 10;
else
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid MPC day code '%c'", p[4])));
/* Gregorian calendar -> JD (Meeus, Astronomical Algorithms, Ch. 7) */
if (month <= 2)
{
year -= 1;
month += 12;
}
a = year / 100;
b = 2 - a + a / 4;
jd = floor(365.25 * (year + 4716)) + floor(30.6001 * (month + 1))
+ day + b - 1524.5;
return jd;
}
/*
* Parse a float from a fixed-width substring.
* Returns NaN if the field is blank/whitespace.
*/
static double
parse_mpc_field(const char *line, int start, int len)
{
char buf[32];
char *end;
double val;
int i;
bool all_blank = true;
if (len > 31)
len = 31;
memcpy(buf, line + start, len);
buf[len] = '\0';
for (i = 0; i < len; i++)
{
if (!isspace((unsigned char)buf[i]))
{
all_blank = false;
break;
}
}
if (all_blank)
return NAN;
val = strtod(buf, &end);
if (end == buf)
return NAN;
return val;
}
/* ================================================================
* Type I/O
*
* Text format: (epoch_jd, q_au, e, inc_deg, omega_deg, Omega_deg,
* tp_jd, H, G)
*
* Angles are displayed in degrees, stored in radians.
* H and G display as "NaN" if unknown.
* ================================================================
*/
Datum
orbital_elements_in(PG_FUNCTION_ARGS)
{
char *str = PG_GETARG_CSTRING(0);
pg_orbital_elements *result;
double epoch, q, e, inc_d, omega_d, Omega_d, tp, h, g;
int nfields;
result = (pg_orbital_elements *) palloc(sizeof(pg_orbital_elements));
nfields = sscanf(str, " ( %lf , %lf , %lf , %lf , %lf , %lf , %lf , %lf , %lf )",
&epoch, &q, &e, &inc_d, &omega_d, &Omega_d, &tp, &h, &g);
if (nfields != 9)
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid input syntax for type orbital_elements: \"%s\"", str),
errhint("Expected (epoch_jd,q_au,e,inc_deg,omega_deg,Omega_deg,tp_jd,H,G).")));
if (q <= 0.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("perihelion distance must be positive: %.6f", q)));
if (e < 0.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("eccentricity must be non-negative: %.6f", e)));
result->epoch = epoch;
result->q = q;
result->e = e;
result->inc = inc_d * DEG_TO_RAD;
result->arg_peri = omega_d * DEG_TO_RAD;
result->raan = Omega_d * DEG_TO_RAD;
result->tp = tp;
result->h_mag = h;
result->g_slope = g;
PG_RETURN_POINTER(result);
}
Datum
orbital_elements_out(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_CSTRING(psprintf("(%.6f,%.10f,%.10f,%.6f,%.6f,%.6f,%.6f,%.2f,%.2f)",
oe->epoch,
oe->q,
oe->e,
oe->inc * RAD_TO_DEG,
oe->arg_peri * RAD_TO_DEG,
oe->raan * RAD_TO_DEG,
oe->tp,
oe->h_mag,
oe->g_slope));
}
Datum
orbital_elements_recv(PG_FUNCTION_ARGS)
{
StringInfo buf = (StringInfo) PG_GETARG_POINTER(0);
pg_orbital_elements *result;
result = (pg_orbital_elements *) palloc(sizeof(pg_orbital_elements));
result->epoch = pq_getmsgfloat8(buf);
result->q = pq_getmsgfloat8(buf);
result->e = pq_getmsgfloat8(buf);
result->inc = pq_getmsgfloat8(buf);
result->arg_peri = pq_getmsgfloat8(buf);
result->raan = pq_getmsgfloat8(buf);
result->tp = pq_getmsgfloat8(buf);
result->h_mag = pq_getmsgfloat8(buf);
result->g_slope = pq_getmsgfloat8(buf);
PG_RETURN_POINTER(result);
}
Datum
orbital_elements_send(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
StringInfoData buf;
pq_begintypsend(&buf);
pq_sendfloat8(&buf, oe->epoch);
pq_sendfloat8(&buf, oe->q);
pq_sendfloat8(&buf, oe->e);
pq_sendfloat8(&buf, oe->inc);
pq_sendfloat8(&buf, oe->arg_peri);
pq_sendfloat8(&buf, oe->raan);
pq_sendfloat8(&buf, oe->tp);
pq_sendfloat8(&buf, oe->h_mag);
pq_sendfloat8(&buf, oe->g_slope);
PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
}
/* ================================================================
* Accessor functions
*
* All angles return degrees (matching TLE accessor convention).
* ================================================================
*/
Datum
oe_epoch(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->epoch);
}
Datum
oe_perihelion(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->q);
}
Datum
oe_eccentricity(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->e);
}
Datum
oe_inclination(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->inc * RAD_TO_DEG);
}
Datum
oe_arg_perihelion(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->arg_peri * RAD_TO_DEG);
}
Datum
oe_raan(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->raan * RAD_TO_DEG);
}
Datum
oe_tp(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->tp);
}
Datum
oe_h_mag(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->h_mag);
}
Datum
oe_g_slope(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
PG_RETURN_FLOAT8(oe->g_slope);
}
/* Computed: semi-major axis = q / (1 - e). NULL if e >= 1 (parabolic/hyperbolic). */
Datum
oe_semi_major_axis(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
if (oe->e >= 1.0)
PG_RETURN_NULL();
PG_RETURN_FLOAT8(oe->q / (1.0 - oe->e));
}
/* Computed: orbital period in years = a^1.5 (Kepler's third law, a in AU -> period in years). */
Datum
oe_period_years(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
double a;
if (oe->e >= 1.0)
PG_RETURN_NULL();
a = oe->q / (1.0 - oe->e);
PG_RETURN_FLOAT8(a * sqrt(a));
}
/* ================================================================
* oe_from_mpc(text) -> orbital_elements
*
* Parse one line of MPC MPCORB.DAT fixed-width format.
*
* Column layout (0-indexed):
* 0-6 Number/designation (ignored by this function)
* 8-12 H magnitude
* 14-18 G slope
* 20-24 Epoch (MPC packed date)
* 26-35 Mean anomaly M (degrees)
* 37-45 Argument of perihelion omega (degrees)
* 48-56 Long. ascending node Omega (degrees)
* 59-67 Inclination i (degrees)
* 70-78 Eccentricity e
* 80-90 Mean daily motion n (degrees/day)
* 92-102 Semi-major axis a (AU)
*
* Converts at parse time:
* q = a * (1 - e)
* n_rad = GAUSS_K / (a * sqrt(a)) [radians/day]
* tp = epoch_jd - M_rad / n_rad
* ================================================================
*/
Datum
oe_from_mpc(PG_FUNCTION_ARGS)
{
text *input = PG_GETARG_TEXT_PP(0);
char *line = text_to_cstring(input);
int len = strlen(line);
pg_orbital_elements *result;
double h_mag, g_slope;
double epoch_jd, M_deg, omega_deg, Omega_deg, inc_deg;
double ecc, n_deg, a_au;
double q, n_rad, M_rad, tp;
if (len < 103)
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("MPC line too short: %d characters (need at least 103)", len)));
/* Parse fields — Fortran 1-indexed columns converted to C 0-indexed.
* MPC format: cols 27-35 = C 26-34 (M), 37-46 = C 36-45 (omega),
* 48-57 = C 47-56 (Omega), 59-68 = C 58-67 (inc), 70-79 = C 69-78 (e),
* 81-91 = C 80-90 (n), 93-103 = C 92-102 (a). */
h_mag = parse_mpc_field(line, 8, 5);
g_slope = parse_mpc_field(line, 14, 5);
epoch_jd = mpc_packed_to_jd(line + 20);
M_deg = parse_mpc_field(line, 26, 9);
omega_deg = parse_mpc_field(line, 36, 10);
Omega_deg = parse_mpc_field(line, 47, 10);
inc_deg = parse_mpc_field(line, 58, 10);
ecc = parse_mpc_field(line, 69, 10);
n_deg = parse_mpc_field(line, 80, 11);
a_au = parse_mpc_field(line, 92, 11);
if (isnan(a_au) || a_au <= 0.0)
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid or missing semi-major axis in MPC line")));
if (isnan(ecc) || ecc < 0.0)
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid or missing eccentricity in MPC line")));
if (isnan(M_deg))
ereport(ERROR,
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
errmsg("invalid or missing mean anomaly in MPC line")));
/* Derived quantities */
q = a_au * (1.0 - ecc);
/* Mean motion from Gauss's constant: n = k / a^(3/2) radians/day */
n_rad = GAUSS_K / (a_au * sqrt(a_au));
/* If MPC provides n, prefer it when sanity-checking, but compute tp from
* the Gauss-derived value to maintain constant consistency. */
(void) n_deg;
M_rad = M_deg * DEG_TO_RAD;
tp = epoch_jd - M_rad / n_rad;
result = (pg_orbital_elements *) palloc(sizeof(pg_orbital_elements));
result->epoch = epoch_jd;
result->q = q;
result->e = ecc;
result->inc = inc_deg * DEG_TO_RAD;
result->arg_peri = omega_deg * DEG_TO_RAD;
result->raan = Omega_deg * DEG_TO_RAD;
result->tp = tp;
result->h_mag = h_mag;
result->g_slope = g_slope;
pfree(line);
PG_RETURN_POINTER(result);
}
/* ================================================================
* small_body_heliocentric(orbital_elements, timestamptz) -> heliocentric
*
* Two-body Keplerian propagation from the stored elements.
* ================================================================
*/
Datum
small_body_heliocentric(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double pos[3];
pg_heliocentric *result;
jd = timestamptz_to_jd(ts);
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd, pos);
result = (pg_heliocentric *) palloc(sizeof(pg_heliocentric));
result->x = pos[0];
result->y = pos[1];
result->z = pos[2];
PG_RETURN_POINTER(result);
}
/* ================================================================
* small_body_observe(orbital_elements, observer, timestamptz) -> topocentric
*
* Full pipeline matching planet_observe():
* 1. Kepler position of the body (heliocentric ecliptic J2000)
* 2. VSOP87 Earth position (same frame)
* 3. Geocentric = body - Earth
* 4. observe_from_geocentric() -> topocentric az/el/range
* ================================================================
*/
Datum
small_body_observe(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(1);
int64 ts = PG_GETARG_INT64(2);
double jd;
double body_helio[3];
double earth_xyz[6];
double geo_ecl[3];
pg_topocentric *result;
jd = timestamptz_to_jd(ts);
/* Body's heliocentric ecliptic J2000 position */
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd, body_helio);
/* Earth's heliocentric position via VSOP87 (body 2 = Earth) */
GetVsop87Coor(jd, 2, earth_xyz);
/* Geocentric ecliptic = body - Earth */
geo_ecl[0] = body_helio[0] - earth_xyz[0];
geo_ecl[1] = body_helio[1] - earth_xyz[1];
geo_ecl[2] = body_helio[2] - earth_xyz[2];
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric(geo_ecl, jd, obs, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* small_body_observe_apparent(orbital_elements, observer, timestamptz) -> topocentric
*
* Light-time corrected observation of a comet or asteroid.
*
* Single-iteration: geometric geocentric distance -> tau -> recompute
* body at (jd - tau). Earth stays at observation time jd.
* ================================================================
*/
Datum
small_body_observe_apparent(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(1);
int64 ts = PG_GETARG_INT64(2);
double jd;
double body_helio[3];
double earth_xyz[6];
double geo_ecl[3];
double geo_dist, tau;
pg_topocentric *result;
jd = timestamptz_to_jd(ts);
/* Body's geometric heliocentric position */
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd, body_helio);
/* Earth at observation time (fixed) */
GetVsop87Coor(jd, 2, earth_xyz);
/* Geometric geocentric distance */
geo_ecl[0] = body_helio[0] - earth_xyz[0];
geo_ecl[1] = body_helio[1] - earth_xyz[1];
geo_ecl[2] = body_helio[2] - earth_xyz[2];
geo_dist = sqrt(geo_ecl[0]*geo_ecl[0] + geo_ecl[1]*geo_ecl[1] + geo_ecl[2]*geo_ecl[2]);
/* Recompute body at retarded time */
tau = geo_dist / C_LIGHT_AU_DAY;
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd - tau, body_helio);
/* Apparent geocentric = retarded body - Earth(jd) */
geo_ecl[0] = body_helio[0] - earth_xyz[0];
geo_ecl[1] = body_helio[1] - earth_xyz[1];
geo_ecl[2] = body_helio[2] - earth_xyz[2];
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric(geo_ecl, jd, obs, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* small_body_equatorial(orbital_elements, timestamptz) -> equatorial
*
* Geocentric RA/Dec of a comet or asteroid (no light-time correction).
*
* Pipeline: Kepler heliocentric -> subtract Earth -> equatorial.
* ================================================================
*/
Datum
small_body_equatorial(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double body_helio[3];
double earth_xyz[6];
double geo_ecl[3];
pg_equatorial *result;
jd = timestamptz_to_jd(ts);
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd, body_helio);
GetVsop87Coor(jd, 2, earth_xyz);
geo_ecl[0] = body_helio[0] - earth_xyz[0];
geo_ecl[1] = body_helio[1] - earth_xyz[1];
geo_ecl[2] = body_helio[2] - earth_xyz[2];
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(geo_ecl, jd, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* small_body_equatorial_apparent(orbital_elements, timestamptz) -> equatorial
*
* Light-time corrected geocentric RA/Dec of a comet or asteroid.
* Same retarded-time correction as small_body_observe_apparent()
* but returns equatorial coordinates instead of topocentric az/el.
* ================================================================
*/
Datum
small_body_equatorial_apparent(PG_FUNCTION_ARGS)
{
pg_orbital_elements *oe = (pg_orbital_elements *) PG_GETARG_POINTER(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double body_helio[3];
double earth_xyz[6];
double geo_ecl[3];
double geo_dist, tau;
pg_equatorial *result;
jd = timestamptz_to_jd(ts);
/* Geometric body position */
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd, body_helio);
/* Earth at observation time (fixed) */
GetVsop87Coor(jd, 2, earth_xyz);
/* Geometric geocentric distance */
geo_ecl[0] = body_helio[0] - earth_xyz[0];
geo_ecl[1] = body_helio[1] - earth_xyz[1];
geo_ecl[2] = body_helio[2] - earth_xyz[2];
geo_dist = sqrt(geo_ecl[0]*geo_ecl[0] + geo_ecl[1]*geo_ecl[1] + geo_ecl[2]*geo_ecl[2]);
/* Retarded body position */
tau = geo_dist / C_LIGHT_AU_DAY;
kepler_position(oe->q, oe->e, oe->inc, oe->arg_peri, oe->raan,
oe->tp, jd - tau, body_helio);
/* Apparent geocentric */
geo_ecl[0] = body_helio[0] - earth_xyz[0];
geo_ecl[1] = body_helio[1] - earth_xyz[1];
geo_ecl[2] = body_helio[2] - earth_xyz[2];
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(geo_ecl, jd, result);
PG_RETURN_POINTER(result);
}

View File

@ -36,6 +36,7 @@ PG_FUNCTION_INFO_V1(pass_duration);
PG_FUNCTION_INFO_V1(next_pass); PG_FUNCTION_INFO_V1(next_pass);
PG_FUNCTION_INFO_V1(predict_passes); PG_FUNCTION_INFO_V1(predict_passes);
PG_FUNCTION_INFO_V1(pass_visible); PG_FUNCTION_INFO_V1(pass_visible);
PG_FUNCTION_INFO_V1(predict_passes_refracted);
#define DEG_TO_RAD (M_PI / 180.0) #define DEG_TO_RAD (M_PI / 180.0)
#define RAD_TO_DEG (180.0 / M_PI) #define RAD_TO_DEG (180.0 / M_PI)
@ -297,7 +298,7 @@ elevation_at_jd(const pg_tle *tle, const pg_observer *obs,
static bool static bool
find_next_pass(const pg_tle *tle, const pg_observer *obs, find_next_pass(const pg_tle *tle, const pg_observer *obs,
double start_jd, double stop_jd, double start_jd, double stop_jd,
double min_el_rad, double min_el_rad, double threshold_rad,
double *aos_jd, double *los_jd, double *aos_jd, double *los_jd,
double *max_el_jd, double *max_el, double *max_el_jd, double *max_el,
double *aos_az, double *los_az) double *aos_az, double *los_az)
@ -316,8 +317,8 @@ find_next_pass(const pg_tle *tle, const pg_observer *obs,
curr_el = elevation_at_jd(tle, obs, jd, NULL); curr_el = elevation_at_jd(tle, obs, jd, NULL);
/* Rising edge: was below horizon, now above */ /* Rising edge: was below threshold, now above */
if (prev_el <= 0.0 && curr_el > 0.0) if (prev_el <= threshold_rad && curr_el > threshold_rad)
{ {
double lo, hi, mid; double lo, hi, mid;
double peak_el; double peak_el;
@ -329,7 +330,7 @@ find_next_pass(const pg_tle *tle, const pg_observer *obs,
while (hi - lo > BISECT_TOL_JD) while (hi - lo > BISECT_TOL_JD)
{ {
mid = (lo + hi) / 2.0; mid = (lo + hi) / 2.0;
if (elevation_at_jd(tle, obs, mid, NULL) > 0.0) if (elevation_at_jd(tle, obs, mid, NULL) > threshold_rad)
hi = mid; hi = mid;
else else
lo = mid; lo = mid;
@ -350,7 +351,7 @@ find_next_pass(const pg_tle *tle, const pg_observer *obs,
if (scan_el > peak_el) if (scan_el > peak_el)
peak_el = scan_el; peak_el = scan_el;
if (scan_el <= 0.0) if (scan_el <= threshold_rad)
{ {
/* Bisect to find LOS */ /* Bisect to find LOS */
lo = scan_jd - COARSE_STEP_JD; lo = scan_jd - COARSE_STEP_JD;
@ -358,7 +359,7 @@ find_next_pass(const pg_tle *tle, const pg_observer *obs,
while (hi - lo > BISECT_TOL_JD) while (hi - lo > BISECT_TOL_JD)
{ {
mid = (lo + hi) / 2.0; mid = (lo + hi) / 2.0;
if (elevation_at_jd(tle, obs, mid, NULL) > 0.0) if (elevation_at_jd(tle, obs, mid, NULL) > threshold_rad)
lo = mid; lo = mid;
else else
hi = mid; hi = mid;
@ -376,7 +377,7 @@ find_next_pass(const pg_tle *tle, const pg_observer *obs,
if (*los_jd - *aos_jd < MIN_PASS_DURATION_JD) if (*los_jd - *aos_jd < MIN_PASS_DURATION_JD)
{ {
jd = *los_jd; jd = *los_jd;
prev_el = 0.0; prev_el = threshold_rad - 0.01;
continue; continue;
} }
@ -404,7 +405,7 @@ find_next_pass(const pg_tle *tle, const pg_observer *obs,
if (*max_el < min_el_rad) if (*max_el < min_el_rad)
{ {
jd = *los_jd; jd = *los_jd;
prev_el = 0.0; prev_el = threshold_rad - 0.01;
continue; continue;
} }
@ -630,6 +631,7 @@ next_pass(PG_FUNCTION_ARGS)
if (!find_next_pass(tle, obs, start_jd, stop_jd, if (!find_next_pass(tle, obs, start_jd, stop_jd,
0.0, /* minimum elevation = 0 degrees */ 0.0, /* minimum elevation = 0 degrees */
0.0, /* threshold = geometric horizon */
&aos_jd, &los_jd, &aos_jd, &los_jd,
&max_el_jd, &max_el, &max_el_jd, &max_el,
&aos_az, &los_az)) &aos_az, &los_az))
@ -722,6 +724,7 @@ predict_passes(PG_FUNCTION_ARGS)
if (!find_next_pass(&ctx->tle, &ctx->obs, if (!find_next_pass(&ctx->tle, &ctx->obs,
ctx->current_jd, ctx->stop_jd, ctx->current_jd, ctx->stop_jd,
ctx->min_el_rad, ctx->min_el_rad,
0.0, /* threshold = geometric horizon */
&aos_jd, &los_jd, &aos_jd, &los_jd,
&max_el_jd, &max_el, &max_el_jd, &max_el,
&aos_az, &los_az)) &aos_az, &los_az))
@ -767,7 +770,111 @@ pass_visible(PG_FUNCTION_ARGS)
PG_RETURN_BOOL(find_next_pass(tle, obs, start_jd, stop_jd, PG_RETURN_BOOL(find_next_pass(tle, obs, start_jd, stop_jd,
0.0, 0.0,
0.0, /* threshold = geometric horizon */
&aos_jd, &los_jd, &aos_jd, &los_jd,
&max_el_jd, &max_el, &max_el_jd, &max_el,
&aos_az, &los_az)); &aos_az, &los_az));
} }
/* ----------------------------------------------------------------
* predict_passes_refracted(tle, observer, start, stop [, min_elevation])
* -> SETOF pass_event
*
* Same as predict_passes but uses refracted horizon threshold.
* Bennett's refraction at 0 deg geometric elevation is ~0.569 deg,
* so the threshold is -0.569 deg = -0.00993 rad. This means AOS
* triggers when the satellite's geometric elevation crosses -0.569
* deg (the point at which refraction bends it to the apparent
* horizon).
* ----------------------------------------------------------------
*/
#define REFRACTED_HORIZON_RAD (-0.00993) /* -0.569 deg, Bennett at h=0 */
typedef struct
{
pg_tle tle;
pg_observer obs;
double current_jd;
double stop_jd;
double min_el_rad;
} predict_passes_refracted_ctx;
Datum
predict_passes_refracted(PG_FUNCTION_ARGS)
{
FuncCallContext *funcctx;
predict_passes_refracted_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_refracted_ctx *)
palloc0(sizeof(predict_passes_refracted_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_refracted_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,
REFRACTED_HORIZON_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));
}
}

View File

@ -29,6 +29,10 @@ PG_FUNCTION_INFO_V1(planet_heliocentric);
PG_FUNCTION_INFO_V1(planet_observe); PG_FUNCTION_INFO_V1(planet_observe);
PG_FUNCTION_INFO_V1(sun_observe); PG_FUNCTION_INFO_V1(sun_observe);
PG_FUNCTION_INFO_V1(moon_observe); PG_FUNCTION_INFO_V1(moon_observe);
PG_FUNCTION_INFO_V1(planet_observe_apparent);
PG_FUNCTION_INFO_V1(sun_observe_apparent);
PG_FUNCTION_INFO_V1(planet_equatorial_apparent);
PG_FUNCTION_INFO_V1(moon_equatorial_apparent);
/* /*
@ -197,3 +201,208 @@ moon_observe(PG_FUNCTION_ARGS)
PG_RETURN_POINTER(result); PG_RETURN_POINTER(result);
} }
/* ================================================================
* planet_observe_apparent(body_id int, observer, timestamptz) -> topocentric
*
* Light-time corrected planet observation.
*
* Single-iteration correction: compute geometric geocentric distance,
* derive tau = dist / c, recompute planet at (jd - tau).
* Earth stays at observation time jd. Sufficient for ~arcsecond
* accuracy (second iteration would gain ~milliarcseconds).
*
* Body IDs: 1=Mercury, ..., 8=Neptune (not Sun, Earth, or Moon)
* ================================================================
*/
Datum
planet_observe_apparent(PG_FUNCTION_ARGS)
{
int32 body_id = PG_GETARG_INT32(0);
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(1);
int64 ts = PG_GETARG_INT64(2);
double jd;
double earth_xyz[6];
double planet_xyz[6];
double geo_ecl[3];
double geo_dist, tau;
int vsop_body;
pg_topocentric *result;
if (body_id < BODY_MERCURY || body_id > BODY_NEPTUNE)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("planet_observe_apparent: body_id %d must be 1-8 (Mercury-Neptune)",
body_id)));
if (body_id == BODY_EARTH)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot observe Earth from Earth")));
jd = timestamptz_to_jd(ts);
vsop_body = body_id - 1;
/* Earth at observation time (stays fixed throughout) */
GetVsop87Coor(jd, 2, earth_xyz);
/* Geometric planet position for initial distance estimate */
GetVsop87Coor(jd, vsop_body, planet_xyz);
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
geo_dist = sqrt(geo_ecl[0]*geo_ecl[0] + geo_ecl[1]*geo_ecl[1] + geo_ecl[2]*geo_ecl[2]);
/* Recompute planet at retarded time (jd - tau) */
tau = geo_dist / C_LIGHT_AU_DAY;
GetVsop87Coor(jd - tau, vsop_body, planet_xyz);
/* Apparent geocentric = retarded planet - Earth(jd) */
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric(geo_ecl, jd, obs, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* sun_observe_apparent(observer, timestamptz) -> topocentric
*
* Light-time corrected Sun observation.
*
* The Sun is at the heliocentric origin (0,0,0) at all times.
* Sun(jd - tau) = (0,0,0), so geocentric_apparent = -Earth(jd).
* Identical to sun_observe() -- included for API symmetry so
* callers don't need to special-case the Sun.
* ================================================================
*/
Datum
sun_observe_apparent(PG_FUNCTION_ARGS)
{
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double earth_xyz[6];
double geo_ecl[3];
pg_topocentric *result;
jd = timestamptz_to_jd(ts);
/* Sun at origin -> geocentric = -Earth(jd) */
GetVsop87Coor(jd, 2, earth_xyz);
geo_ecl[0] = -earth_xyz[0];
geo_ecl[1] = -earth_xyz[1];
geo_ecl[2] = -earth_xyz[2];
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
observe_from_geocentric(geo_ecl, jd, obs, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* planet_equatorial_apparent(body_id int, timestamptz) -> equatorial
*
* Light-time corrected geocentric RA/Dec of a planet.
* Same retarded-time correction as planet_observe_apparent() but
* returns equatorial coordinates instead of topocentric az/el.
*
* Body IDs: 1=Mercury, ..., 8=Neptune (not Sun, Earth, or Moon)
* ================================================================
*/
Datum
planet_equatorial_apparent(PG_FUNCTION_ARGS)
{
int32 body_id = PG_GETARG_INT32(0);
int64 ts = PG_GETARG_INT64(1);
double jd;
double earth_xyz[6];
double planet_xyz[6];
double geo_ecl[3];
double geo_dist, tau;
int vsop_body;
pg_equatorial *result;
if (body_id < BODY_MERCURY || body_id > BODY_NEPTUNE)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("planet_equatorial_apparent: body_id %d must be 1-8 (Mercury-Neptune)",
body_id)));
if (body_id == BODY_EARTH)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot observe Earth from Earth")));
jd = timestamptz_to_jd(ts);
vsop_body = body_id - 1;
/* Earth at observation time */
GetVsop87Coor(jd, 2, earth_xyz);
/* Geometric planet for initial distance */
GetVsop87Coor(jd, vsop_body, planet_xyz);
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
geo_dist = sqrt(geo_ecl[0]*geo_ecl[0] + geo_ecl[1]*geo_ecl[1] + geo_ecl[2]*geo_ecl[2]);
/* Retarded planet position */
tau = geo_dist / C_LIGHT_AU_DAY;
GetVsop87Coor(jd - tau, vsop_body, planet_xyz);
/* Apparent geocentric */
geo_ecl[0] = planet_xyz[0] - earth_xyz[0];
geo_ecl[1] = planet_xyz[1] - earth_xyz[1];
geo_ecl[2] = planet_xyz[2] - earth_xyz[2];
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(geo_ecl, jd, result);
PG_RETURN_POINTER(result);
}
/* ================================================================
* moon_equatorial_apparent(timestamptz) -> equatorial
*
* Light-time corrected geocentric RA/Dec of the Moon.
*
* ELP2000-82B returns geocentric ecliptic directly (no Earth
* subtraction needed). Moon is ~1.3 light-seconds away, so
* tau ~ 0.000015 days. Small but included for consistency.
* ================================================================
*/
Datum
moon_equatorial_apparent(PG_FUNCTION_ARGS)
{
int64 ts = PG_GETARG_INT64(0);
double jd;
double moon_ecl[3];
double geo_dist, tau;
pg_equatorial *result;
jd = timestamptz_to_jd(ts);
/* Geometric Moon geocentric ecliptic J2000 (AU) */
GetElp82bCoor(jd, moon_ecl);
geo_dist = sqrt(moon_ecl[0]*moon_ecl[0] + moon_ecl[1]*moon_ecl[1] + moon_ecl[2]*moon_ecl[2]);
/* Retarded Moon position */
tau = geo_dist / C_LIGHT_AU_DAY;
GetElp82bCoor(jd - tau, moon_ecl);
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
geocentric_to_equatorial(moon_ecl, jd, result);
PG_RETURN_POINTER(result);
}

120
src/refraction_funcs.c Normal file
View File

@ -0,0 +1,120 @@
/*
* refraction_funcs.c -- Atmospheric refraction for pg_orrery
*
* Bennett's (1982) formula for standard atmosphere, with optional
* pressure/temperature correction per Meeus (1991).
*
* Domain guard: clamp geometric elevation to -1 deg before tan()
* evaluation. Below that, Bennett's formula is outside its valid
* range and we return 0.
*/
#include "postgres.h"
#include "fmgr.h"
#include "types.h"
#include <math.h>
PG_FUNCTION_INFO_V1(atmospheric_refraction);
PG_FUNCTION_INFO_V1(atmospheric_refraction_ext);
PG_FUNCTION_INFO_V1(topo_elevation_apparent);
#define RAD_TO_DEG (180.0 / M_PI)
#define DEG_TO_RAD (M_PI / 180.0)
/*
* bennett_refraction -- core Bennett (1982) formula
*
* Input: geometric elevation in degrees
* Output: refraction correction in degrees (>= 0)
*
* R = 1/tan(h + 7.31/(h + 4.4)) arcminutes
* where h is the geometric elevation in degrees.
*
* The formula diverges near h = -4.4 deg; clamp to -1 deg
* (below which the atmosphere model is meaningless anyway).
*/
static double
bennett_refraction(double h_deg)
{
double h, R;
if (h_deg < -1.0)
return 0.0;
h = fmax(h_deg, -1.0);
/* Bennett's formula: arcminutes */
R = 1.0 / tan((h + 7.31 / (h + 4.4)) * DEG_TO_RAD);
if (!isfinite(R))
return 0.0;
/* Convert arcminutes to degrees */
R /= 60.0;
if (R < 0.0)
R = 0.0;
return R;
}
/*
* atmospheric_refraction(elevation_deg) -> refraction_deg
*
* Standard atmosphere: P = 1010 mbar, T = 10 C.
*/
Datum
atmospheric_refraction(PG_FUNCTION_ARGS)
{
double h_deg = PG_GETARG_FLOAT8(0);
PG_RETURN_FLOAT8(bennett_refraction(h_deg));
}
/*
* atmospheric_refraction_ext(elevation_deg, pressure_mbar, temp_celsius)
* -> refraction_deg
*
* Meeus correction for non-standard atmosphere:
* R_corrected = R * (P / 1010.0) * (283.0 / (273.0 + T))
*/
Datum
atmospheric_refraction_ext(PG_FUNCTION_ARGS)
{
double h_deg = PG_GETARG_FLOAT8(0);
double P = PG_GETARG_FLOAT8(1);
double T = PG_GETARG_FLOAT8(2);
double R;
R = bennett_refraction(h_deg);
/* Meeus pressure/temperature correction */
R *= (P / 1010.0) * (283.0 / (273.0 + T));
if (!isfinite(R) || R < 0.0)
R = 0.0;
PG_RETURN_FLOAT8(R);
}
/*
* topo_elevation_apparent(topocentric) -> apparent_elevation_deg
*
* Geometric elevation from the topocentric type plus Bennett's
* atmospheric refraction correction.
*/
Datum
topo_elevation_apparent(PG_FUNCTION_ARGS)
{
pg_topocentric *topo = (pg_topocentric *) PG_GETARG_POINTER(0);
double el_deg;
double refr;
el_deg = topo->elevation * RAD_TO_DEG;
refr = bennett_refraction(el_deg);
PG_RETURN_FLOAT8(el_deg + refr);
}

View File

@ -6,8 +6,10 @@
* local hour angle, and converts to topocentric azimuth/elevation. * local hour angle, and converts to topocentric azimuth/elevation.
* *
* Range and range_rate are zero -- stars are effectively at infinity. * Range and range_rate are zero -- stars are effectively at infinity.
* For objects with known proper motion, apply it to (RA, Dec) before *
* calling star_observe. * star_observe / star_observe_safe: catalog J2000 coords only.
* star_observe_pm / star_equatorial_pm: proper motion, parallax, RV.
* star_equatorial: catalog J2000 to apparent equatorial of date.
*/ */
#include "postgres.h" #include "postgres.h"
@ -18,6 +20,9 @@
PG_FUNCTION_INFO_V1(star_observe); PG_FUNCTION_INFO_V1(star_observe);
PG_FUNCTION_INFO_V1(star_observe_safe); PG_FUNCTION_INFO_V1(star_observe_safe);
PG_FUNCTION_INFO_V1(star_observe_pm);
PG_FUNCTION_INFO_V1(star_equatorial_pm);
PG_FUNCTION_INFO_V1(star_equatorial);
/* /*
* star_observe(ra_hours, dec_degrees, observer, timestamptz) -> topocentric * star_observe(ra_hours, dec_degrees, observer, timestamptz) -> topocentric
@ -120,3 +125,221 @@ star_observe_safe(PG_FUNCTION_ARGS)
PG_RETURN_POINTER(result); PG_RETURN_POINTER(result);
} }
/*
* star_observe_pm(ra_hours, dec_deg, pm_ra_masyr, pm_dec_masyr,
* parallax_mas, rv_kms, observer, timestamptz) -> topocentric
*
* Full star observation with proper motion correction applied before
* IAU 1976 precession. pm_ra is mu_alpha*cos(delta) in mas/yr
* (Hipparcos/Gaia convention). Parallax and radial velocity accepted
* for API symmetry with star_equatorial_pm but not yet applied to
* the topocentric result (would require Earth's heliocentric position).
*/
Datum
star_observe_pm(PG_FUNCTION_ARGS)
{
double ra_hours = PG_GETARG_FLOAT8(0);
double dec_deg = PG_GETARG_FLOAT8(1);
double pm_ra_masyr = PG_GETARG_FLOAT8(2);
double pm_dec_masyr = PG_GETARG_FLOAT8(3);
double parallax_mas = PG_GETARG_FLOAT8(4);
double rv_kms = PG_GETARG_FLOAT8(5);
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(6);
int64 ts = PG_GETARG_INT64(7);
double jd, dt_years;
double ra_j2000, dec_j2000;
double cos_dec, ra_corrected, dec_corrected;
double ra_date, dec_date;
double gmst, lst, ha;
double az, el;
pg_topocentric *result;
if (ra_hours < 0.0 || ra_hours >= 24.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("right ascension out of range: %.6f", ra_hours),
errhint("RA must be in [0, 24) hours.")));
if (dec_deg < -90.0 || dec_deg > 90.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("declination out of range: %.6f", dec_deg),
errhint("Declination must be between -90 and +90 degrees.")));
jd = timestamptz_to_jd(ts);
dt_years = (jd - J2000_JD) / 365.25;
if (fabs(dt_years) > 200.0)
ereport(NOTICE,
(errmsg("proper motion extrapolation %.0f years from J2000 — accuracy degrades beyond ~200 years", dt_years)));
ra_j2000 = ra_hours * (M_PI / 12.0);
dec_j2000 = dec_deg * DEG_TO_RAD;
/* Apply proper motion (linear in J2000 frame).
* pm_ra is mu_alpha*cos(delta), so divide by cos(dec) to get dRA. */
cos_dec = cos(dec_j2000);
if (fabs(cos_dec) < cos(89.99 * DEG_TO_RAD))
cos_dec = (cos_dec >= 0.0 ? 1.0 : -1.0) * cos(89.99 * DEG_TO_RAD);
ra_corrected = ra_j2000 + (pm_ra_masyr / 3.6e6) * DEG_TO_RAD / cos_dec * dt_years;
dec_corrected = dec_j2000 + (pm_dec_masyr / 3.6e6) * DEG_TO_RAD * dt_years;
if (dec_corrected > M_PI / 2.0)
dec_corrected = M_PI / 2.0;
if (dec_corrected < -M_PI / 2.0)
dec_corrected = -M_PI / 2.0;
ra_corrected = fmod(ra_corrected, 2.0 * M_PI);
if (ra_corrected < 0.0)
ra_corrected += 2.0 * M_PI;
/* Parallax and RV accepted for API completeness; positional effect
* is sub-arcsecond for all but the nearest stars. */
(void) parallax_mas;
(void) rv_kms;
precess_j2000_to_date(jd, ra_corrected, dec_corrected, &ra_date, &dec_date);
gmst = gmst_from_jd(jd);
lst = gmst + obs->lon;
ha = lst - ra_date;
equatorial_to_horizontal(ha, dec_date, obs->lat, &az, &el);
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
result->azimuth = az;
result->elevation = el;
result->range_km = 0.0;
result->range_rate = 0.0;
PG_RETURN_POINTER(result);
}
/*
* star_equatorial_pm(ra_hours, dec_deg, pm_ra_masyr, pm_dec_masyr,
* parallax_mas, rv_kms, timestamptz) -> equatorial
*
* Proper-motion-corrected apparent equatorial coordinates of date.
* No observer needed (geocentric). If parallax > 0, distance is
* derived as 1000/parallax_mas parsecs converted to km.
*/
Datum
star_equatorial_pm(PG_FUNCTION_ARGS)
{
double ra_hours = PG_GETARG_FLOAT8(0);
double dec_deg = PG_GETARG_FLOAT8(1);
double pm_ra_masyr = PG_GETARG_FLOAT8(2);
double pm_dec_masyr = PG_GETARG_FLOAT8(3);
double parallax_mas = PG_GETARG_FLOAT8(4);
double rv_kms = PG_GETARG_FLOAT8(5);
int64 ts = PG_GETARG_INT64(6);
double jd, dt_years;
double ra_j2000, dec_j2000;
double cos_dec, ra_corrected, dec_corrected;
double ra_date, dec_date;
pg_equatorial *result;
(void) rv_kms;
if (ra_hours < 0.0 || ra_hours >= 24.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("right ascension out of range: %.6f", ra_hours),
errhint("RA must be in [0, 24) hours.")));
if (dec_deg < -90.0 || dec_deg > 90.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("declination out of range: %.6f", dec_deg),
errhint("Declination must be between -90 and +90 degrees.")));
jd = timestamptz_to_jd(ts);
dt_years = (jd - J2000_JD) / 365.25;
if (fabs(dt_years) > 200.0)
ereport(NOTICE,
(errmsg("proper motion extrapolation %.0f years from J2000 — accuracy degrades beyond ~200 years", dt_years)));
ra_j2000 = ra_hours * (M_PI / 12.0);
dec_j2000 = dec_deg * DEG_TO_RAD;
cos_dec = cos(dec_j2000);
if (fabs(cos_dec) < cos(89.99 * DEG_TO_RAD))
cos_dec = (cos_dec >= 0.0 ? 1.0 : -1.0) * cos(89.99 * DEG_TO_RAD);
ra_corrected = ra_j2000 + (pm_ra_masyr / 3.6e6) * DEG_TO_RAD / cos_dec * dt_years;
dec_corrected = dec_j2000 + (pm_dec_masyr / 3.6e6) * DEG_TO_RAD * dt_years;
if (dec_corrected > M_PI / 2.0)
dec_corrected = M_PI / 2.0;
if (dec_corrected < -M_PI / 2.0)
dec_corrected = -M_PI / 2.0;
ra_corrected = fmod(ra_corrected, 2.0 * M_PI);
if (ra_corrected < 0.0)
ra_corrected += 2.0 * M_PI;
precess_j2000_to_date(jd, ra_corrected, dec_corrected, &ra_date, &dec_date);
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
result->ra = ra_date;
result->dec = dec_date;
/* Distance from parallax: d_pc = 1000/parallax_mas, d_AU = d_pc * 206265 */
if (parallax_mas > 0.0)
result->distance = (1000.0 / parallax_mas) * 206265.0 * AU_KM;
else
result->distance = 0.0;
PG_RETURN_POINTER(result);
}
/*
* star_equatorial(ra_hours, dec_deg, timestamptz) -> equatorial
*
* Precess J2000 catalog coordinates to apparent equatorial of date.
* Distance is zero (no parallax information).
*/
Datum
star_equatorial(PG_FUNCTION_ARGS)
{
double ra_hours = PG_GETARG_FLOAT8(0);
double dec_deg = PG_GETARG_FLOAT8(1);
int64 ts = PG_GETARG_INT64(2);
double jd, ra_j2000, dec_j2000, ra_date, dec_date;
pg_equatorial *result;
if (ra_hours < 0.0 || ra_hours >= 24.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("right ascension out of range: %.6f", ra_hours),
errhint("RA must be in [0, 24) hours.")));
if (dec_deg < -90.0 || dec_deg > 90.0)
ereport(ERROR,
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
errmsg("declination out of range: %.6f", dec_deg),
errhint("Declination must be between -90 and +90 degrees.")));
jd = timestamptz_to_jd(ts);
ra_j2000 = ra_hours * (M_PI / 12.0);
dec_j2000 = dec_deg * DEG_TO_RAD;
precess_j2000_to_date(jd, ra_j2000, dec_j2000, &ra_date, &dec_date);
result = (pg_equatorial *) palloc(sizeof(pg_equatorial));
result->ra = ra_date;
result->dec = dec_date;
result->distance = 0.0;
PG_RETURN_POINTER(result);
}

View File

@ -203,6 +203,50 @@ typedef struct pg_heliocentric
} pg_heliocentric; } pg_heliocentric;
/*
* Orbital elements -- classical Keplerian elements for comets/asteroids
*
* Stores osculation epoch, perihelion distance, eccentricity,
* three angular elements (radians), perihelion passage time,
* and optional photometric parameters (H magnitude, G slope).
* NaN in h_mag/g_slope means "unknown".
*/
typedef struct pg_orbital_elements
{
double epoch; /* osculation epoch, JD UTC */
double q; /* perihelion distance, AU */
double e; /* eccentricity */
double inc; /* inclination, radians */
double arg_peri; /* argument of perihelion (omega), radians */
double raan; /* longitude of ascending node (Omega), radians */
double tp; /* time of perihelion passage, JD UTC */
double h_mag; /* absolute magnitude H (NaN if unknown) */
double g_slope; /* slope parameter G (NaN if unknown) */
} pg_orbital_elements;
/* 9 doubles = 72 bytes, must match INTERNALLENGTH in CREATE TYPE */
/*
* Equatorial coordinates -- apparent RA/Dec of date
*
* Right ascension and declination in radians (internal), displayed as
* hours [0,24) and degrees [-90,90]. Distance in km.
*
* Frame: apparent (of date). Solar system: J2000 precessed via IAU 1976.
* Satellites: TEME (~mean equator of date, residual nutation ~arcsec).
* Matches what GoTo telescope mounts and sky apps expect.
*/
typedef struct pg_equatorial
{
double ra; /* radians, [0, 2*pi) */
double dec; /* radians, [-pi/2, pi/2] */
double distance; /* km (solar system: geo_dist * AU_KM; stars: 0.0) */
} pg_equatorial;
/* 3 doubles = 24 bytes, must match INTERNALLENGTH in CREATE TYPE */
/* /*
* Astronomical constants * Astronomical constants
*/ */
@ -210,6 +254,7 @@ typedef struct pg_heliocentric
#define GAUSS_K 0.01720209895 /* gravitational constant, AU^(3/2)/day */ #define GAUSS_K 0.01720209895 /* gravitational constant, AU^(3/2)/day */
#define GAUSS_K2 (GAUSS_K * GAUSS_K) #define GAUSS_K2 (GAUSS_K * GAUSS_K)
#define OBLIQUITY_J2000 0.40909280422232897 /* 23.4392911 deg in radians */ #define OBLIQUITY_J2000 0.40909280422232897 /* 23.4392911 deg in radians */
#define C_LIGHT_AU_DAY 173.1446327 /* speed of light, AU/day (299792.458 * 86400 / 149597870.7) */
/* /*
* Solar system body IDs (VSOP87 convention, extended) * Solar system body IDs (VSOP87 convention, extended)

View File

@ -0,0 +1,280 @@
-- equatorial type regression tests
--
-- Tests equatorial type I/O, accessor functions, satellite RA/Dec,
-- planet/Sun/Moon/small body equatorial coordinates, stellar
-- precession, proper motion, light-time correction, and DE variants.
\set boulder '''40.015N 105.270W 1655m'''::observer
-- ============================================================
-- Test 1: equatorial type I/O round-trip
-- ============================================================
SELECT 'eq_io' AS test,
'(12.00000000,45.00000000,0.000)'::equatorial::text AS val;
test | val
-------+---------------------------------
eq_io | (12.00000000,45.00000000,0.000)
(1 row)
-- ============================================================
-- Test 2: equatorial accessor functions
-- ============================================================
SELECT 'eq_accessors' AS test,
round(eq_ra(e)::numeric, 4) AS ra_hours,
round(eq_dec(e)::numeric, 4) AS dec_deg,
round(eq_distance(e)::numeric, 1) AS dist_km
FROM (SELECT '(6.50000000,-23.00000000,149597870.700)'::equatorial AS e) sub;
test | ra_hours | dec_deg | dist_km
--------------+----------+----------+-------------
eq_accessors | 6.5000 | -23.0000 | 149597870.7
(1 row)
-- ============================================================
-- Test 3: equatorial input validation - RA out of range
-- ============================================================
SELECT 'eq_bad_ra' AS test, '(25.0,0.0,0.0)'::equatorial;
ERROR: right ascension out of range: 25.000000
LINE 1: SELECT 'eq_bad_ra' AS test, '(25.0,0.0,0.0)'::equatorial;
^
HINT: RA must be in [0, 24) hours.
-- ============================================================
-- Test 4: equatorial input validation - Dec out of range
-- ============================================================
SELECT 'eq_bad_dec' AS test, '(12.0,91.0,0.0)'::equatorial;
ERROR: declination out of range: 91.000000
LINE 1: SELECT 'eq_bad_dec' AS test, '(12.0,91.0,0.0)'::equatorial;
^
HINT: Declination must be between -90 and +90 degrees.
-- ============================================================
-- Test 5: Sun equatorial RA/Dec at J2000.0
-- At 2000-01-01 12:00:00 UTC the Sun should be near RA ~18.75h, Dec ~-23 deg
-- (winter solstice was ~10 days earlier)
-- ============================================================
SELECT 'sun_eq' AS test,
round(eq_ra(sun_equatorial('2000-01-01 12:00:00+00'))::numeric, 1) AS ra_h,
round(eq_dec(sun_equatorial('2000-01-01 12:00:00+00'))::numeric, 0) AS dec_deg;
test | ra_h | dec_deg
--------+------+---------
sun_eq | 18.8 | -23
(1 row)
-- ============================================================
-- Test 6: Sun equatorial at summer solstice ~2024
-- RA should be ~6h, Dec ~+23.4 deg
-- ============================================================
SELECT 'sun_eq_solstice' AS test,
round(eq_ra(sun_equatorial('2024-06-21 12:00:00+00'))::numeric, 0) AS ra_h,
round(eq_dec(sun_equatorial('2024-06-21 12:00:00+00'))::numeric, 0) AS dec_deg;
test | ra_h | dec_deg
-----------------+------+---------
sun_eq_solstice | 6 | 23
(1 row)
-- ============================================================
-- Test 7: Moon equatorial returns valid coordinates
-- Range should be 356k-407k km
-- ============================================================
SELECT 'moon_eq' AS test,
eq_ra(moon_equatorial('2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid,
eq_dec(moon_equatorial('2024-06-21 12:00:00+00')) BETWEEN -90 AND 90 AS dec_valid,
eq_distance(moon_equatorial('2024-06-21 12:00:00+00')) BETWEEN 350000 AND 410000 AS range_valid;
test | ra_valid | dec_valid | range_valid
---------+----------+-----------+-------------
moon_eq | t | t | t
(1 row)
-- ============================================================
-- Test 8: Jupiter equatorial returns valid coordinates
-- Distance should be ~588M-968M km (3.93-6.47 AU)
-- ============================================================
SELECT 'jupiter_eq' AS test,
eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid,
eq_dec(planet_equatorial(5, '2024-06-21 12:00:00+00')) BETWEEN -90 AND 90 AS dec_valid,
eq_distance(planet_equatorial(5, '2024-06-21 12:00:00+00')) BETWEEN 500000000 AND 1000000000 AS range_valid;
test | ra_valid | dec_valid | range_valid
------------+----------+-----------+-------------
jupiter_eq | t | t | t
(1 row)
-- ============================================================
-- Test 9: planet_equatorial error - cannot observe Earth
-- ============================================================
SELECT 'earth_eq_error' AS test, planet_equatorial(3, now());
ERROR: cannot observe Earth from Earth
-- ============================================================
-- Test 10: star_equatorial at J2000.0 (precession = identity)
-- Polaris RA 2.530303h Dec 89.2641 deg should be unchanged
-- ============================================================
SELECT 'star_eq_j2000' AS test,
round(eq_ra(star_equatorial(2.530303, 89.2641, '2000-01-01 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_dec(star_equatorial(2.530303, 89.2641, '2000-01-01 12:00:00+00'))::numeric, 4) AS dec_deg,
round(eq_distance(star_equatorial(2.530303, 89.2641, '2000-01-01 12:00:00+00'))::numeric, 1) AS dist;
test | ra_h | dec_deg | dist
---------------+--------+---------+------
star_eq_j2000 | 2.5303 | 89.2641 | 0.0
(1 row)
-- ============================================================
-- Test 11: star_equatorial with precession (25 years from J2000)
-- RA should shift slightly due to precession
-- ============================================================
SELECT 'star_eq_precessed' AS test,
round(eq_ra(star_equatorial(2.530303, 89.2641, '2025-06-15 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_dec(star_equatorial(2.530303, 89.2641, '2025-06-15 12:00:00+00'))::numeric, 4) AS dec_deg;
test | ra_h | dec_deg
-------------------+--------+---------
star_eq_precessed | 3.0836 | 89.3695
(1 row)
-- ============================================================
-- Test 12: star_equatorial_pm for Barnard's Star
-- Barnard's Star: RA 17.963472h, Dec 4.6933 deg
-- pm_ra = -798.58 mas/yr, pm_dec = 10328.12 mas/yr
-- parallax = 545.4 mas, rv = -110.51 km/s
-- After 25 years, Dec should shift by ~0.26 deg
-- ============================================================
SELECT 'barnard_pm' AS test,
round(eq_dec(star_equatorial_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
'2025-01-01 12:00:00+00'))::numeric, 2) AS dec_deg,
round(eq_distance(star_equatorial_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
'2025-01-01 12:00:00+00'))::numeric, -6) AS dist_km;
test | dec_deg | dist_km
------------+---------+----------------
barnard_pm | 4.76 | 56576466000000
(1 row)
-- ============================================================
-- Test 13: star_observe_pm returns valid topocentric
-- ============================================================
SELECT 'barnard_topo' AS test,
round(topo_azimuth(star_observe_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
:boulder, '2024-07-15 04:00:00+00'))::numeric, 0) AS az_deg,
round(topo_elevation(star_observe_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
:boulder, '2024-07-15 04:00:00+00'))::numeric, 0) AS el_deg;
test | az_deg | el_deg
--------------+--------+--------
barnard_topo | 146 | 50
(1 row)
-- ============================================================
-- Test 14: Satellite equatorial (geocentric) from ECI
-- ISS at known ECI: should get valid RA/Dec
-- ============================================================
SELECT 'sat_eq_geo' AS test,
eq_ra(eci_to_equatorial_geo(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
'2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid,
eq_dec(eci_to_equatorial_geo(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
'2024-06-21 12:00:00+00')) BETWEEN -90 AND 90 AS dec_valid;
test | ra_valid | dec_valid
------------+----------+-----------
sat_eq_geo | t | t
(1 row)
-- ============================================================
-- Test 15: Satellite equatorial (topocentric vs geocentric)
-- Topocentric should differ from geocentric by ~0.5-1 deg for LEO
-- ============================================================
SELECT 'sat_parallax' AS test,
round(abs(
eq_ra(eci_to_equatorial(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
:boulder, '2024-06-21 12:00:00+00'))
- eq_ra(eci_to_equatorial_geo(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
'2024-06-21 12:00:00+00'))
)::numeric, 2) AS parallax_hours;
test | parallax_hours
--------------+----------------
sat_parallax | 0.16
(1 row)
-- ============================================================
-- Test 16: All planets return valid equatorial coordinates
-- ============================================================
SELECT 'all_planets_eq' AS test,
body_id,
round(eq_ra(planet_equatorial(body_id, '2024-06-21 12:00:00+00'))::numeric, 2) AS ra_h,
round(eq_dec(planet_equatorial(body_id, '2024-06-21 12:00:00+00'))::numeric, 1) AS dec_deg
FROM generate_series(1, 8) AS body_id
WHERE body_id != 3;
test | body_id | ra_h | dec_deg
----------------+---------+-------+---------
all_planets_eq | 1 | 6.65 | 24.9
all_planets_eq | 2 | 6.38 | 23.9
all_planets_eq | 4 | 2.47 | 13.5
all_planets_eq | 5 | 4.29 | 20.6
all_planets_eq | 6 | 23.40 | -6.0
all_planets_eq | 7 | 3.53 | 18.8
all_planets_eq | 8 | 0.03 | -1.2
(7 rows)
-- ============================================================
-- Test 17: planet_equatorial_apparent (light-time corrected)
-- Jupiter's light-time is ~35-52 min; apparent RA should differ
-- from geometric by a small amount
-- ============================================================
SELECT 'jupiter_apparent' AS test,
round(abs(
eq_ra(planet_equatorial_apparent(5, '2024-06-21 12:00:00+00'))
- eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))
)::numeric, 4) AS ra_diff_hours;
test | ra_diff_hours
------------------+---------------
jupiter_apparent | 0.0002
(1 row)
-- ============================================================
-- Test 18: moon_equatorial_apparent (light-time ~1.3 sec)
-- Difference from geometric should be tiny
-- ============================================================
SELECT 'moon_apparent' AS test,
round(abs(
eq_ra(moon_equatorial_apparent('2024-06-21 12:00:00+00'))
- eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))
)::numeric, 6) AS ra_diff_hours;
test | ra_diff_hours
---------------+---------------
moon_apparent | 0.000015
(1 row)
-- ============================================================
-- Test 19: Small body equatorial with Ceres
-- Ceres orbital elements (approximate)
-- ============================================================
SELECT 'ceres_eq' AS test,
eq_ra(small_body_equatorial(
'(2460400.5,2.5577,0.0785,0.1849,1.2836,1.4013,2460500.0,3.53,0.12)'::orbital_elements,
'2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid;
test | ra_valid
----------+----------
ceres_eq | t
(1 row)
-- ============================================================
-- Test 20: DE equatorial variants (fall back to VSOP87)
-- Without DE configured, these should produce same results as VSOP87
-- ============================================================
SELECT 'de_planet_eq' AS test,
round(eq_ra(planet_equatorial_de(5, '2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h_vsop,
round(eq_ra(planet_equatorial_de(5, '2024-06-21 12:00:00+00'))::numeric, 4) =
round(eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))::numeric, 4) AS match;
test | ra_h | ra_h_vsop | match
--------------+--------+-----------+-------
de_planet_eq | 4.2922 | 4.2922 | t
(1 row)
SELECT 'de_moon_eq' AS test,
round(eq_ra(moon_equatorial_de('2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h_elp,
round(eq_ra(moon_equatorial_de('2024-06-21 12:00:00+00'))::numeric, 4) =
round(eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))::numeric, 4) AS match;
test | ra_h | ra_h_elp | match
------------+---------+----------+-------
de_moon_eq | 17.5281 | 17.5281 | t
(1 row)

View File

@ -42,20 +42,21 @@ ORDER BY a.name, b.name;
ISS | Hubble | f ISS | Hubble | f
(6 rows) (6 rows)
-- Altitude distance: ISS <-> Equatorial-LEO should be ~0 (same altitude shell) -- Orbital distance: 2-D metric (altitude + inclination in km)
-- ISS <-> Equatorial-LEO should be ~5192 (0 km alt gap + 47° inc diff × 6378 km)
SELECT a.name AS sat_a, b.name AS sat_b, SELECT a.name AS sat_a, b.name AS sat_b,
round((a.tle <-> b.tle)::numeric, 0) AS alt_dist_km round((a.tle <-> b.tle)::numeric, 0) AS orbital_dist_km
FROM test_orbits a, test_orbits b FROM test_orbits a, test_orbits b
WHERE a.id < b.id WHERE a.id < b.id
ORDER BY a.name, b.name; ORDER BY a.name, b.name;
sat_a | sat_b | alt_dist_km sat_a | sat_b | orbital_dist_km
---------+----------------+------------- ---------+----------------+-----------------
GPS-IIR | Equatorial-LEO | 19451 GPS-IIR | Equatorial-LEO | 20245
Hubble | Equatorial-LEO | 115 Hubble | Equatorial-LEO | 2615
Hubble | GPS-IIR | 19332 Hubble | GPS-IIR | 19564
ISS | Equatorial-LEO | 0 ISS | Equatorial-LEO | 5192
ISS | GPS-IIR | 19451 ISS | GPS-IIR | 19456
ISS | Hubble | 115 ISS | Hubble | 2582
(6 rows) (6 rows)
-- GiST index scan: find all sats overlapping ISS (altitude AND inclination) -- GiST index scan: find all sats overlapping ISS (altitude AND inclination)
@ -70,7 +71,7 @@ ORDER BY name;
(1 row) (1 row)
RESET enable_seqscan; RESET enable_seqscan;
-- Nearest-neighbor via GiST: order by altitude distance to ISS -- Nearest-neighbor via GiST: order by 2-D orbital distance to ISS
SET enable_seqscan = off; SET enable_seqscan = off;
SELECT name, round((tle <-> (SELECT tle FROM test_orbits WHERE name = 'ISS'))::numeric, 0) AS dist SELECT name, round((tle <-> (SELECT tle FROM test_orbits WHERE name = 'ISS'))::numeric, 0) AS dist
FROM test_orbits FROM test_orbits
@ -78,9 +79,9 @@ WHERE name != 'ISS'
ORDER BY tle <-> (SELECT tle FROM test_orbits WHERE name = 'ISS'); ORDER BY tle <-> (SELECT tle FROM test_orbits WHERE name = 'ISS');
name | dist name | dist
----------------+------- ----------------+-------
Equatorial-LEO | 0 Hubble | 2582
Hubble | 115 Equatorial-LEO | 5192
GPS-IIR | 19451 GPS-IIR | 19456
(3 rows) (3 rows)
RESET enable_seqscan; RESET enable_seqscan;

View File

@ -0,0 +1,321 @@
-- orbital_elements regression tests
--
-- Tests orbital_elements type I/O, accessors, MPC parser,
-- small_body_heliocentric(), and small_body_observe().
CREATE EXTENSION IF NOT EXISTS pg_orrery;
NOTICE: extension "pg_orrery" already exists, skipping
\set boulder '''40.015N 105.270W 1655m'''::observer
-- ============================================================
-- Test 1: Type I/O round-trip
-- Construct orbital_elements from text literal, verify output matches.
-- Angles in degrees, stored as radians internally.
-- ============================================================
SELECT 'io_roundtrip' AS test,
'(2460600.000000,2.5478000000,0.0789126000,10.586640,73.429370,80.268600,2460319.000000,3.33,0.12)'::orbital_elements AS oe;
test | oe
--------------+---------------------------------------------------------------------------------------------------
io_roundtrip | (2460600.000000,2.5478000000,0.0789126000,10.586640,73.429370,80.268600,2460319.000000,3.33,0.12)
(1 row)
-- ============================================================
-- Test 2: Accessor functions (direct field access)
-- ============================================================
SELECT 'accessor_epoch' AS test,
round(oe_epoch('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 1) AS epoch_jd;
test | epoch_jd
----------------+-----------
accessor_epoch | 2460600.0
(1 row)
SELECT 'accessor_q' AS test,
round(oe_perihelion('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 4) AS q_au;
test | q_au
------------+--------
accessor_q | 2.5478
(1 row)
SELECT 'accessor_e' AS test,
round(oe_eccentricity('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 7) AS ecc;
test | ecc
------------+-----------
accessor_e | 0.0789126
(1 row)
SELECT 'accessor_inc' AS test,
round(oe_inclination('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 5) AS inc_deg;
test | inc_deg
--------------+----------
accessor_inc | 10.58664
(1 row)
SELECT 'accessor_omega' AS test,
round(oe_arg_perihelion('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 5) AS omega_deg;
test | omega_deg
----------------+-----------
accessor_omega | 73.42937
(1 row)
SELECT 'accessor_Omega' AS test,
round(oe_raan('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 4) AS Omega_deg;
test | omega_deg
----------------+-----------
accessor_Omega | 80.2686
(1 row)
SELECT 'accessor_tp' AS test,
round(oe_tp('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 1) AS tp_jd;
test | tp_jd
-------------+-----------
accessor_tp | 2460319.0
(1 row)
SELECT 'accessor_h' AS test,
round(oe_h_mag('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 2) AS h_mag;
test | h_mag
------------+-------
accessor_h | 3.33
(1 row)
SELECT 'accessor_g' AS test,
round(oe_g_slope('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 2) AS g_slope;
test | g_slope
------------+---------
accessor_g | 0.12
(1 row)
-- ============================================================
-- Test 3: Computed accessors
-- ============================================================
-- Semi-major axis: a = q / (1 - e) = 2.5478 / (1 - 0.0789126) ~ 2.766 AU
SELECT 'sma_elliptic' AS test,
round(oe_semi_major_axis('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 3) AS a_au;
test | a_au
--------------+-------
sma_elliptic | 2.766
(1 row)
-- Period: a^1.5 years. a ~ 2.766, period ~ 4.599 years
SELECT 'period_elliptic' AS test,
round(oe_period_years('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 2) AS period_yr;
test | period_yr
-----------------+-----------
period_elliptic | 4.60
(1 row)
-- Hyperbolic orbit (e=1.5): semi-major axis and period should be NULL
SELECT 'sma_hyperbolic' AS test,
oe_semi_major_axis('(2460600.0,1.0,1.5,10.0,73.0,80.0,2460319.0,NaN,NaN)'::orbital_elements) IS NULL AS is_null;
test | is_null
----------------+---------
sma_hyperbolic | t
(1 row)
SELECT 'period_hyperbolic' AS test,
oe_period_years('(2460600.0,1.0,1.5,10.0,73.0,80.0,2460319.0,NaN,NaN)'::orbital_elements) IS NULL AS is_null;
test | is_null
-------------------+---------
period_hyperbolic | t
(1 row)
-- ============================================================
-- Test 4: MPC parser -- (1) Ceres
--
-- MPCORB.DAT line for Ceres (epoch 2024 Oct 17.0 = K24AM):
-- Packed epoch K24AM -> K=2000, 24, A=Oct, M=22 -> 2024-10-22
-- (Actually: K=2000, year=24, month=A=10, day=M=22)
-- JD for 2024-10-22 = 2460605.5
--
-- a = 2.7660961 AU, e = 0.0789126
-- q = a*(1-e) = 2.7660961*(1-0.0789126) = 2.5478...
-- M = 60.07966 deg
-- ============================================================
SELECT 'mpc_ceres_sma' AS test,
round(oe_semi_major_axis(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 4) AS a_au;
test | a_au
---------------+--------
mpc_ceres_sma | 2.7661
(1 row)
SELECT 'mpc_ceres_q' AS test,
round(oe_perihelion(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 4) AS q_au;
test | q_au
-------------+--------
mpc_ceres_q | 2.5478
(1 row)
SELECT 'mpc_ceres_ecc' AS test,
round(oe_eccentricity(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 7) AS ecc;
test | ecc
---------------+-----------
mpc_ceres_ecc | 0.0789126
(1 row)
SELECT 'mpc_ceres_inc' AS test,
round(oe_inclination(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 5) AS inc_deg;
test | inc_deg
---------------+----------
mpc_ceres_inc | 10.58664
(1 row)
SELECT 'mpc_ceres_h' AS test,
round(oe_h_mag(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 2) AS h_mag;
test | h_mag
-------------+-------
mpc_ceres_h | 3.33
(1 row)
-- ============================================================
-- Test 5: small_body_heliocentric -- compare to kepler_propagate
-- Both should produce the same heliocentric position for Ceres.
-- ============================================================
SELECT 'helio_vs_kepler' AS test,
round(helio_x(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 6) AS x_au,
round(helio_y(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 6) AS y_au,
round(helio_z(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 6) AS z_au;
test | x_au | y_au | z_au
-----------------+-----------+-----------+----------
helio_vs_kepler | -1.430911 | -2.313853 | 0.190494
(1 row)
-- Verify heliocentric distance is reasonable (Ceres orbits ~2.5-3.0 AU)
SELECT 'helio_distance' AS test,
round(helio_distance(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 2) AS dist_au,
helio_distance(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
)) BETWEEN 2.5 AND 3.0 AS in_range;
test | dist_au | in_range
----------------+---------+----------
helio_distance | 2.73 | t
(1 row)
-- ============================================================
-- Test 6: small_body_observe -- topocentric observation
-- Verify we get reasonable az/el/range for Ceres from Boulder.
-- Range should be on the order of 1.5-4.5 AU in km.
-- ============================================================
SELECT 'observe_range' AS test,
topo_range(small_body_observe(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
:boulder, '2025-01-01 00:00:00+00'
)) BETWEEN 1.5 * 149597870.7 AND 4.5 * 149597870.7 AS range_reasonable;
test | range_reasonable
---------------+------------------
observe_range | t
(1 row)
-- Elevation should be a finite number
SELECT 'observe_elevation' AS test,
topo_elevation(small_body_observe(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
:boulder, '2025-01-01 00:00:00+00'
)) BETWEEN -90 AND 90 AS el_reasonable;
test | el_reasonable
-------------------+---------------
observe_elevation | t
(1 row)
-- ============================================================
-- Test 7: Parabolic/hyperbolic elements via text I/O
-- Verify type handles e >= 1 without error.
-- ============================================================
SELECT 'parabolic_io' AS test,
oe_eccentricity('(2460600.0,1.0,1.0,45.0,90.0,180.0,2460500.0,12.0,0.15)'::orbital_elements) AS ecc;
test | ecc
--------------+-----
parabolic_io | 1
(1 row)
SELECT 'hyperbolic_io' AS test,
oe_eccentricity('(2460600.0,0.5,2.5,30.0,60.0,120.0,2460400.0,8.0,0.04)'::orbital_elements) AS ecc;
test | ecc
---------------+-----
hyperbolic_io | 2.5
(1 row)
-- ============================================================
-- Test 8: Error paths
-- ============================================================
-- Invalid text input (wrong number of fields)
SELECT 'bad_text' AS test, '(1.0,2.0,3.0)'::orbital_elements;
ERROR: invalid input syntax for type orbital_elements: "(1.0,2.0,3.0)"
LINE 1: SELECT 'bad_text' AS test, '(1.0,2.0,3.0)'::orbital_elements...
^
HINT: Expected (epoch_jd,q_au,e,inc_deg,omega_deg,Omega_deg,tp_jd,H,G).
-- Negative perihelion distance
SELECT 'negative_q' AS test, '(2460600.0,-1.0,0.5,10.0,73.0,80.0,2460319.0,3.33,0.12)'::orbital_elements;
ERROR: perihelion distance must be positive: -1.000000
LINE 1: SELECT 'negative_q' AS test, '(2460600.0,-1.0,0.5,10.0,73.0,...
^
-- Negative eccentricity
SELECT 'negative_e' AS test, '(2460600.0,1.0,-0.1,10.0,73.0,80.0,2460319.0,3.33,0.12)'::orbital_elements;
ERROR: eccentricity must be non-negative: -0.100000
LINE 1: SELECT 'negative_e' AS test, '(2460600.0,1.0,-0.1,10.0,73.0,...
^
-- MPC line too short
SELECT 'mpc_short' AS test, oe_from_mpc('too short');
ERROR: MPC line too short: 9 characters (need at least 103)
-- ============================================================
-- Test 9: MPC packed date decoding
-- Verify K24AM -> 2024-10-22 -> JD 2460605.5
-- ============================================================
SELECT 'mpc_epoch' AS test,
round(oe_epoch(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 1) AS epoch_jd;
test | epoch_jd
-----------+-----------
mpc_epoch | 2460605.5
(1 row)
-- ============================================================
-- Test 10: Consistency -- small_body_observe vs comet_observe
-- Both pipelines should produce the same topocentric result
-- when fed the same elements and Earth position.
-- ============================================================
WITH ceres AS (
SELECT oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
) AS oe
), earth AS (
SELECT planet_heliocentric(3, '2025-06-15 12:00:00+00') AS pos
)
SELECT 'pipeline_match' AS test,
round(abs(
topo_elevation(small_body_observe(c.oe, :boulder, '2025-06-15 12:00:00+00'))
- topo_elevation(comet_observe(
oe_perihelion(c.oe), oe_eccentricity(c.oe),
oe_inclination(c.oe), oe_arg_perihelion(c.oe), oe_raan(c.oe),
oe_tp(c.oe),
helio_x(e.pos), helio_y(e.pos), helio_z(e.pos),
:boulder, '2025-06-15 12:00:00+00'
))
)::numeric, 6) AS el_diff_deg
FROM ceres c, earth e;
test | el_diff_deg
----------------+-------------
pipeline_match | 0.000000
(1 row)

View File

@ -0,0 +1,197 @@
-- refraction regression tests
--
-- Tests atmospheric refraction (Bennett 1982), pressure/temperature
-- correction, apparent elevation, and refracted pass prediction.
\set boulder '''40.015N 105.270W 1655m'''::observer
-- ISS TLE for pass prediction tests (inline in CTEs below)
-- ============================================================
-- Test 1: Refraction at horizon (0 deg) ~ 0.57 deg
-- Bennett: R = 1/tan(0 + 7.31/4.4) arcmin ~ 34.5 arcmin ~ 0.575 deg
-- ============================================================
SELECT 'refr_horizon' AS test,
round(atmospheric_refraction(0.0)::numeric, 2) AS refr_deg;
test | refr_deg
--------------+----------
refr_horizon | 0.57
(1 row)
-- ============================================================
-- Test 2: Refraction at 30 deg ~ 0.03 deg
-- ============================================================
SELECT 'refr_30deg' AS test,
round(atmospheric_refraction(30.0)::numeric, 3) AS refr_deg;
test | refr_deg
------------+----------
refr_30deg | 0.029
(1 row)
-- ============================================================
-- Test 3: Refraction at zenith (90 deg) ~ 0 deg
-- ============================================================
SELECT 'refr_zenith' AS test,
round(atmospheric_refraction(90.0)::numeric, 4) AS refr_deg;
test | refr_deg
-------------+----------
refr_zenith | 0.0000
(1 row)
-- ============================================================
-- Test 4: Refraction at 10 deg ~ 0.09 deg
-- ============================================================
SELECT 'refr_10deg' AS test,
round(atmospheric_refraction(10.0)::numeric, 3) AS refr_deg;
test | refr_deg
------------+----------
refr_10deg | 0.090
(1 row)
-- ============================================================
-- Test 5: Domain guard - refraction at -5 deg should return 0
-- (below -1 deg validity range)
-- ============================================================
SELECT 'refr_below_range' AS test,
atmospheric_refraction(-5.0) AS refr_deg;
test | refr_deg
------------------+----------
refr_below_range | 0
(1 row)
-- ============================================================
-- Test 6: Domain guard - refraction at -10 deg returns 0 (no NaN)
-- ============================================================
SELECT 'refr_deep_neg' AS test,
atmospheric_refraction(-10.0) AS refr_deg,
atmospheric_refraction(-10.0) = atmospheric_refraction(-10.0) AS is_finite;
test | refr_deg | is_finite
---------------+----------+-----------
refr_deep_neg | 0 | t
(1 row)
-- ============================================================
-- Test 7: Refraction at exactly -1 deg (edge of domain)
-- Should return a small positive value
-- ============================================================
SELECT 'refr_minus1' AS test,
atmospheric_refraction(-1.0) > 0 AS positive;
test | positive
-------------+----------
refr_minus1 | t
(1 row)
-- ============================================================
-- Test 8: Extended refraction with P/T correction
-- Standard: P=1010, T=10 should match basic function
-- ============================================================
SELECT 'refr_ext_standard' AS test,
round(atmospheric_refraction_ext(0.0, 1010.0, 10.0)::numeric, 2) AS refr_deg,
round(atmospheric_refraction(0.0)::numeric, 2) AS refr_basic,
round(atmospheric_refraction_ext(0.0, 1010.0, 10.0)::numeric, 4) =
round(atmospheric_refraction(0.0)::numeric, 4) AS match;
test | refr_deg | refr_basic | match
-------------------+----------+------------+-------
refr_ext_standard | 0.57 | 0.57 | t
(1 row)
-- ============================================================
-- Test 9: Extended refraction - cold high-altitude
-- At P=700 mbar, T=-20 C, refraction should be reduced
-- ============================================================
SELECT 'refr_ext_cold' AS test,
round(atmospheric_refraction_ext(0.0, 700.0, -20.0)::numeric, 2) AS refr_deg,
atmospheric_refraction_ext(0.0, 700.0, -20.0) <
atmospheric_refraction(0.0) AS less_than_standard;
test | refr_deg | less_than_standard
---------------+----------+--------------------
refr_ext_cold | 0.45 | t
(1 row)
-- ============================================================
-- Test 10: Extended refraction - hot sea level
-- At P=1013, T=35 C, refraction should be slightly different
-- ============================================================
SELECT 'refr_ext_hot' AS test,
round(atmospheric_refraction_ext(0.0, 1013.0, 35.0)::numeric, 2) AS refr_deg;
test | refr_deg
--------------+----------
refr_ext_hot | 0.53
(1 row)
-- ============================================================
-- Test 11: Apparent elevation for a topocentric observation
-- Sun near horizon: geometric el small -> apparent el higher
-- ============================================================
SELECT 'apparent_el' AS test,
round(topo_elevation(sun_observe(:boulder, '2024-03-20 00:30:00+00'))::numeric, 1) AS geometric,
round(topo_elevation_apparent(sun_observe(:boulder, '2024-03-20 00:30:00+00'))::numeric, 1) AS apparent,
topo_elevation_apparent(sun_observe(:boulder, '2024-03-20 00:30:00+00')) >
topo_elevation(sun_observe(:boulder, '2024-03-20 00:30:00+00')) AS refraction_positive;
test | geometric | apparent | refraction_positive
-------------+-----------+----------+---------------------
apparent_el | 7.3 | 7.5 | t
(1 row)
-- ============================================================
-- Test 12: Apparent elevation for star high up (refraction small)
-- Polaris from Boulder has el ~49 deg; refraction ~0.01 deg
-- ============================================================
SELECT 'apparent_el_high' AS test,
round(topo_elevation_apparent(
star_observe(2.530303, 89.2641, :boulder, '2024-06-15 04:00:00+00'))::numeric, 1) AS apparent_deg;
test | apparent_deg
------------------+--------------
apparent_el_high | 39.4
(1 row)
-- ============================================================
-- Test 13: Refracted pass prediction returns results
-- Using the ISS TLE, should find passes in a week 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 'refracted_passes' AS test,
count(*) > 0 AS has_passes
FROM iss, predict_passes_refracted(
t, :boulder,
'2024-01-02 00:00:00+00', '2024-01-09 00:00:00+00');
test | has_passes
------------------+------------
refracted_passes | t
(1 row)
-- ============================================================
-- Test 14: Refracted passes find at least as many as standard
-- Because refracted horizon is -0.569 deg, satellites visible ~35s earlier
-- ============================================================
SELECT 'refracted_more_passes' AS test,
(SELECT count(*) FROM predict_passes_refracted(
'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,
:boulder,
'2024-01-02 00:00:00+00', '2024-01-09 00:00:00+00'))
>=
(SELECT count(*) FROM predict_passes(
'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,
:boulder,
'2024-01-02 00:00:00+00', '2024-01-09 00:00:00+00'))
AS refracted_ge_standard;
test | refracted_ge_standard
-----------------------+-----------------------
refracted_more_passes | t
(1 row)
-- ============================================================
-- Test 15: Monotonicity - refraction decreases with elevation
-- ============================================================
SELECT 'refr_monotonic' AS test,
atmospheric_refraction(0.0) > atmospheric_refraction(10.0) AS r0_gt_r10,
atmospheric_refraction(10.0) > atmospheric_refraction(30.0) AS r10_gt_r30,
atmospheric_refraction(30.0) > atmospheric_refraction(60.0) AS r30_gt_r60,
atmospheric_refraction(60.0) > atmospheric_refraction(90.0) AS r60_gt_r90;
test | r0_gt_r10 | r10_gt_r30 | r30_gt_r60 | r60_gt_r90
----------------+-----------+------------+------------+------------
refr_monotonic | t | t | t | t
(1 row)

197
test/sql/equatorial.sql Normal file
View File

@ -0,0 +1,197 @@
-- equatorial type regression tests
--
-- Tests equatorial type I/O, accessor functions, satellite RA/Dec,
-- planet/Sun/Moon/small body equatorial coordinates, stellar
-- precession, proper motion, light-time correction, and DE variants.
\set boulder '''40.015N 105.270W 1655m'''::observer
-- ============================================================
-- Test 1: equatorial type I/O round-trip
-- ============================================================
SELECT 'eq_io' AS test,
'(12.00000000,45.00000000,0.000)'::equatorial::text AS val;
-- ============================================================
-- Test 2: equatorial accessor functions
-- ============================================================
SELECT 'eq_accessors' AS test,
round(eq_ra(e)::numeric, 4) AS ra_hours,
round(eq_dec(e)::numeric, 4) AS dec_deg,
round(eq_distance(e)::numeric, 1) AS dist_km
FROM (SELECT '(6.50000000,-23.00000000,149597870.700)'::equatorial AS e) sub;
-- ============================================================
-- Test 3: equatorial input validation - RA out of range
-- ============================================================
SELECT 'eq_bad_ra' AS test, '(25.0,0.0,0.0)'::equatorial;
-- ============================================================
-- Test 4: equatorial input validation - Dec out of range
-- ============================================================
SELECT 'eq_bad_dec' AS test, '(12.0,91.0,0.0)'::equatorial;
-- ============================================================
-- Test 5: Sun equatorial RA/Dec at J2000.0
-- At 2000-01-01 12:00:00 UTC the Sun should be near RA ~18.75h, Dec ~-23 deg
-- (winter solstice was ~10 days earlier)
-- ============================================================
SELECT 'sun_eq' AS test,
round(eq_ra(sun_equatorial('2000-01-01 12:00:00+00'))::numeric, 1) AS ra_h,
round(eq_dec(sun_equatorial('2000-01-01 12:00:00+00'))::numeric, 0) AS dec_deg;
-- ============================================================
-- Test 6: Sun equatorial at summer solstice ~2024
-- RA should be ~6h, Dec ~+23.4 deg
-- ============================================================
SELECT 'sun_eq_solstice' AS test,
round(eq_ra(sun_equatorial('2024-06-21 12:00:00+00'))::numeric, 0) AS ra_h,
round(eq_dec(sun_equatorial('2024-06-21 12:00:00+00'))::numeric, 0) AS dec_deg;
-- ============================================================
-- Test 7: Moon equatorial returns valid coordinates
-- Range should be 356k-407k km
-- ============================================================
SELECT 'moon_eq' AS test,
eq_ra(moon_equatorial('2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid,
eq_dec(moon_equatorial('2024-06-21 12:00:00+00')) BETWEEN -90 AND 90 AS dec_valid,
eq_distance(moon_equatorial('2024-06-21 12:00:00+00')) BETWEEN 350000 AND 410000 AS range_valid;
-- ============================================================
-- Test 8: Jupiter equatorial returns valid coordinates
-- Distance should be ~588M-968M km (3.93-6.47 AU)
-- ============================================================
SELECT 'jupiter_eq' AS test,
eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid,
eq_dec(planet_equatorial(5, '2024-06-21 12:00:00+00')) BETWEEN -90 AND 90 AS dec_valid,
eq_distance(planet_equatorial(5, '2024-06-21 12:00:00+00')) BETWEEN 500000000 AND 1000000000 AS range_valid;
-- ============================================================
-- Test 9: planet_equatorial error - cannot observe Earth
-- ============================================================
SELECT 'earth_eq_error' AS test, planet_equatorial(3, now());
-- ============================================================
-- Test 10: star_equatorial at J2000.0 (precession = identity)
-- Polaris RA 2.530303h Dec 89.2641 deg should be unchanged
-- ============================================================
SELECT 'star_eq_j2000' AS test,
round(eq_ra(star_equatorial(2.530303, 89.2641, '2000-01-01 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_dec(star_equatorial(2.530303, 89.2641, '2000-01-01 12:00:00+00'))::numeric, 4) AS dec_deg,
round(eq_distance(star_equatorial(2.530303, 89.2641, '2000-01-01 12:00:00+00'))::numeric, 1) AS dist;
-- ============================================================
-- Test 11: star_equatorial with precession (25 years from J2000)
-- RA should shift slightly due to precession
-- ============================================================
SELECT 'star_eq_precessed' AS test,
round(eq_ra(star_equatorial(2.530303, 89.2641, '2025-06-15 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_dec(star_equatorial(2.530303, 89.2641, '2025-06-15 12:00:00+00'))::numeric, 4) AS dec_deg;
-- ============================================================
-- Test 12: star_equatorial_pm for Barnard's Star
-- Barnard's Star: RA 17.963472h, Dec 4.6933 deg
-- pm_ra = -798.58 mas/yr, pm_dec = 10328.12 mas/yr
-- parallax = 545.4 mas, rv = -110.51 km/s
-- After 25 years, Dec should shift by ~0.26 deg
-- ============================================================
SELECT 'barnard_pm' AS test,
round(eq_dec(star_equatorial_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
'2025-01-01 12:00:00+00'))::numeric, 2) AS dec_deg,
round(eq_distance(star_equatorial_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
'2025-01-01 12:00:00+00'))::numeric, -6) AS dist_km;
-- ============================================================
-- Test 13: star_observe_pm returns valid topocentric
-- ============================================================
SELECT 'barnard_topo' AS test,
round(topo_azimuth(star_observe_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
:boulder, '2024-07-15 04:00:00+00'))::numeric, 0) AS az_deg,
round(topo_elevation(star_observe_pm(
17.963472, 4.6933, -798.58, 10328.12, 545.4, -110.51,
:boulder, '2024-07-15 04:00:00+00'))::numeric, 0) AS el_deg;
-- ============================================================
-- Test 14: Satellite equatorial (geocentric) from ECI
-- ISS at known ECI: should get valid RA/Dec
-- ============================================================
SELECT 'sat_eq_geo' AS test,
eq_ra(eci_to_equatorial_geo(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
'2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid,
eq_dec(eci_to_equatorial_geo(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
'2024-06-21 12:00:00+00')) BETWEEN -90 AND 90 AS dec_valid;
-- ============================================================
-- Test 15: Satellite equatorial (topocentric vs geocentric)
-- Topocentric should differ from geocentric by ~0.5-1 deg for LEO
-- ============================================================
SELECT 'sat_parallax' AS test,
round(abs(
eq_ra(eci_to_equatorial(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
:boulder, '2024-06-21 12:00:00+00'))
- eq_ra(eci_to_equatorial_geo(
'(-4400.594,1586.943,4772.175,2.604,7.004,2.126)'::eci_position,
'2024-06-21 12:00:00+00'))
)::numeric, 2) AS parallax_hours;
-- ============================================================
-- Test 16: All planets return valid equatorial coordinates
-- ============================================================
SELECT 'all_planets_eq' AS test,
body_id,
round(eq_ra(planet_equatorial(body_id, '2024-06-21 12:00:00+00'))::numeric, 2) AS ra_h,
round(eq_dec(planet_equatorial(body_id, '2024-06-21 12:00:00+00'))::numeric, 1) AS dec_deg
FROM generate_series(1, 8) AS body_id
WHERE body_id != 3;
-- ============================================================
-- Test 17: planet_equatorial_apparent (light-time corrected)
-- Jupiter's light-time is ~35-52 min; apparent RA should differ
-- from geometric by a small amount
-- ============================================================
SELECT 'jupiter_apparent' AS test,
round(abs(
eq_ra(planet_equatorial_apparent(5, '2024-06-21 12:00:00+00'))
- eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))
)::numeric, 4) AS ra_diff_hours;
-- ============================================================
-- Test 18: moon_equatorial_apparent (light-time ~1.3 sec)
-- Difference from geometric should be tiny
-- ============================================================
SELECT 'moon_apparent' AS test,
round(abs(
eq_ra(moon_equatorial_apparent('2024-06-21 12:00:00+00'))
- eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))
)::numeric, 6) AS ra_diff_hours;
-- ============================================================
-- Test 19: Small body equatorial with Ceres
-- Ceres orbital elements (approximate)
-- ============================================================
SELECT 'ceres_eq' AS test,
eq_ra(small_body_equatorial(
'(2460400.5,2.5577,0.0785,0.1849,1.2836,1.4013,2460500.0,3.53,0.12)'::orbital_elements,
'2024-06-21 12:00:00+00')) BETWEEN 0 AND 24 AS ra_valid;
-- ============================================================
-- Test 20: DE equatorial variants (fall back to VSOP87)
-- Without DE configured, these should produce same results as VSOP87
-- ============================================================
SELECT 'de_planet_eq' AS test,
round(eq_ra(planet_equatorial_de(5, '2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h_vsop,
round(eq_ra(planet_equatorial_de(5, '2024-06-21 12:00:00+00'))::numeric, 4) =
round(eq_ra(planet_equatorial(5, '2024-06-21 12:00:00+00'))::numeric, 4) AS match;
SELECT 'de_moon_eq' AS test,
round(eq_ra(moon_equatorial_de('2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h,
round(eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))::numeric, 4) AS ra_h_elp,
round(eq_ra(moon_equatorial_de('2024-06-21 12:00:00+00'))::numeric, 4) =
round(eq_ra(moon_equatorial('2024-06-21 12:00:00+00'))::numeric, 4) AS match;

View File

@ -39,9 +39,10 @@ FROM test_orbits a, test_orbits b
WHERE a.id < b.id WHERE a.id < b.id
ORDER BY a.name, b.name; ORDER BY a.name, b.name;
-- Altitude distance: ISS <-> Equatorial-LEO should be ~0 (same altitude shell) -- Orbital distance: 2-D metric (altitude + inclination in km)
-- ISS <-> Equatorial-LEO should be ~5192 (0 km alt gap + 47° inc diff × 6378 km)
SELECT a.name AS sat_a, b.name AS sat_b, SELECT a.name AS sat_a, b.name AS sat_b,
round((a.tle <-> b.tle)::numeric, 0) AS alt_dist_km round((a.tle <-> b.tle)::numeric, 0) AS orbital_dist_km
FROM test_orbits a, test_orbits b FROM test_orbits a, test_orbits b
WHERE a.id < b.id WHERE a.id < b.id
ORDER BY a.name, b.name; ORDER BY a.name, b.name;
@ -54,7 +55,7 @@ WHERE tle && (SELECT tle FROM test_orbits WHERE name = 'ISS')
ORDER BY name; ORDER BY name;
RESET enable_seqscan; RESET enable_seqscan;
-- Nearest-neighbor via GiST: order by altitude distance to ISS -- Nearest-neighbor via GiST: order by 2-D orbital distance to ISS
SET enable_seqscan = off; SET enable_seqscan = off;
SELECT name, round((tle <-> (SELECT tle FROM test_orbits WHERE name = 'ISS'))::numeric, 0) AS dist SELECT name, round((tle <-> (SELECT tle FROM test_orbits WHERE name = 'ISS'))::numeric, 0) AS dist
FROM test_orbits FROM test_orbits

View File

@ -0,0 +1,208 @@
-- orbital_elements regression tests
--
-- Tests orbital_elements type I/O, accessors, MPC parser,
-- small_body_heliocentric(), and small_body_observe().
CREATE EXTENSION IF NOT EXISTS pg_orrery;
\set boulder '''40.015N 105.270W 1655m'''::observer
-- ============================================================
-- Test 1: Type I/O round-trip
-- Construct orbital_elements from text literal, verify output matches.
-- Angles in degrees, stored as radians internally.
-- ============================================================
SELECT 'io_roundtrip' AS test,
'(2460600.000000,2.5478000000,0.0789126000,10.586640,73.429370,80.268600,2460319.000000,3.33,0.12)'::orbital_elements AS oe;
-- ============================================================
-- Test 2: Accessor functions (direct field access)
-- ============================================================
SELECT 'accessor_epoch' AS test,
round(oe_epoch('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 1) AS epoch_jd;
SELECT 'accessor_q' AS test,
round(oe_perihelion('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 4) AS q_au;
SELECT 'accessor_e' AS test,
round(oe_eccentricity('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 7) AS ecc;
SELECT 'accessor_inc' AS test,
round(oe_inclination('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 5) AS inc_deg;
SELECT 'accessor_omega' AS test,
round(oe_arg_perihelion('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 5) AS omega_deg;
SELECT 'accessor_Omega' AS test,
round(oe_raan('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 4) AS Omega_deg;
SELECT 'accessor_tp' AS test,
round(oe_tp('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 1) AS tp_jd;
SELECT 'accessor_h' AS test,
round(oe_h_mag('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 2) AS h_mag;
SELECT 'accessor_g' AS test,
round(oe_g_slope('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 2) AS g_slope;
-- ============================================================
-- Test 3: Computed accessors
-- ============================================================
-- Semi-major axis: a = q / (1 - e) = 2.5478 / (1 - 0.0789126) ~ 2.766 AU
SELECT 'sma_elliptic' AS test,
round(oe_semi_major_axis('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 3) AS a_au;
-- Period: a^1.5 years. a ~ 2.766, period ~ 4.599 years
SELECT 'period_elliptic' AS test,
round(oe_period_years('(2460600.0,2.5478,0.0789126,10.58664,73.42937,80.2686,2460319.0,3.33,0.12)'::orbital_elements)::numeric, 2) AS period_yr;
-- Hyperbolic orbit (e=1.5): semi-major axis and period should be NULL
SELECT 'sma_hyperbolic' AS test,
oe_semi_major_axis('(2460600.0,1.0,1.5,10.0,73.0,80.0,2460319.0,NaN,NaN)'::orbital_elements) IS NULL AS is_null;
SELECT 'period_hyperbolic' AS test,
oe_period_years('(2460600.0,1.0,1.5,10.0,73.0,80.0,2460319.0,NaN,NaN)'::orbital_elements) IS NULL AS is_null;
-- ============================================================
-- Test 4: MPC parser -- (1) Ceres
--
-- MPCORB.DAT line for Ceres (epoch 2024 Oct 17.0 = K24AM):
-- Packed epoch K24AM -> K=2000, 24, A=Oct, M=22 -> 2024-10-22
-- (Actually: K=2000, year=24, month=A=10, day=M=22)
-- JD for 2024-10-22 = 2460605.5
--
-- a = 2.7660961 AU, e = 0.0789126
-- q = a*(1-e) = 2.7660961*(1-0.0789126) = 2.5478...
-- M = 60.07966 deg
-- ============================================================
SELECT 'mpc_ceres_sma' AS test,
round(oe_semi_major_axis(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 4) AS a_au;
SELECT 'mpc_ceres_q' AS test,
round(oe_perihelion(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 4) AS q_au;
SELECT 'mpc_ceres_ecc' AS test,
round(oe_eccentricity(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 7) AS ecc;
SELECT 'mpc_ceres_inc' AS test,
round(oe_inclination(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 5) AS inc_deg;
SELECT 'mpc_ceres_h' AS test,
round(oe_h_mag(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 2) AS h_mag;
-- ============================================================
-- Test 5: small_body_heliocentric -- compare to kepler_propagate
-- Both should produce the same heliocentric position for Ceres.
-- ============================================================
SELECT 'helio_vs_kepler' AS test,
round(helio_x(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 6) AS x_au,
round(helio_y(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 6) AS y_au,
round(helio_z(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 6) AS z_au;
-- Verify heliocentric distance is reasonable (Ceres orbits ~2.5-3.0 AU)
SELECT 'helio_distance' AS test,
round(helio_distance(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
))::numeric, 2) AS dist_au,
helio_distance(small_body_heliocentric(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
'2025-01-01 00:00:00+00'
)) BETWEEN 2.5 AND 3.0 AS in_range;
-- ============================================================
-- Test 6: small_body_observe -- topocentric observation
-- Verify we get reasonable az/el/range for Ceres from Boulder.
-- Range should be on the order of 1.5-4.5 AU in km.
-- ============================================================
SELECT 'observe_range' AS test,
topo_range(small_body_observe(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
:boulder, '2025-01-01 00:00:00+00'
)) BETWEEN 1.5 * 149597870.7 AND 4.5 * 149597870.7 AS range_reasonable;
-- Elevation should be a finite number
SELECT 'observe_elevation' AS test,
topo_elevation(small_body_observe(
oe_from_mpc('00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'),
:boulder, '2025-01-01 00:00:00+00'
)) BETWEEN -90 AND 90 AS el_reasonable;
-- ============================================================
-- Test 7: Parabolic/hyperbolic elements via text I/O
-- Verify type handles e >= 1 without error.
-- ============================================================
SELECT 'parabolic_io' AS test,
oe_eccentricity('(2460600.0,1.0,1.0,45.0,90.0,180.0,2460500.0,12.0,0.15)'::orbital_elements) AS ecc;
SELECT 'hyperbolic_io' AS test,
oe_eccentricity('(2460600.0,0.5,2.5,30.0,60.0,120.0,2460400.0,8.0,0.04)'::orbital_elements) AS ecc;
-- ============================================================
-- Test 8: Error paths
-- ============================================================
-- Invalid text input (wrong number of fields)
SELECT 'bad_text' AS test, '(1.0,2.0,3.0)'::orbital_elements;
-- Negative perihelion distance
SELECT 'negative_q' AS test, '(2460600.0,-1.0,0.5,10.0,73.0,80.0,2460319.0,3.33,0.12)'::orbital_elements;
-- Negative eccentricity
SELECT 'negative_e' AS test, '(2460600.0,1.0,-0.1,10.0,73.0,80.0,2460319.0,3.33,0.12)'::orbital_elements;
-- MPC line too short
SELECT 'mpc_short' AS test, oe_from_mpc('too short');
-- ============================================================
-- Test 9: MPC packed date decoding
-- Verify K24AM -> 2024-10-22 -> JD 2460605.5
-- ============================================================
SELECT 'mpc_epoch' AS test,
round(oe_epoch(oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
))::numeric, 1) AS epoch_jd;
-- ============================================================
-- Test 10: Consistency -- small_body_observe vs comet_observe
-- Both pipelines should produce the same topocentric result
-- when fed the same elements and Earth position.
-- ============================================================
WITH ceres AS (
SELECT oe_from_mpc(
'00001 3.33 0.12 K24AM 60.07966 73.42937 80.26860 10.58664 0.0789126 0.21406048 2.7660961 0 MPO838504 8738 115 1801-2024 0.65 M-v 30k MPCLINUX 0000 (1) Ceres 20240825'
) AS oe
), earth AS (
SELECT planet_heliocentric(3, '2025-06-15 12:00:00+00') AS pos
)
SELECT 'pipeline_match' AS test,
round(abs(
topo_elevation(small_body_observe(c.oe, :boulder, '2025-06-15 12:00:00+00'))
- topo_elevation(comet_observe(
oe_perihelion(c.oe), oe_eccentricity(c.oe),
oe_inclination(c.oe), oe_arg_perihelion(c.oe), oe_raan(c.oe),
oe_tp(c.oe),
helio_x(e.pos), helio_y(e.pos), helio_z(e.pos),
:boulder, '2025-06-15 12:00:00+00'
))
)::numeric, 6) AS el_diff_deg
FROM ceres c, earth e;

139
test/sql/refraction.sql Normal file
View File

@ -0,0 +1,139 @@
-- refraction regression tests
--
-- Tests atmospheric refraction (Bennett 1982), pressure/temperature
-- correction, apparent elevation, and refracted pass prediction.
\set boulder '''40.015N 105.270W 1655m'''::observer
-- ISS TLE for pass prediction tests (inline in CTEs below)
-- ============================================================
-- Test 1: Refraction at horizon (0 deg) ~ 0.57 deg
-- Bennett: R = 1/tan(0 + 7.31/4.4) arcmin ~ 34.5 arcmin ~ 0.575 deg
-- ============================================================
SELECT 'refr_horizon' AS test,
round(atmospheric_refraction(0.0)::numeric, 2) AS refr_deg;
-- ============================================================
-- Test 2: Refraction at 30 deg ~ 0.03 deg
-- ============================================================
SELECT 'refr_30deg' AS test,
round(atmospheric_refraction(30.0)::numeric, 3) AS refr_deg;
-- ============================================================
-- Test 3: Refraction at zenith (90 deg) ~ 0 deg
-- ============================================================
SELECT 'refr_zenith' AS test,
round(atmospheric_refraction(90.0)::numeric, 4) AS refr_deg;
-- ============================================================
-- Test 4: Refraction at 10 deg ~ 0.09 deg
-- ============================================================
SELECT 'refr_10deg' AS test,
round(atmospheric_refraction(10.0)::numeric, 3) AS refr_deg;
-- ============================================================
-- Test 5: Domain guard - refraction at -5 deg should return 0
-- (below -1 deg validity range)
-- ============================================================
SELECT 'refr_below_range' AS test,
atmospheric_refraction(-5.0) AS refr_deg;
-- ============================================================
-- Test 6: Domain guard - refraction at -10 deg returns 0 (no NaN)
-- ============================================================
SELECT 'refr_deep_neg' AS test,
atmospheric_refraction(-10.0) AS refr_deg,
atmospheric_refraction(-10.0) = atmospheric_refraction(-10.0) AS is_finite;
-- ============================================================
-- Test 7: Refraction at exactly -1 deg (edge of domain)
-- Should return a small positive value
-- ============================================================
SELECT 'refr_minus1' AS test,
atmospheric_refraction(-1.0) > 0 AS positive;
-- ============================================================
-- Test 8: Extended refraction with P/T correction
-- Standard: P=1010, T=10 should match basic function
-- ============================================================
SELECT 'refr_ext_standard' AS test,
round(atmospheric_refraction_ext(0.0, 1010.0, 10.0)::numeric, 2) AS refr_deg,
round(atmospheric_refraction(0.0)::numeric, 2) AS refr_basic,
round(atmospheric_refraction_ext(0.0, 1010.0, 10.0)::numeric, 4) =
round(atmospheric_refraction(0.0)::numeric, 4) AS match;
-- ============================================================
-- Test 9: Extended refraction - cold high-altitude
-- At P=700 mbar, T=-20 C, refraction should be reduced
-- ============================================================
SELECT 'refr_ext_cold' AS test,
round(atmospheric_refraction_ext(0.0, 700.0, -20.0)::numeric, 2) AS refr_deg,
atmospheric_refraction_ext(0.0, 700.0, -20.0) <
atmospheric_refraction(0.0) AS less_than_standard;
-- ============================================================
-- Test 10: Extended refraction - hot sea level
-- At P=1013, T=35 C, refraction should be slightly different
-- ============================================================
SELECT 'refr_ext_hot' AS test,
round(atmospheric_refraction_ext(0.0, 1013.0, 35.0)::numeric, 2) AS refr_deg;
-- ============================================================
-- Test 11: Apparent elevation for a topocentric observation
-- Sun near horizon: geometric el small -> apparent el higher
-- ============================================================
SELECT 'apparent_el' AS test,
round(topo_elevation(sun_observe(:boulder, '2024-03-20 00:30:00+00'))::numeric, 1) AS geometric,
round(topo_elevation_apparent(sun_observe(:boulder, '2024-03-20 00:30:00+00'))::numeric, 1) AS apparent,
topo_elevation_apparent(sun_observe(:boulder, '2024-03-20 00:30:00+00')) >
topo_elevation(sun_observe(:boulder, '2024-03-20 00:30:00+00')) AS refraction_positive;
-- ============================================================
-- Test 12: Apparent elevation for star high up (refraction small)
-- Polaris from Boulder has el ~49 deg; refraction ~0.01 deg
-- ============================================================
SELECT 'apparent_el_high' AS test,
round(topo_elevation_apparent(
star_observe(2.530303, 89.2641, :boulder, '2024-06-15 04:00:00+00'))::numeric, 1) AS apparent_deg;
-- ============================================================
-- Test 13: Refracted pass prediction returns results
-- Using the ISS TLE, should find passes in a week 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 'refracted_passes' AS test,
count(*) > 0 AS has_passes
FROM iss, predict_passes_refracted(
t, :boulder,
'2024-01-02 00:00:00+00', '2024-01-09 00:00:00+00');
-- ============================================================
-- Test 14: Refracted passes find at least as many as standard
-- Because refracted horizon is -0.569 deg, satellites visible ~35s earlier
-- ============================================================
SELECT 'refracted_more_passes' AS test,
(SELECT count(*) FROM predict_passes_refracted(
'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,
:boulder,
'2024-01-02 00:00:00+00', '2024-01-09 00:00:00+00'))
>=
(SELECT count(*) FROM predict_passes(
'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,
:boulder,
'2024-01-02 00:00:00+00', '2024-01-09 00:00:00+00'))
AS refracted_ge_standard;
-- ============================================================
-- Test 15: Monotonicity - refraction decreases with elevation
-- ============================================================
SELECT 'refr_monotonic' AS test,
atmospheric_refraction(0.0) > atmospheric_refraction(10.0) AS r0_gt_r10,
atmospheric_refraction(10.0) > atmospheric_refraction(30.0) AS r10_gt_r30,
atmospheric_refraction(30.0) > atmospheric_refraction(60.0) AS r30_gt_r60,
atmospheric_refraction(60.0) > atmospheric_refraction(90.0) AS r60_gt_r90;