Add multi-observer support for topocentric fitting

Extend od_observation_t with observer_idx so each observation can
reference a different ground station. Config now holds an array of
observers instead of a single pointer. The existing single-observer
tle_from_topocentric() is unchanged (sets observer_idx=0 for all obs).

New overload: tle_from_topocentric(topo[], ts[], observer[], int4[], ...)
accepts parallel observer_ids array indexing into the observers array.
PG function overloading resolves by argument types.

Tests 9-11: two-station fit converges, single-station via multi-observer
API matches, out-of-range observer_id raises error.
This commit is contained in:
Ryan Malloy 2026-02-17 15:59:11 -07:00
parent 9b0634725b
commit 59fd8ba743
7 changed files with 507 additions and 14 deletions

View File

@ -2,7 +2,8 @@ MODULE_big = pg_orrery
EXTENSION = pg_orrery
DATA = sql/pg_orrery--0.1.0.sql sql/pg_orrery--0.2.0.sql sql/pg_orrery--0.1.0--0.2.0.sql \
sql/pg_orrery--0.3.0.sql sql/pg_orrery--0.2.0--0.3.0.sql \
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.4.0--0.5.0.sql
# Our extension C sources
OBJS = src/pg_orrery.o src/tle_type.o src/eci_type.o src/observer_type.o \

View File

@ -0,0 +1,21 @@
-- pg_orrery 0.4.0 -> 0.5.0 migration
--
-- Adds multi-observer support, IOD bootstrap (seed-free fitting),
-- and covariance output for uncertainty estimation.
-- ============================================================
-- Multi-observer topocentric fitting
-- ============================================================
CREATE FUNCTION 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,
OUT fitted_tle tle, OUT iterations int4,
OUT rms_final float8, OUT rms_initial float8, OUT status text
) RETURNS RECORD
AS 'MODULE_PATHNAME', 'tle_from_topocentric_multi'
LANGUAGE C STABLE PARALLEL SAFE;
COMMENT ON FUNCTION tle_from_topocentric(topocentric[], timestamptz[], observer[], int4[], tle, boolean, int4) IS
'Fit a TLE from topocentric observations collected by multiple ground stations. observer_ids[i] indexes into observers[]. Requires seed TLE and >= 6 observations.';

View File

@ -26,6 +26,7 @@
PG_FUNCTION_INFO_V1(tle_from_eci);
PG_FUNCTION_INFO_V1(tle_from_topocentric);
PG_FUNCTION_INFO_V1(tle_from_topocentric_multi);
PG_FUNCTION_INFO_V1(tle_fit_residuals);
/* ================================================================
@ -155,10 +156,11 @@ tle_from_eci(PG_FUNCTION_ARGS)
/* Configure solver */
memset(&config, 0, sizeof(config));
config.obs_type = OD_OBS_ECI;
config.fit_bstar = fit_bstar ? 1 : 0;
config.max_iter = max_iter;
config.observer = NULL;
config.obs_type = OD_OBS_ECI;
config.fit_bstar = fit_bstar ? 1 : 0;
config.max_iter = max_iter;
config.observers = NULL;
config.n_observers = 0;
/* Convert seed TLE if provided */
if (has_seed)
@ -274,15 +276,17 @@ tle_from_topocentric(PG_FUNCTION_ARGS)
obs[i].data[0] = topo->azimuth;
obs[i].data[1] = topo->elevation;
obs[i].data[2] = topo->range_km;
obs[i].observer_idx = 0; /* single observer */
}
}
/* Configure solver */
memset(&config, 0, sizeof(config));
config.obs_type = OD_OBS_TOPO;
config.fit_bstar = fit_bstar ? 1 : 0;
config.max_iter = max_iter;
config.observer = &observer;
config.obs_type = OD_OBS_TOPO;
config.fit_bstar = fit_bstar ? 1 : 0;
config.max_iter = max_iter;
config.observers = &observer;
config.n_observers = 1;
/* Seed TLE required for topocentric */
if (!has_seed)
@ -328,6 +332,163 @@ tle_from_topocentric(PG_FUNCTION_ARGS)
}
/* ================================================================
* tle_from_topocentric_multi(topocentric[], timestamptz[],
* observer[], int4[],
* tle, boolean, int4)
* -> RECORD (same as tle_from_eci)
*
* Multi-observer variant: observations from different ground stations.
* observer_ids[i] indexes into the observers[] array.
* ================================================================
*/
Datum
tle_from_topocentric_multi(PG_FUNCTION_ARGS)
{
ArrayType *topo_arr = PG_GETARG_ARRAYTYPE_P(0);
ArrayType *time_arr = PG_GETARG_ARRAYTYPE_P(1);
ArrayType *obs_arr = PG_GETARG_ARRAYTYPE_P(2);
ArrayType *id_arr = PG_GETARG_ARRAYTYPE_P(3);
bool has_seed = !PG_ARGISNULL(4);
pg_tle *seed_pg = has_seed ? (pg_tle *) PG_GETARG_POINTER(4) : NULL;
bool fit_bstar = PG_ARGISNULL(5) ? false : PG_GETARG_BOOL(5);
int32 max_iter = PG_ARGISNULL(6) ? 15 : PG_GETARG_INT32(6);
int n_topo, n_times, n_obs_sites, n_ids;
od_observation_t *obs;
od_config_t config;
od_observer_t *observers;
od_result_t result;
tle_t seed_sat;
int i, rc;
TupleDesc tupdesc;
Datum values[5];
bool nulls[5];
HeapTuple tuple;
/* Deconstruct all arrays */
Datum *topo_datums, *time_datums, *obs_datums, *id_datums;
bool *topo_nulls, *time_nulls, *obs_nulls, *id_nulls;
deconstruct_array(topo_arr, topo_arr->elemtype, sizeof(pg_topocentric),
false, TYPALIGN_DOUBLE,
&topo_datums, &topo_nulls, &n_topo);
deconstruct_array(time_arr, TIMESTAMPTZOID, sizeof(int64), FLOAT8PASSBYVAL,
TYPALIGN_DOUBLE, &time_datums, &time_nulls, &n_times);
deconstruct_array(obs_arr, obs_arr->elemtype, sizeof(pg_observer),
false, TYPALIGN_DOUBLE,
&obs_datums, &obs_nulls, &n_obs_sites);
deconstruct_array(id_arr, INT4OID, sizeof(int32), true,
TYPALIGN_INT, &id_datums, &id_nulls, &n_ids);
if (n_topo != n_times)
ereport(ERROR,
(errcode(ERRCODE_ARRAY_SUBSCRIPT_ERROR),
errmsg("observations and times arrays must have same length: "
"%d vs %d", n_topo, n_times)));
if (n_topo != n_ids)
ereport(ERROR,
(errcode(ERRCODE_ARRAY_SUBSCRIPT_ERROR),
errmsg("observations and observer_ids arrays must have same length: "
"%d vs %d", n_topo, n_ids)));
if (n_topo < 6)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("at least 6 observations required, got %d", n_topo)));
if (n_obs_sites < 1)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("at least 1 observer required")));
/* Build observer array (pre-compute ECEF for each) */
observers = (od_observer_t *) palloc(sizeof(od_observer_t) * n_obs_sites);
for (i = 0; i < n_obs_sites; i++)
{
pg_observer *op = (pg_observer *) DatumGetPointer(obs_datums[i]);
observers[i].lat = op->lat;
observers[i].lon = op->lon;
observers[i].alt_m = op->alt_m;
od_observer_to_ecef(op->lat, op->lon, op->alt_m, observers[i].ecef);
}
/* Build observation array with per-observation observer index */
obs = (od_observation_t *) palloc(sizeof(od_observation_t) * n_topo);
for (i = 0; i < n_topo; i++)
{
pg_topocentric *topo = (pg_topocentric *) DatumGetPointer(topo_datums[i]);
int64 ts = DatumGetInt64(time_datums[i]);
int32 oid = DatumGetInt32(id_datums[i]);
if (oid < 0 || oid >= n_obs_sites)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("observer_id %d out of range [0, %d)",
oid, n_obs_sites)));
obs[i].jd = PG_EPOCH_JD + ((double)ts / (double)USECS_PER_DAY);
obs[i].data[0] = topo->azimuth;
obs[i].data[1] = topo->elevation;
obs[i].data[2] = topo->range_km;
obs[i].observer_idx = oid;
}
/* Configure solver */
memset(&config, 0, sizeof(config));
config.obs_type = OD_OBS_TOPO;
config.fit_bstar = fit_bstar ? 1 : 0;
config.max_iter = max_iter;
config.observers = observers;
config.n_observers = n_obs_sites;
/* Seed TLE required for topocentric */
if (!has_seed)
ereport(ERROR,
(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
errmsg("seed TLE required for topocentric fitting")));
pg_tle_to_sat_code_od(seed_pg, &seed_sat);
memset(&result, 0, sizeof(result));
rc = od_fit_tle(obs, n_topo, &seed_sat, &config, &result);
pfree(obs);
pfree(observers);
if (rc != 0)
ereport(ERROR,
(errcode(ERRCODE_DATA_EXCEPTION),
errmsg("TLE fitting failed: %s", result.status)));
/* Build result tuple */
if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("function returning record called in context "
"that cannot accept type record")));
tupdesc = BlessTupleDesc(tupdesc);
memset(nulls, 0, sizeof(nulls));
{
pg_tle *fitted = (pg_tle *) palloc(sizeof(pg_tle));
sat_code_to_pg_tle(&result.fitted_tle, fitted);
values[0] = PointerGetDatum(fitted);
}
values[1] = Int32GetDatum(result.iterations);
values[2] = Float8GetDatum(result.rms_final);
values[3] = Float8GetDatum(result.rms_initial);
values[4] = CStringGetTextDatum(result.status);
tuple = heap_form_tuple(tupdesc, values, nulls);
PG_RETURN_DATUM(HeapTupleGetDatum(tuple));
}
/* ================================================================
* tle_fit_residuals(tle, eci_position[], timestamptz[])
* -> TABLE (t timestamptz, dx_km float8, dy_km float8, dz_km float8,

View File

@ -223,7 +223,7 @@ compute_residuals_eci(const tle_t *tle, const od_observation_t *obs,
*/
static double
compute_residuals_topo(const tle_t *tle, const od_observation_t *obs,
int n_obs, const od_observer_t *observer,
int n_obs, const od_observer_t *observers,
double *residuals)
{
double *params;
@ -240,6 +240,7 @@ compute_residuals_topo(const tle_t *tle, const od_observation_t *obs,
for (i = 0; i < n_obs; i++)
{
const od_observer_t *observer = &observers[obs[i].observer_idx];
double tsince = (obs[i].jd - tle->epoch) * 1440.0;
double pos[3], vel[3];
int err;
@ -547,7 +548,7 @@ od_fit_tle(const od_observation_t *obs, int n_obs,
rms_cur = compute_residuals_eci(&current_tle, obs, n_obs, residuals);
else
rms_cur = compute_residuals_topo(&current_tle, obs, n_obs,
config->observer, residuals);
config->observers, residuals);
if (rms_cur < 0.0)
{
@ -639,7 +640,7 @@ od_fit_tle(const od_observation_t *obs, int n_obs,
compute_residuals_eci(&tle_pert, obs, n_obs, resid_pert);
else
compute_residuals_topo(&tle_pert, obs, n_obs,
config->observer, resid_pert);
config->observers, resid_pert);
/* Jacobian column j (column-major for LAPACK)
* H = dG/dx where G is the computed observation function.
@ -715,7 +716,7 @@ od_fit_tle(const od_observation_t *obs, int n_obs,
rms_cur = compute_residuals_eci(&current_tle, obs, n_obs, residuals);
else
rms_cur = compute_residuals_topo(&current_tle, obs, n_obs,
config->observer, residuals);
config->observers, residuals);
if (rms_cur < 0.0)
{

View File

@ -42,6 +42,7 @@ typedef struct
{
double jd; /* Julian date of observation */
double data[6]; /* ECI: [x,y,z,vx,vy,vz], topo: [az,el,range,...] */
int observer_idx; /* index into config->observers[] (topo mode) */
} od_observation_t;
/*
@ -63,7 +64,8 @@ typedef struct
od_obs_type_t obs_type; /* ECI or topocentric */
int fit_bstar; /* include B* as 7th state */
int max_iter; /* iteration limit */
od_observer_t *observer; /* non-NULL for topocentric mode */
od_observer_t *observers; /* array of observers (topo mode) */
int n_observers; /* count (0 for ECI mode) */
} od_config_t;
/*

View File

@ -3,6 +3,7 @@
-- Tests tle_from_eci(), tle_from_topocentric(), and tle_fit_residuals().
-- Uses round-trip methodology: propagate known TLE → fit from obs → compare.
CREATE EXTENSION IF NOT EXISTS pg_orrery;
ALTER EXTENSION pg_orrery UPDATE TO '0.5.0';
-- ============================================================
-- Test 1: ECI round-trip (ISS-like LEO orbit)
--
@ -256,3 +257,158 @@ FROM result;
t | t
(1 row)
-- ============================================================
-- Test 9: Multi-observer topocentric (MIT + Boulder observe ISS)
--
-- Two ground stations observe ISS, fit via multi-observer API.
-- Uses the overloaded tle_from_topocentric(topo[], ts[], observer[], int4[], ...).
-- ============================================================
WITH iss_tle AS (
SELECT E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
),
stations AS (
SELECT
'(42.36,-71.09,20)'::observer AS mit,
'(40.01,-105.27,1655)'::observer AS boulder
),
topo_obs AS (
SELECT
array_agg(topo ORDER BY rn) AS observations,
array_agg(ts ORDER BY rn) AS times,
array_agg(obs_id ORDER BY rn) AS observer_ids
FROM (
-- MIT observations (observer_id = 0)
SELECT observe(t, mit, ts) AS topo, ts, 0 AS obs_id,
row_number() OVER (ORDER BY ts) AS rn
FROM iss_tle, stations,
generate_series(
'2024-01-01 12:00:00+00'::timestamptz,
'2024-01-01 13:00:00+00'::timestamptz,
'5 minutes'::interval
) AS ts
UNION ALL
-- Boulder observations (observer_id = 1)
SELECT observe(t, boulder, ts) AS topo, ts, 1 AS obs_id,
row_number() OVER (ORDER BY ts) + 100 AS rn
FROM iss_tle, stations,
generate_series(
'2024-01-01 12:00:00+00'::timestamptz,
'2024-01-01 13:00:00+00'::timestamptz,
'5 minutes'::interval
) AS ts
) sub
),
result AS (
SELECT (tle_from_topocentric(
observations, times,
ARRAY[mit, boulder], observer_ids,
t, false, 20
)).*
FROM topo_obs, stations, iss_tle
)
SELECT
status = 'converged' AS did_converge,
rms_final < 10.0 AS rms_under_10km
FROM result;
did_converge | rms_under_10km
--------------+----------------
t | t
(1 row)
-- ============================================================
-- Test 10: Single observer via multi-observer API
--
-- Verify the multi-observer path produces equivalent results
-- when all observations come from one station.
-- ============================================================
WITH iss_tle AS (
SELECT E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
),
mit AS (
SELECT '(42.36,-71.09,20)'::observer AS obs
),
topo_obs AS (
SELECT
array_agg(observe(t, obs, ts) ORDER BY ts) AS observations,
array_agg(ts ORDER BY ts) AS times,
array_agg(0) AS observer_ids
FROM iss_tle, mit,
generate_series(
'2024-01-01 12:00:00+00'::timestamptz,
'2024-01-01 13:30:00+00'::timestamptz,
'5 minutes'::interval
) AS ts
),
result AS (
SELECT (tle_from_topocentric(
observations, times,
ARRAY[obs], observer_ids,
t, false, 20
)).*
FROM topo_obs, mit, iss_tle
)
SELECT
status = 'converged' AS did_converge,
rms_final < 10.0 AS rms_under_10km
FROM result;
did_converge | rms_under_10km
--------------+----------------
t | t
(1 row)
-- ============================================================
-- Test 11: Error - observer_id out of range
-- ============================================================
DO $$
BEGIN
PERFORM tle_from_topocentric(
ARRAY[
observe(
E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
'(42.36,-71.09,20)'::observer,
'2024-01-01 12:00:00+00'::timestamptz
),
observe(
E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
'(42.36,-71.09,20)'::observer,
'2024-01-01 12:05:00+00'::timestamptz
),
observe(
E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
'(42.36,-71.09,20)'::observer,
'2024-01-01 12:10:00+00'::timestamptz
),
observe(
E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
'(42.36,-71.09,20)'::observer,
'2024-01-01 12:15:00+00'::timestamptz
),
observe(
E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
'(42.36,-71.09,20)'::observer,
'2024-01-01 12:20:00+00'::timestamptz
),
observe(
E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
'(42.36,-71.09,20)'::observer,
'2024-01-01 12:25:00+00'::timestamptz
)
],
ARRAY[
'2024-01-01 12:00:00+00'::timestamptz,
'2024-01-01 12:05:00+00'::timestamptz,
'2024-01-01 12:10:00+00'::timestamptz,
'2024-01-01 12:15:00+00'::timestamptz,
'2024-01-01 12:20:00+00'::timestamptz,
'2024-01-01 12:25:00+00'::timestamptz
],
ARRAY['(42.36,-71.09,20)'::observer],
ARRAY[5, 0, 0, 0, 0, 0], -- observer_id 5 out of range
E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle
);
RAISE NOTICE 'ERROR: should have raised exception';
EXCEPTION
WHEN invalid_parameter_value THEN
RAISE NOTICE 'OK: observer_id out of range error caught';
END $$;
NOTICE: OK: observer_id out of range error caught

View File

@ -4,6 +4,7 @@
-- Uses round-trip methodology: propagate known TLE → fit from obs → compare.
CREATE EXTENSION IF NOT EXISTS pg_orrery;
ALTER EXTENSION pg_orrery UPDATE TO '0.5.0';
-- ============================================================
-- Test 1: ECI round-trip (ISS-like LEO orbit)
@ -237,3 +238,153 @@ SELECT
rms_final < 2.0 AS rms_under_2km,
status = 'converged' AS did_converge
FROM result;
-- ============================================================
-- Test 9: Multi-observer topocentric (MIT + Boulder observe ISS)
--
-- Two ground stations observe ISS, fit via multi-observer API.
-- Uses the overloaded tle_from_topocentric(topo[], ts[], observer[], int4[], ...).
-- ============================================================
WITH iss_tle AS (
SELECT E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
),
stations AS (
SELECT
'(42.36,-71.09,20)'::observer AS mit,
'(40.01,-105.27,1655)'::observer AS boulder
),
topo_obs AS (
SELECT
array_agg(topo ORDER BY rn) AS observations,
array_agg(ts ORDER BY rn) AS times,
array_agg(obs_id ORDER BY rn) AS observer_ids
FROM (
-- MIT observations (observer_id = 0)
SELECT observe(t, mit, ts) AS topo, ts, 0 AS obs_id,
row_number() OVER (ORDER BY ts) AS rn
FROM iss_tle, stations,
generate_series(
'2024-01-01 12:00:00+00'::timestamptz,
'2024-01-01 13:00:00+00'::timestamptz,
'5 minutes'::interval
) AS ts
UNION ALL
-- Boulder observations (observer_id = 1)
SELECT observe(t, boulder, ts) AS topo, ts, 1 AS obs_id,
row_number() OVER (ORDER BY ts) + 100 AS rn
FROM iss_tle, stations,
generate_series(
'2024-01-01 12:00:00+00'::timestamptz,
'2024-01-01 13:00:00+00'::timestamptz,
'5 minutes'::interval
) AS ts
) sub
),
result AS (
SELECT (tle_from_topocentric(
observations, times,
ARRAY[mit, boulder], observer_ids,
t, false, 20
)).*
FROM topo_obs, stations, iss_tle
)
SELECT
status = 'converged' AS did_converge,
rms_final < 10.0 AS rms_under_10km
FROM result;
-- ============================================================
-- Test 10: Single observer via multi-observer API
--
-- Verify the multi-observer path produces equivalent results
-- when all observations come from one station.
-- ============================================================
WITH iss_tle AS (
SELECT E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle AS t
),
mit AS (
SELECT '(42.36,-71.09,20)'::observer AS obs
),
topo_obs AS (
SELECT
array_agg(observe(t, obs, ts) ORDER BY ts) AS observations,
array_agg(ts ORDER BY ts) AS times,
array_agg(0) AS observer_ids
FROM iss_tle, mit,
generate_series(
'2024-01-01 12:00:00+00'::timestamptz,
'2024-01-01 13:30:00+00'::timestamptz,
'5 minutes'::interval
) AS ts
),
result AS (
SELECT (tle_from_topocentric(
observations, times,
ARRAY[obs], observer_ids,
t, false, 20
)).*
FROM topo_obs, mit, iss_tle
)
SELECT
status = 'converged' AS did_converge,
rms_final < 10.0 AS rms_under_10km
FROM result;
-- ============================================================
-- Test 11: Error - observer_id out of range
-- ============================================================
DO $$
BEGIN
PERFORM tle_from_topocentric(
ARRAY[
observe(
E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
'(42.36,-71.09,20)'::observer,
'2024-01-01 12:00:00+00'::timestamptz
),
observe(
E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
'(42.36,-71.09,20)'::observer,
'2024-01-01 12:05:00+00'::timestamptz
),
observe(
E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
'(42.36,-71.09,20)'::observer,
'2024-01-01 12:10:00+00'::timestamptz
),
observe(
E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
'(42.36,-71.09,20)'::observer,
'2024-01-01 12:15:00+00'::timestamptz
),
observe(
E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
'(42.36,-71.09,20)'::observer,
'2024-01-01 12:20:00+00'::timestamptz
),
observe(
E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle,
'(42.36,-71.09,20)'::observer,
'2024-01-01 12:25:00+00'::timestamptz
)
],
ARRAY[
'2024-01-01 12:00:00+00'::timestamptz,
'2024-01-01 12:05:00+00'::timestamptz,
'2024-01-01 12:10:00+00'::timestamptz,
'2024-01-01 12:15:00+00'::timestamptz,
'2024-01-01 12:20:00+00'::timestamptz,
'2024-01-01 12:25:00+00'::timestamptz
],
ARRAY['(42.36,-71.09,20)'::observer],
ARRAY[5, 0, 0, 0, 0, 0], -- observer_id 5 out of range
E'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9025\n2 25544 51.6400 208.9163 0006703 30.1694 61.7520 15.50100486 00001'::tle
);
RAISE NOTICE 'ERROR: should have raised exception';
EXCEPTION
WHEN invalid_parameter_value THEN
RAISE NOTICE 'OK: observer_id out of range error caught';
END $$;