Skip to content

Functions: Orbit Determination

Functions for fitting TLE orbital elements from observations via differential correction (DC). The solver uses equinoctial elements internally to avoid singularities at zero eccentricity and inclination, with LAPACK SVD (dgelss_) for the least-squares solve.

Three observation types are supported: ECI position/velocity, topocentric (az/el/range with optional range rate), and angles-only (RA/Dec). Each has single-observer and multi-observer variants where applicable.

All OD functions share the same 8-column output record.


Every OD function returns a RECORD with these fields:

FieldTypeDescription
fitted_tletleThe fitted TLE. NULL if the solver did not converge.
iterationsint4Number of DC iterations performed.
rms_finalfloat8RMS residual after final iteration. Units depend on the observation type (km for ECI/topocentric, radians for angles-only).
rms_initialfloat8RMS residual before the first iteration. Compare with rms_final to assess improvement.
statustextConvergence status: 'converged', 'max_iterations', or an error description.
condition_numberfloat8Condition number of the normal equations matrix. Values above ~1e10 suggest poorly-constrained geometry.
covariancefloat8[]Formal covariance matrix (H^T H)^{-1} from the final Jacobian, stored as a flat array in row-major order. Length is nstate * nstate.
nstateint4Number of estimated parameters (6 for equinoctial elements, 7 if fit_bstar is true).

Fit a TLE from ECI position/velocity observations. This is the simplest OD mode — the observations are already in the SGP4 propagation frame (TEME), so no observer geometry is involved.

tle_from_eci(
positions eci_position[],
times timestamptz[],
seed tle DEFAULT NULL,
fit_bstar boolean DEFAULT false,
max_iter int4 DEFAULT 15,
weights float8[] DEFAULT NULL,
OUT fitted_tle tle,
OUT iterations int4,
OUT rms_final float8,
OUT rms_initial float8,
OUT status text,
OUT condition_number float8,
OUT covariance float8[],
OUT nstate int4
) RETURNS RECORD
ParameterTypeDefaultDescription
positionseci_position[]Array of ECI position/velocity observations. Requires >= 6 observations.
timestimestamptz[]Observation timestamps. Must be same length as positions.
seedtleNULLInitial TLE estimate. When NULL, the solver uses the Gibbs or Herrick-Gibbs method to bootstrap an initial orbit from three position vectors.
fit_bstarbooleanfalseWhen true, estimates B* drag coefficient as a 7th parameter. Requires a well-distributed observation arc.
max_iterint415Maximum differential correction iterations.
weightsfloat8[]NULLPer-observation weights. NULL means uniform weighting. Length must equal length of positions. Higher weight = more influence on the solution.
-- Round-trip test: propagate a TLE, then fit it back
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 tle
),
obs AS (
SELECT array_agg(sgp4_propagate(iss.tle, t) ORDER BY t) AS positions,
array_agg(t ORDER BY t) AS times
FROM iss,
generate_series(
'2024-01-01 12:00:00+00'::timestamptz,
'2024-01-01 13:30:00+00'::timestamptz,
interval '5 minutes') t
)
SELECT iterations,
round(rms_final::numeric, 6) AS rms_km,
round(rms_initial::numeric, 3) AS rms_init_km,
status,
round(condition_number::numeric, 1) AS cond
FROM obs, tle_from_eci(obs.positions, obs.times);

Fit a TLE from topocentric (azimuth/elevation/range) observations collected by a single ground station.

tle_from_topocentric(
observations topocentric[],
times timestamptz[],
obs observer,
seed tle DEFAULT NULL,
fit_bstar boolean DEFAULT false,
max_iter int4 DEFAULT 15,
fit_range_rate boolean DEFAULT false,
weights float8[] DEFAULT NULL,
OUT fitted_tle tle,
OUT iterations int4,
OUT rms_final float8,
OUT rms_initial float8,
OUT status text,
OUT condition_number float8,
OUT covariance float8[],
OUT nstate int4
) RETURNS RECORD
ParameterTypeDefaultDescription
observationstopocentric[]Array of topocentric observations (az, el, range, range_rate). Requires >= 6 observations.
timestimestamptz[]Observation timestamps.
obsobserverGround station location.
seedtleNULLInitial TLE estimate. Unlike ECI fitting, topocentric fitting typically needs a seed for convergence.
fit_bstarbooleanfalseEstimate B* drag coefficient.
max_iterint415Maximum iterations.
fit_range_ratebooleanfalseWhen true, includes range rate as a 4th residual component per observation. Use when you have Doppler or radar range-rate data.
weightsfloat8[]NULLPer-observation weights. NULL = uniform.
-- Observe a satellite, then fit back from topocentric data
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 tle
),
obs AS (
SELECT array_agg(observe(iss.tle, '40.0N 105.3W 1655m'::observer, t) ORDER BY t)
AS observations,
array_agg(t ORDER BY t) AS times
FROM iss,
generate_series(
'2024-01-01 12:00:00+00'::timestamptz,
'2024-01-01 12:10:00+00'::timestamptz,
interval '30 seconds') t
)
SELECT iterations,
round(rms_final::numeric, 4) AS rms_km,
status
FROM obs,
tle_from_topocentric(
obs.observations, obs.times,
'40.0N 105.3W 1655m'::observer,
seed := iss.tle
);

Fit a TLE from topocentric observations collected by multiple ground stations. The observer_ids array maps each observation to its originating station.

tle_from_topocentric(
observations topocentric[],
times timestamptz[],
observers observer[],
observer_ids int4[],
seed tle DEFAULT NULL,
fit_bstar boolean DEFAULT false,
max_iter int4 DEFAULT 15,
fit_range_rate boolean DEFAULT false,
weights float8[] DEFAULT NULL,
OUT fitted_tle tle,
OUT iterations int4,
OUT rms_final float8,
OUT rms_initial float8,
OUT status text,
OUT condition_number float8,
OUT covariance float8[],
OUT nstate int4
) RETURNS RECORD
ParameterTypeDefaultDescription
observationstopocentric[]All observations from all stations, interleaved in time order.
timestimestamptz[]Observation timestamps.
observersobserver[]Array of ground station locations (1-indexed).
observer_idsint4[]Per-observation index into observers[]. observer_ids[i] identifies which station produced observations[i].
seedtleNULLInitial TLE estimate.
fit_bstarbooleanfalseEstimate B* drag coefficient.
max_iterint415Maximum iterations.
fit_range_ratebooleanfalseInclude range rate in residuals.
weightsfloat8[]NULLPer-observation weights. Useful when stations have different measurement accuracies.
-- Two ground stations observe the same satellite
SELECT iterations, round(rms_final::numeric, 4) AS rms_km, status
FROM tle_from_topocentric(
observations := ARRAY[obs1_t1, obs1_t2, obs2_t1, obs2_t2]::topocentric[],
times := ARRAY['2024-01-01 12:00+00', '2024-01-01 12:05+00',
'2024-01-01 12:02+00', '2024-01-01 12:07+00']::timestamptz[],
observers := ARRAY['40.0N 105.3W 1655m', '34.1N 118.3W 100m']::observer[],
observer_ids := ARRAY[1, 1, 2, 2],
seed := seed_tle
);

Fit a TLE from angles-only (RA/Dec) observations collected by a single ground station. When no seed TLE is provided, the Gauss method derives an initial orbit from three observations — making this function fully seed-free.

tle_from_angles(
ra_hours float8[],
dec_degrees float8[],
times timestamptz[],
obs observer,
seed tle DEFAULT NULL,
fit_bstar boolean DEFAULT false,
max_iter int4 DEFAULT 15,
weights float8[] DEFAULT NULL,
OUT fitted_tle tle,
OUT iterations int4,
OUT rms_final float8,
OUT rms_initial float8,
OUT status text,
OUT condition_number float8,
OUT covariance float8[],
OUT nstate int4
) RETURNS RECORD
ParameterTypeDefaultDescription
ra_hoursfloat8[]Right ascension values in hours, range [0, 24). Matches the star_observe() convention.
dec_degreesfloat8[]Declination values in degrees, range [-90, 90].
timestimestamptz[]Observation timestamps. Requires >= 3 observations.
obsobserverGround station location.
seedtleNULLInitial TLE estimate. When NULL, the Gauss method bootstraps an initial orbit from 3 observations.
fit_bstarbooleanfalseEstimate B* drag coefficient.
max_iterint415Maximum iterations.
weightsfloat8[]NULLPer-observation weights.
-- Angles-only OD: RA/Dec observations of a satellite
SELECT iterations,
round(rms_final::numeric, 6) AS rms_rad,
status,
round(condition_number::numeric, 1) AS cond
FROM tle_from_angles(
ra_hours := ARRAY[12.345, 12.567, 12.789, 13.012, 13.234, 13.456],
dec_degrees := ARRAY[45.1, 44.8, 44.3, 43.6, 42.8, 41.9],
times := ARRAY[
'2024-01-01 12:00+00', '2024-01-01 12:01+00',
'2024-01-01 12:02+00', '2024-01-01 12:03+00',
'2024-01-01 12:04+00', '2024-01-01 12:05+00'
]::timestamptz[],
obs := '40.0N 105.3W 1655m'::observer
);

Fit a TLE from angles-only (RA/Dec) observations collected by multiple ground stations. Uses the same Gauss IOD bootstrap as the single-observer variant when no seed is provided.

tle_from_angles(
ra_hours float8[],
dec_degrees float8[],
times timestamptz[],
observers observer[],
observer_ids int4[],
seed tle DEFAULT NULL,
fit_bstar boolean DEFAULT false,
max_iter int4 DEFAULT 15,
weights float8[] DEFAULT NULL,
OUT fitted_tle tle,
OUT iterations int4,
OUT rms_final float8,
OUT rms_initial float8,
OUT status text,
OUT condition_number float8,
OUT covariance float8[],
OUT nstate int4
) RETURNS RECORD
ParameterTypeDefaultDescription
ra_hoursfloat8[]Right ascension values in hours [0, 24).
dec_degreesfloat8[]Declination values in degrees [-90, 90].
timestimestamptz[]Observation timestamps from all stations, interleaved in time order.
observersobserver[]Array of ground station locations (1-indexed).
observer_idsint4[]Per-observation index into observers[].
seedtleNULLInitial TLE estimate. NULL = Gauss IOD bootstrap.
fit_bstarbooleanfalseEstimate B* drag coefficient.
max_iterint415Maximum iterations.
weightsfloat8[]NULLPer-observation weights. Useful when stations have different apertures or sky conditions.
-- Two optical stations observe the same satellite
SELECT iterations, round(rms_final::numeric, 6) AS rms_rad, status
FROM tle_from_angles(
ra_hours := ARRAY[12.3, 12.5, 12.7, 12.4, 12.6, 12.8],
dec_degrees := ARRAY[45.0, 44.5, 44.0, 44.8, 44.3, 43.7],
times := ARRAY[
'2024-01-01 12:00+00', '2024-01-01 12:02+00',
'2024-01-01 12:04+00', '2024-01-01 12:01+00',
'2024-01-01 12:03+00', '2024-01-01 12:05+00'
]::timestamptz[],
observers := ARRAY['40.0N 105.3W 1655m', '34.1N 118.3W 100m']::observer[],
observer_ids := ARRAY[1, 1, 1, 2, 2, 2]
);

Compute per-observation position residuals between a fitted TLE and the original ECI observations. Returns one row per observation with the XYZ and total position error in km. Use this to identify outlier observations or assess spatial error distribution.

tle_fit_residuals(
fitted tle,
positions eci_position[],
times timestamptz[]
) RETURNS TABLE (
t timestamptz,
dx_km float8,
dy_km float8,
dz_km float8,
pos_err_km float8
)
ParameterTypeDescription
fittedtleThe fitted TLE (typically the fitted_tle output from tle_from_eci()).
positionseci_position[]The original ECI observations used for fitting.
timestimestamptz[]The original observation timestamps.

One row per observation:

ColumnTypeUnitDescription
ttimestamptzObservation time
dx_kmfloat8kmX-axis residual (observed - computed)
dy_kmfloat8kmY-axis residual
dz_kmfloat8kmZ-axis residual
pos_err_kmfloat8kmTotal position error: sqrt(dx^2 + dy^2 + dz^2)
-- After fitting, inspect per-observation residuals
WITH fit AS (
SELECT fitted_tle, positions, times
FROM obs, tle_from_eci(obs.positions, obs.times)
)
SELECT t,
round(dx_km::numeric, 4) AS dx,
round(dy_km::numeric, 4) AS dy,
round(dz_km::numeric, 4) AS dz,
round(pos_err_km::numeric, 4) AS total_err
FROM fit, tle_fit_residuals(fit.fitted_tle, fit.positions, fit.times)
ORDER BY pos_err_km DESC;