pg_orrery/docs/DESIGN.md
Ryan Malloy 15a830dc40 Initial implementation of pg_orbit PostgreSQL extension
6 custom types (tle, eci_position, geodetic, topocentric, observer,
pass_event), 67 SQL functions, 2 operators (&&, <->), and a GiST
operator class for altitude-band indexing. Wraps Bill Gray's sat_code
for SGP4/SDP4 propagation with WGS-72 constants for propagation and
WGS-84 for coordinate output. All 5 regression tests pass on PG 18.
2026-02-15 17:07:07 -07:00

591 lines
26 KiB
Markdown

# pg_orbit Design Document
Internal architecture notes. Documents WHY decisions were made,
not how to use the extension. Intended audience: future maintainers
who need to modify pg_orbit without breaking physical correctness.
## 1. Constant Chain of Custody
This is the single most critical design constraint in the extension.
Get it wrong and positions silently drift by kilometers. There is no
runtime check that can detect this class of error after the fact.
### The Problem
Two-Line Elements are not raw orbital measurements. They are *mean*
elements produced by a differential correction process that fits
observed positions against an SGP4 propagator running with a specific
set of geopotential constants (WGS-72). The mean elements absorb
geodetic model biases -- the eccentricity, inclination, and mean
motion encode corrections that only make physical sense when propagated
with the same constants used to generate them.
Substituting WGS-84 constants (or any other set) into the propagator
does not "upgrade" accuracy. It breaks the internal consistency of the
element set. The resulting position error can exceed the natural
prediction error of the TLE by an order of magnitude.
### The Rules
1. **SGP4/SDP4 propagation**: WGS-72 constants only (mu, ae, J2, J3, J4, ke).
These flow through sat_code's `norad_in.h` defines and are never
overridden.
2. **Coordinate output** (geodetic lat/lon/alt, topocentric az/el/range):
WGS-84 ellipsoid (a = 6378.137 km, f = 1/298.257223563). This is
the modern standard for ground-station positioning and GPS receivers.
3. **TEME frame**: The SGP4 output frame (True Equator, Mean Equinox)
uses only 4 of the 106 IAU-80 nutation terms. Applying the full
nutation model would "correct" for effects that SGP4 already accounts
for internally, introducing error rather than removing it.
4. **No other combination is valid.** WGS-72 for propagation, WGS-84 for
output. Perigee and apogee altitudes from `tle_perigee()` and
`tle_apogee()` use WGS-72 AE because they derive from mean elements.
Geodetic altitude from `eci_to_geodetic()` uses WGS-84 because it
converts a physical position.
### Constant Inventory
| Constant | Source Paper | Value | pg_orbit Location | sat_code Location |
|----------|-------------|-------|-------------------|-------------------|
| ae (equatorial radius) | Hoots & Roehrich STR#3 | 6378.135 km | `types.h:20` (WGS72_AE) | `norad_in.h:64` (earth_radius_in_km) |
| J2 | Hoots & Roehrich STR#3 | 0.001082616 | `types.h:21` (WGS72_J2) | `norad_in.h:69` (xj2) |
| J3 | Hoots & Roehrich STR#3 | -2.53881e-6 | `types.h:22` (WGS72_J3) | `norad_in.h:63` (xj3) |
| J4 | Hoots & Roehrich STR#3 | -1.65597e-6 | `types.h:23` (WGS72_J4) | `norad_in.h:79` (xj4) |
| ke | Hoots & Roehrich STR#3 | 0.0743669161331734132 min^-1 | `types.h:24` (WGS72_KE) | `norad_in.h:83` (xke) |
| mu | Hoots & Roehrich STR#3 | 398600.8 km^3/s^2 | `types.h:19` (WGS72_MU) | (implicit in xke) |
| WGS-84 a | NIMA TR8350.2 | 6378.137 km | `types.h:31` (WGS84_A) | -- |
| WGS-84 f | NIMA TR8350.2 | 1/298.257223563 | `types.h:32` (WGS84_F) | -- |
Note that `types.h` carries a parallel copy of the WGS-72 constants
even though sat_code defines them in `norad_in.h`. This is intentional:
`types.h` is the single header for all pg_orbit C sources, and
`norad_in.h` is an internal sat_code header not meant for external
consumers. The GiST index (`gist_tle.c`) and TLE accessor functions
(`tle_type.c`) need KE and AE without pulling in sat_code internals.
The values MUST be identical.
### Why Two Copies of AE?
`tle_perigee()`, `tle_apogee()`, and the GiST altitude-band computation
all use `WGS72_KE` and `WGS72_AE` from `types.h`. They compute:
a_er = (KE / n)^(2/3) [earth radii]
perigee_km = a_er * (1 - e) * AE - AE
This MUST use WGS-72 AE (6378.135), not WGS-84 (6378.137), because `n`
is a mean motion fitted against the WGS-72 geopotential. Using the
wrong radius shifts every altitude by 2 meters -- small in absolute terms
but wrong in principle, and the error compounds in index operations.
## 2. SGP4 Implementation Choice
pg_orbit wraps Bill Gray's `sat_code` library (MIT license, Project Pluto).
### Why sat_code over alternatives
**Vallado's reference implementation** (from the STR#3 revision paper) is
the canonical source but has two problems: it is written in C++ with heavy
use of global state, and its license is unclear for embedding in a
PostgreSQL extension.
**libsgp4** (various forks on GitHub) is typically a C++ class hierarchy
that assumes an object-per-satellite lifecycle. This conflicts with
PostgreSQL's per-call execution model where we cannot persist C++ objects
across function invocations.
**sat_code** was chosen because:
1. **Pure C linkage.** All public functions are declared `extern "C"` in
`norad.h` (lines 97-133). The library is compiled as C++ but exposes
a flat C function interface: `SGP4_init()`, `SGP4()`, `SDP4_init()`,
`SDP4()`, `parse_elements()`, `select_ephemeris()`.
2. **No global mutable state.** The propagator state lives in a caller-
allocated `double params[N_SAT_PARAMS]` array. This maps directly to
PostgreSQL's `palloc`-based memory model: allocate before propagation,
free after.
3. **Includes deep-space SDP4.** Many SGP4 implementations only handle
near-earth orbits (period < 225 minutes). sat_code includes the full
SDP4 with lunar/solar perturbations via `deep.cpp`, handling GEO,
Molniya, and GPS orbits.
4. **MIT license.** Compatible with the PostgreSQL License for embedding
in a shared library.
5. **Actively maintained.** Used in Bill Gray's Find_Orb production
astrometry software. Bug fixes reach us through the git submodule.
### Build Integration
The Makefile compiles sat_code's `.cpp` files with `g++` and links the
resulting `.o` files into the PostgreSQL shared library alongside our C
sources. The `-lstdc++` link flag pulls in the C++ runtime. This is the
same pattern used by PostGIS for GEOS integration (C extension linking
C++ library objects).
```
src/*.c --> gcc --> .o --|
lib/sat_code/*.cpp -> g++ -> .o --|--> pg_orbit.so
-lstdc++ -lm
```
The `-I$(SAT_CODE_DIR)` flag lets our C files `#include "norad.h"`
directly.
## 3. Type System Design
### Design Principles
Every pg_orbit type is fixed-size, not varlena. This means:
- No TOAST overhead (no detoasting on access)
- Direct pointer access via `PG_GETARG_POINTER(n)` -- no copy
- Predictable memory layout for binary I/O (`tle_recv`/`tle_send`)
- All types use `ALIGNMENT = double` to satisfy the strictest member
alignment requirement (all structs contain `double` fields)
- `STORAGE = plain` -- the type is never compressed or moved to TOAST
### TLE Type (112 bytes)
The TLE type stores **parsed mean elements**, not raw text.
This is the most important type design decision. Alternatives considered:
1. **Store raw 69+69 character lines, parse on every propagation.**
Rejected. Parsing is ~10x slower than propagation itself for a
pre-initialized model. Every `sgp4_propagate()` call would pay
the parse cost.
2. **Store raw text plus parsed cache.**
Rejected. Doubles the storage for no benefit. The parsed form
round-trips perfectly through `write_elements_in_tle_format()`.
3. **Store parsed mean elements only.**
Chosen. Input validation happens once at `tle_in()` time via
sat_code's `parse_elements()`. The text representation is
reconstructed on output via `write_elements_in_tle_format()`.
Storage layout:
```
Offset Size Field
0 88 11 doubles: epoch, inclination, raan, eccentricity,
arg_perigee, mean_anomaly, mean_motion,
mean_motion_dot, mean_motion_ddot, bstar
(one unused slot in the double array for alignment)
80 12 3 int32: norad_id, elset_num, rev_num
92 12 classification (1), ephemeris_type (1),
intl_desig (9), pad (1)
----
112 bytes total
```
The SQL definition declares `INTERNALLENGTH = 112`. This is smaller
than the raw two-line text (138+ bytes with line separator) and avoids
the 4-byte varlena header overhead.
Angular elements are stored in radians (matching sat_code's internal
representation). Accessor functions convert to degrees for human
consumption. Mean motion is stored in radians/minute; the accessor
returns revolutions/day.
### ECI Position Type (48 bytes)
Six doubles: x, y, z (km), vx, vy, vz (km/s).
SGP4 outputs velocity in km/min. We convert to km/s at the boundary
(`sgp4_funcs.c`, lines 181-183: `vel[i] / 60.0`). This conversion
happens exactly once, at the point where the pg_eci struct is populated.
Internally, all velocity in pg_orbit is km/s.
### Geodetic Type (24 bytes)
Three doubles: lat, lon (radians), alt (km above WGS-84).
Radians internally, degrees in text representation. The accessor
functions perform the conversion.
### Observer Type (24 bytes)
Three doubles: lat, lon (radians), alt_m (meters above WGS-84).
Note the asymmetry with geodetic: observer altitude is in meters
(matching GPS receiver output and ground station databases), while
geodetic altitude is in km (matching orbital altitude conventions).
This prevents unit confusion at the API boundary -- you set up a
ground station in meters, you get satellite altitude in kilometers.
### Topocentric Type (32 bytes)
Four doubles: azimuth, elevation (radians), range_km, range_rate (km/s).
### Pass Event Type (48 bytes)
Three int64 (TimestampTz): aos_time, max_el_time, los_time.
Three doubles: max_elevation (degrees), aos_azimuth (degrees),
los_azimuth (degrees).
Times are stored as native PostgreSQL TimestampTz values (microseconds
since 2000-01-01 00:00:00 UTC). This allows direct comparison with
SQL timestamp expressions without conversion.
## 4. TEME to ECEF Transform
SGP4 outputs position and velocity in the TEME (True Equator, Mean
Equinox) frame. Converting to Earth-fixed coordinates for geodetic
and topocentric output requires a frame rotation.
### The Rotation
TEME to ECEF is a single Z-axis rotation by the negative of the
Greenwich Mean Sidereal Time (GMST) angle:
```
[x_ecef] [ cos(g) sin(g) 0 ] [x_teme]
[y_ecef] = [-sin(g) cos(g) 0 ] [y_teme]
[z_ecef] [ 0 0 1 ] [z_teme]
```
This is implemented in `coord_funcs.c:teme_to_ecef()` and
`pass_funcs.c:teme_to_ecef()` (duplicated -- see note below).
### GMST Computation
GMST is computed from the Julian date using the IAU 1982 formula,
which is the same low-precision model that SGP4 uses internally.
From Vallado (2013) Eq. 3-47:
```c
T_UT1 = (JD - 2451545.0) / 36525.0
GMST = 67310.54841
+ (876600*3600 + 8640184.812866) * T_UT1
+ 0.093104 * T_UT1^2
- 6.2e-6 * T_UT1^3
```
The result is in seconds of time, converted to radians by multiplying
by pi/43200 and normalized to [0, 2*pi).
We deliberately do NOT use a higher-precision GMST model (e.g., IAU 2000A).
The SGP4 output is only accurate to the precision of its own GMST model;
applying a more precise rotation would not improve the final position and
could introduce a systematic offset.
### Velocity Transform
The velocity transform includes the Earth angular velocity cross product
term, accounting for the rotating reference frame:
```
v_ecef = R(-g) * v_teme + omega_E x r_ecef
```
where omega_E = 7.2921158553e-5 rad/s. In `pass_funcs.c`, the velocity
cross product uses omega_E in rad/min (multiplied by 60.0) because
sat_code outputs velocity in km/min.
### ECEF to Geodetic
The ECEF-to-geodetic conversion uses Bowring's iterative method, which
converges in 2-3 iterations for LEO altitudes:
```
phi_0 = atan2(z, p * (1 - e^2)) [initial estimate]
N = a / sqrt(1 - e^2 * sin^2(phi)) [prime vertical radius]
phi = atan2(z + e^2 * N * sin(phi), p) [iterate]
```
Convergence criterion: 1.0e-12 radians (sub-millimeter at Earth's surface).
Maximum 10 iterations as a safety bound, though LEO cases converge in 2-3.
We chose Bowring's method over Vermeille's or Heikkinen's closed-form
solutions because it is simpler, well-tested, and the iteration cost is
negligible compared to the SGP4 propagation that precedes it.
### Why Duplicated Helpers
`coord_funcs.c` and `pass_funcs.c` each contain their own static copies
of `pg_tle_to_sat_code()`, `gmst_from_jd()`, `teme_to_ecef()`,
`observer_to_ecef()`, and `ecef_to_topocentric()`. All `.c` files link
into a single `.so`, so we could share these through a common object file.
We chose duplication instead because:
1. Each translation unit is self-contained. No hidden coupling between
files through shared internal functions.
2. The functions are small (5-20 lines each). The binary size increase
is negligible.
3. The compiler can inline them within each translation unit.
4. If the helpers ever need to diverge (e.g., pass_funcs using km/min
velocity while coord_funcs uses km/s), they can do so without
affecting each other.
## 5. Pass Prediction Algorithm
### The Core Problem
Given a TLE and a ground observer, find all time intervals where the
satellite is above the observer's local horizon. SGP4 has no closed-form
inverse for the elevation function -- you cannot algebraically solve
"at what time does elevation = 0?" You have to evaluate the function
at many points and search for zero crossings.
This is the same fundamental approach used by Skyfield's `find_events()`
and Gpredict's pass finder.
### Algorithm
1. **Coarse scan**: Step through the search window at 30-second intervals,
evaluating elevation at each step. A 30-second step is fine enough
for LEO (~90 min period, ~10 min pass duration) that no pass shorter
than about 1 minute gets skipped, and coarse enough that a 7-day
window requires ~20,000 propagation calls (fast on modern hardware).
2. **AOS detection**: When elevation transitions from negative to
positive between consecutive coarse steps, a rising edge has been
found. Bisect the interval to locate AOS to within 0.1 second
tolerance (BISECT_TOL_JD = 0.1/86400).
3. **LOS detection**: Continue the coarse scan from AOS until elevation
goes negative again. Bisect to refine LOS to the same 0.1 second
tolerance.
4. **Peak elevation**: Between AOS and LOS, the elevation function is
unimodal (one peak). Use ternary search (50 iterations) to locate
the maximum. Ternary search is chosen over golden section because
the convergence rate is adequate and the implementation is simpler.
5. **Degenerate pass filter**: Passes shorter than 10 seconds
(MIN_PASS_DURATION_JD) are discarded. These are typically numerical
noise from orbits just barely grazing the horizon.
6. **Minimum elevation filter**: Passes whose peak elevation falls below
the caller-specified threshold are silently skipped. The scan resumes
from their LOS.
7. **Post-LOS gap**: After finding a complete pass, the scan resumes
1 minute past LOS (POST_LOS_GAP_JD) to avoid re-detecting the
same descending arc.
### Error Handling in the Scan
If SGP4 returns a hard error (negative semi-major axis, nearly parabolic,
convergence failure) during the elevation scan, `elevation_at_jd()`
returns -pi radians. This is well below any real horizon elevation, so
the scan treats the point as "below horizon" and continues. The pass
finder does NOT abort the query on propagation errors during scanning --
a TLE might be valid for part of the search window and decayed for
the rest.
### SRF (Set-Returning Function) Pattern
`predict_passes()` uses PostgreSQL's ValuePerCall SRF protocol:
- First call: allocate a `predict_passes_ctx` struct in
`funcctx->multi_call_memory_ctx`, copy the TLE and observer into it,
initialize the scan position.
- Each subsequent call: call `find_next_pass()` starting from the
current scan position. If a pass is found, advance `current_jd`
past LOS and return the pass. If not, return DONE.
The TLE and observer are copied into the multi_call context because
the original function arguments may be freed between calls.
## 6. GiST Index Architecture
### The Indexing Problem
The natural query pattern for conjunction screening is: "which TLEs
have orbits that could intersect with this TLE?" Full conjunction
analysis requires propagating both objects and computing distance at
every timestep -- far too expensive for an index scan.
### Altitude-Band Approximation
Every orbit has a perigee altitude and an apogee altitude. Two orbits
can only come close to each other if their altitude ranges overlap.
This is a **necessary but not sufficient** condition for conjunction.
The GiST index stores [perigee_km, apogee_km] as its internal key
(`tle_alt_range` struct: two doubles, 16 bytes). This is derived from
the mean elements via Kepler's third law:
```
a = (KE / n)^(2/3) [earth radii, WGS-72]
perigee_km = a * (1 - e) * AE - AE
apogee_km = a * (1 + e) * AE - AE
```
The altitude band uses WGS-72 constants because n and e are WGS-72
mean elements.
### GiST Support Functions
| Function | Strategy | Description |
|----------|----------|-------------|
| compress | -- | Leaf: extract [perigee, apogee] from pg_tle. Internal: pass through (already a range). |
| decompress | -- | Identity. We operate on compressed keys directly. |
| consistent | `&&` (3), `@>` (7), `<@` (8) | Does the subtree's bounding range overlap/contain the query range? |
| union | -- | [min(low), max(high)] over all entries in a node. |
| penalty | -- | Increase in altitude span (km) from expanding the node's range to include the new entry. |
| picksplit | -- | Sort entries by altitude midpoint, split at the median. |
| same | -- | Endpoint comparison within 1e-9 km tolerance. |
| distance | `<->` (15) | Minimum gap between altitude bands (0 if overlapping). Drives KNN queries. |
### Always Recheck
`gist_tle_consistent()` always sets `*recheck = true`. Altitude band
overlap eliminates the vast majority of non-candidates (a LEO satellite
cannot conjunct with a GEO satellite), but it cannot confirm conjunction.
Two objects at the same altitude but on opposite sides of the earth are
not close. After the index scan filters candidates, the query must
propagate both TLEs to the reference time and compute actual distance.
### Picksplit Strategy
The picksplit function sorts entries by the midpoint of their altitude
band ((perigee + apogee) / 2) and splits at the median. This is optimal
for a 1-D range index because it minimizes overlap between the two
resulting subtrees. Orbits at similar altitudes end up in the same
subtree, which matches the access pattern of conjunction screening
(you are usually querying within a narrow altitude band).
## 7. Memory Management
### Allocation Strategy
All heap allocation goes through `palloc()` / `pfree()`. No `malloc()`,
no `new`, no static buffers.
- **Single-shot propagation** (`sgp4_propagate`, `tle_distance`):
`palloc(sizeof(double) * N_SAT_PARAMS)` for the params array,
propagate, `pfree(params)`. The params array lives in the current
memory context and is freed before the function returns.
- **Set-returning functions** (`sgp4_propagate_series`, `ground_track`,
`predict_passes`): The SRF context struct (containing `tle_t`,
`params[N_SAT_PARAMS]`, and scan state) is allocated in
`funcctx->multi_call_memory_ctx`. This context survives across calls
and is freed automatically when the SRF completes.
- **Type I/O functions** (`tle_in`, `eci_in`, etc.): The result struct
is allocated with `palloc()` in the current context. PostgreSQL
manages its lifecycle.
### No Global Mutable State
There are no file-scope variables, no static locals that accumulate
state, no caches. Every function computes from its arguments alone.
This is required for `PARALLEL SAFE` (all pg_orbit functions are
declared PARALLEL SAFE) and avoids cross-session contamination in
a multi-backend PostgreSQL server.
sat_code itself has no global mutable state. The propagator state is
entirely in the `params[]` array and the `tle_t` struct, both of which
are caller-provided.
### SRF Context Layout
For `sgp4_propagate_series` and `ground_track`, the params array is
embedded directly in the context struct (not a separate allocation):
```c
typedef struct {
tle_t sat;
double params[N_SAT_PARAMS]; /* ~92 doubles, embedded */
int is_deep;
double epoch_jd;
int64 start_ts;
int64 step_usec;
} propagate_series_ctx;
```
This puts everything in a single allocation, reducing palloc overhead
and keeping the data contiguous in memory for cache locality during
the per-row propagation loop.
## 8. Error Handling
### Error Classification
sat_code returns integer error codes from SGP4() and SDP4():
| Code | Constant | Severity | Meaning | pg_orbit Response |
|------|----------|----------|---------|-------------------|
| 0 | -- | OK | Normal | Return result |
| -1 | SXPX_ERR_NEARLY_PARABOLIC | Fatal | eccentricity >= 1 | `ereport(ERROR)` |
| -2 | SXPX_ERR_NEGATIVE_MAJOR_AXIS | Fatal | Decayed orbit | `ereport(ERROR)` |
| -3 | SXPX_WARN_ORBIT_WITHIN_EARTH | Warning | Entire orbit below surface | `ereport(NOTICE)`, return result |
| -4 | SXPX_WARN_PERIGEE_WITHIN_EARTH | Warning | Perigee below surface | `ereport(NOTICE)`, return result |
| -5 | SXPX_ERR_NEGATIVE_XN | Fatal | Negative mean motion | `ereport(ERROR)` |
| -6 | SXPX_ERR_CONVERGENCE_FAIL | Fatal | Kepler equation diverged | `ereport(ERROR)` |
The distinction between warnings (-3, -4) and errors (-1, -2, -5, -6) is
physical. A satellite with perigee within the earth is plausible during
reentry or shortly after launch -- the state vector is still mathematically
valid. A negative semi-major axis means the orbit has decayed past the
point where the model produces meaningful output.
### Error Handling in Different Contexts
**Direct propagation** (`sgp4_propagate`, `tle_distance`): Fatal errors
abort the query via `ereport(ERROR)`. Warnings emit a `NOTICE` and
return the result.
**Pass prediction** (`elevation_at_jd` in `pass_funcs.c`): Fatal errors
return -pi elevation instead of aborting. The scan treats this as "below
horizon" and continues. A TLE might be valid for the first 3 days of a
7-day search window and then decay -- the pass finder should return the
valid passes, not abort.
**SRF propagation** (`sgp4_propagate_series`): Fatal errors abort the
entire series. There is no meaningful way to "skip" a bad timestep in a
continuous time series.
### Input Validation
TLE parsing errors from `parse_elements()` are caught in `tle_in()` and
reported as `ERRCODE_INVALID_TEXT_REPRESENTATION`. Invalid TLEs never
make it into storage.
`select_ephemeris()` returning -1 (invalid mean motion or eccentricity
range) is caught at propagation time, not at storage time. A TLE with
marginal elements might parse correctly but fail when you try to
initialize the propagator.
## 9. Theory-to-Code Mapping
This table maps key equations from the SGP4 theory papers to their
implementation in pg_orbit and sat_code.
| Theory | Paper | What | Code Location |
|--------|-------|------|---------------|
| Mean element recovery | Brouwer (1959) | Recover original mean motion (xnodp) and semi-major axis (aodp) from input TLE elements, removing secular J2 perturbations | `sat_code/common.cpp:sxpall_common_init()` lines 17-35 |
| Secular perturbations | Lane & Cranford (1969), Hoots & Roehrich STR#3 | Secular rates of mean anomaly, argument of perigee, and RAAN due to J2, J4 | `sat_code/common.cpp:sxpx_common_init()` lines 86-101 |
| Atmospheric drag | Hoots & Roehrich STR#3 | B* formulation of drag, C1/C2/C4 coefficients, perigee-dependent s parameter | `sat_code/common.cpp:sxpx_common_init()` lines 47-84; `sat_code/sgp4.cpp:SGP4_init()` |
| Short-period perturbations | Lane & Cranford (1969), Brouwer (1959) | Oscillatory corrections to radius, argument of latitude, node, and inclination | `sat_code/common.cpp:sxpx_posn_vel()` lines 121-229 |
| Kepler equation | Classical | Newton-Raphson with second-order correction, bounded first step | `sat_code/common.cpp:sxpx_posn_vel()` lines 175-208 |
| Deep-space resonance | Hujsak (1979) | Lunar and solar gravitational perturbations, geopotential resonance for 12-hour and 24-hour orbits | `sat_code/deep.cpp:Deep_dpinit()`, `Deep_dpsec()`, `Deep_dpper()` |
| Near-earth propagation | Hoots & Roehrich STR#3 | SGP4 main loop: secular + short-period + drag terms | `sat_code/sgp4.cpp:SGP4()` |
| Deep-space propagation | Hoots & Roehrich STR#3 | SDP4: SGP4 core + deep-space secular/periodic corrections | `sat_code/sdp4.cpp:SDP4()` |
| Semi-major axis from n | Kepler's third law | a = (KE / n)^(2/3) in earth radii | `src/tle_type.c:tle_perigee()` line 415; `src/gist_tle.c:tle_to_alt_range()` line 76 |
| GMST | Vallado (2013) Eq. 3-47 | Greenwich Mean Sidereal Time from Julian date | `src/coord_funcs.c:gmst_from_jd()` lines 59-73; `src/pass_funcs.c:gmst_from_jd()` lines 129-151 |
| TEME to ECEF | Vallado (2013) | Z-axis rotation by -GMST, velocity cross-product correction | `src/coord_funcs.c:teme_to_ecef()` lines 83-103; `src/pass_funcs.c:teme_to_ecef()` lines 157-179 |
| Geodetic from ECEF | Bowring (1976) | Iterative latitude from ECEF Cartesian coordinates on WGS-84 | `src/coord_funcs.c:ecef_to_geodetic()` lines 109-138 |
| Topocentric transform | Standard SEZ | ECEF range vector rotated to South-East-Zenith, azimuth from north | `src/coord_funcs.c:ecef_to_topocentric()` lines 163-188 |
| Observer to ECEF | Geodesy standard | WGS-84 ellipsoid surface point to Cartesian | `src/coord_funcs.c:observer_to_ecef()` lines 143-156 |
| Range rate | Dot product | Projection of relative velocity onto line-of-sight unit vector | `src/coord_funcs.c:eci_to_topocentric()` line 618 |
| Near/deep selection | Hoots & Roehrich STR#3 | Period threshold: 225 minutes (n < 2*pi/225 rad/min) | `sat_code/norad.h:select_ephemeris()` |