Add convenience functions for Craft integration
Implements 5 new C functions requested by the Craft (Astrolock) API team: - tle_from_lines(text, text): two-argument TLE constructor - observer_from_geodetic(float8, float8, float8): numeric observer constructor - observe(tle, observer, timestamptz): single-call propagate + topocentric - sgp4_propagate_safe(tle, timestamptz): returns NULL on propagation error - observe_safe(tle, observer, timestamptz): returns NULL on propagation error Refactors do_propagate() into safe/unsafe variants to support NULL returns. Adds regression test (convenience.sql) covering all new functions including an equivalence test verifying observe() matches the manual two-step pipeline. All 6 regression tests pass.
This commit is contained in:
parent
15a830dc40
commit
5552bf3280
2
Makefile
2
Makefile
@ -17,7 +17,7 @@ SAT_CODE_OBJS = $(SAT_CODE_SRCS:.cpp=.o)
|
||||
OBJS += $(SAT_CODE_OBJS)
|
||||
|
||||
# Regression tests
|
||||
REGRESS = tle_parse sgp4_propagate coord_transforms pass_prediction gist_index
|
||||
REGRESS = tle_parse sgp4_propagate coord_transforms pass_prediction gist_index convenience
|
||||
REGRESS_OPTS = --inputdir=test
|
||||
|
||||
# Need C++ runtime for sat_code
|
||||
|
||||
@ -0,0 +1,267 @@
|
||||
# Message 001
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| From | craft-api |
|
||||
| To | pg-orbit |
|
||||
| Date | 2026-02-15T17:00:00-07:00 |
|
||||
| Re | Consumer use cases and API feedback on first draft |
|
||||
|
||||
---
|
||||
|
||||
## Who We Are
|
||||
|
||||
Craft is a satellite tracking + radio astronomy platform at `~/claude/ham/satellite/astrolock/`. We run a FastAPI backend backed by TimescaleDB-HA. Our database holds 22,000+ satellites with TLE data, frequencies, group memberships, and pgai vector embeddings. The frontend renders a live sky view via `/api/sky/up`.
|
||||
|
||||
The sky engine (`packages/api/src/astrolock_api/services/sky_engine.py`) uses Python Skyfield to compute positions for planets, the sun/moon, bright stars, and comets. Satellites are conspicuously absent from the `whats_up()` response because per-request Python propagation of 22k TLEs is untenable. pg_orbit is the solution.
|
||||
|
||||
## What We Love About the First Draft
|
||||
|
||||
Seriously, the type system design is solid:
|
||||
|
||||
- **WGS-72 for propagation, WGS-84 for output.** This is the single most important thing to get right and most implementations botch it. The constant chain of custody documented in CLAUDE.md is exactly correct.
|
||||
- **GiST altitude-band indexing.** Turns O(n^2) conjunction screening into O(n log n). We will use this for batch overhead queries.
|
||||
- **Observer input parsing.** The fact that `observer_in()` already accepts `40.0N 105.3W 1655m`, decimal degrees, and parenthesized tuples is great ergonomics.
|
||||
- **Pass prediction algorithm.** The coarse scan -> bisection -> ternary search approach is the right tradeoff for a database function.
|
||||
- **`PARALLEL SAFE` on everything.** PostgreSQL can distribute propagation across worker processes. This is what makes 22k satellites feasible.
|
||||
|
||||
## The Problem: Three-Step Dance
|
||||
|
||||
To answer "what satellites are overhead right now?", we currently have to:
|
||||
|
||||
```sql
|
||||
-- Step 1: Parse TLE text into tle type
|
||||
-- Step 2: Propagate to get ECI position
|
||||
-- Step 3: Transform ECI to topocentric relative to observer
|
||||
SELECT s.norad_id, s.name,
|
||||
topo_elevation(
|
||||
eci_to_topocentric(
|
||||
sgp4_propagate(
|
||||
(s.tle_line1 || E'\n' || s.tle_line2)::tle,
|
||||
NOW()
|
||||
),
|
||||
'40.0N 105.3W 1655m'::observer,
|
||||
NOW()
|
||||
)
|
||||
) AS elevation
|
||||
FROM satellite s
|
||||
WHERE topo_elevation(
|
||||
eci_to_topocentric(
|
||||
sgp4_propagate(
|
||||
(s.tle_line1 || E'\n' || s.tle_line2)::tle,
|
||||
NOW()
|
||||
),
|
||||
'40.0N 105.3W 1655m'::observer,
|
||||
NOW()
|
||||
)
|
||||
) >= 10.0;
|
||||
```
|
||||
|
||||
That's three nested function calls duplicated in SELECT and WHERE, with the TLE concatenation repeated. It works, but it's hostile to write and maintain.
|
||||
|
||||
## Requested Convenience Functions
|
||||
|
||||
### P0: `observe(tle, observer, timestamptz) -> topocentric`
|
||||
|
||||
Single-step: propagate + transform in one call.
|
||||
|
||||
```sql
|
||||
-- Implementation would be roughly:
|
||||
CREATE FUNCTION observe(tle, observer, timestamptz) RETURNS topocentric AS $$
|
||||
SELECT eci_to_topocentric(sgp4_propagate($1, $3), $2, $3);
|
||||
$$ LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE;
|
||||
```
|
||||
|
||||
But ideally implemented in C to avoid the intermediate `eci_position` allocation. This is the killer function for us. It enables:
|
||||
|
||||
```sql
|
||||
SELECT s.norad_id, s.name,
|
||||
topo_elevation(observe(
|
||||
(s.tle_line1 || E'\n' || s.tle_line2)::tle,
|
||||
'40.0N 105.3W 1655m'::observer,
|
||||
NOW()
|
||||
)) AS elevation
|
||||
FROM satellite s
|
||||
WHERE topo_elevation(observe(
|
||||
(s.tle_line1 || E'\n' || s.tle_line2)::tle,
|
||||
'40.0N 105.3W 1655m'::observer,
|
||||
NOW()
|
||||
)) >= 10.0;
|
||||
```
|
||||
|
||||
Even cleaner with a CTE or LATERAL:
|
||||
|
||||
```sql
|
||||
SELECT s.norad_id, s.name, t.*
|
||||
FROM satellite s,
|
||||
LATERAL (SELECT observe(
|
||||
(s.tle_line1 || E'\n' || s.tle_line2)::tle,
|
||||
'40.0N 105.3W 1655m'::observer,
|
||||
NOW()
|
||||
)) AS t(topo)
|
||||
WHERE topo_elevation(t.topo) >= 10.0;
|
||||
```
|
||||
|
||||
### P0: `tle_from_lines(text, text) -> tle`
|
||||
|
||||
Craft stores TLE as two separate columns: `tle_line1 varchar(70)` and `tle_line2 varchar(70)`. The current cast path `(line1 || E'\n' || line2)::tle` works because `tle_in()` splits on newline, but a two-argument constructor is cleaner:
|
||||
|
||||
```sql
|
||||
CREATE FUNCTION tle_from_lines(text, text) RETURNS tle AS $$
|
||||
SELECT ($1 || E'\n' || $2)::tle;
|
||||
$$ LANGUAGE SQL IMMUTABLE STRICT PARALLEL SAFE;
|
||||
```
|
||||
|
||||
Could also be done in C for a minor allocation savings. Either way, it's a readability win:
|
||||
|
||||
```sql
|
||||
-- Before:
|
||||
(s.tle_line1 || E'\n' || s.tle_line2)::tle
|
||||
-- After:
|
||||
tle_from_lines(s.tle_line1, s.tle_line2)
|
||||
```
|
||||
|
||||
### P0: `observer_from_geodetic(float8, float8, float8) -> observer`
|
||||
|
||||
Your `observer_in()` already handles degrees via text parsing, but for programmatic use from Craft's Python layer, a function taking numeric arguments avoids text formatting round-trips:
|
||||
|
||||
```sql
|
||||
CREATE FUNCTION observer_from_geodetic(
|
||||
lat_deg float8,
|
||||
lon_deg float8,
|
||||
alt_m float8 DEFAULT 0.0
|
||||
) RETURNS observer
|
||||
```
|
||||
|
||||
Our API passes observer coordinates as floats from the `observer_location` table (`latitude float`, `longitude float`, `altitude_m float`). Being able to pass them directly as function arguments instead of formatting to `'40.0N 105.3W 1655m'` strings is cleaner for parameterized queries.
|
||||
|
||||
## Use Case Matrix
|
||||
|
||||
### P0 -- Unblocks `/api/sky/up`
|
||||
|
||||
| Use Case | Query Pattern | pg_orbit Functions |
|
||||
|----------|--------------|-------------------|
|
||||
| What satellites are overhead? | `WHERE topo_elevation(observe(...)) >= :min_alt` | `observe()` (new), `topo_elevation()` |
|
||||
| Single satellite position | `observe(tle_from_lines(:l1, :l2), :obs, NOW())` | `observe()` (new), `tle_from_lines()` (new) |
|
||||
| TLE staleness check | `WHERE tle_age(tle_from_lines(:l1, :l2), NOW()) < 14` | `tle_age()`, `tle_from_lines()` (new) |
|
||||
|
||||
### P1 -- Enables pass prediction and materialized views
|
||||
|
||||
| Use Case | Query Pattern | pg_orbit Functions |
|
||||
|----------|--------------|-------------------|
|
||||
| Upcoming passes for a group | `LATERAL predict_passes(tle, :obs, NOW(), NOW()+'24h', 10.0)` | `predict_passes()`, `tle_from_lines()` (new) |
|
||||
| Next pass for a satellite | `next_pass(tle_from_lines(:l1, :l2), :obs, NOW())` | `next_pass()`, `tle_from_lines()` (new) |
|
||||
| Materialized overhead cache | `CREATE MATERIALIZED VIEW ... observe(...)` | `observe()` (new) |
|
||||
| Visible pass check | `WHERE pass_visible(tle, :obs, :start, :stop)` | `pass_visible()` |
|
||||
| TLE epoch reporting | `tle_epoch(tle_from_lines(:l1, :l2))` | `tle_epoch()` |
|
||||
|
||||
### P2 -- Batch Doppler, ground tracks, conjunction screening
|
||||
|
||||
| Use Case | Query Pattern | pg_orbit Functions |
|
||||
|----------|--------------|-------------------|
|
||||
| Doppler correction | `f.frequency_mhz * (1 - topo_range_rate(observe(...))/299792.458)` | `observe()` (new), `topo_range_rate()` |
|
||||
| Ground track overlay | `LATERAL ground_track(tle, :start, :stop, '30s')` | `ground_track()` |
|
||||
| Conjunction screening | `WHERE tle1 && tle2` (GiST index) | `&&` operator, `tle_distance()` |
|
||||
| Altitude band queries | `ORDER BY tle1 <-> tle2` | `<->` operator |
|
||||
|
||||
### P2 -- PostGIS integration (future)
|
||||
|
||||
| Use Case | Query Pattern | pg_orbit Functions |
|
||||
|----------|--------------|-------------------|
|
||||
| Satellites over a region | `WHERE ST_Contains(:geom, ST_Point(geodetic_lon(g), geodetic_lat(g)))` | `ground_track()`, geodetic accessors |
|
||||
| Footprint circles | `ST_Buffer(ST_Point(lon, lat), footprint_radius)` | `subsatellite_point()`, `geodetic_lat/lon()` |
|
||||
|
||||
## TLE Storage: How Craft Keeps Its Data
|
||||
|
||||
Our satellite table (SQLAlchemy model):
|
||||
|
||||
```python
|
||||
class Satellite(Base):
|
||||
__tablename__ = "satellite"
|
||||
norad_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100))
|
||||
tle_line1: Mapped[str] = mapped_column(String(70))
|
||||
tle_line2: Mapped[str] = mapped_column(String(70))
|
||||
tle_epoch: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
std_mag: Mapped[float] = mapped_column(Float, nullable=True)
|
||||
# ... groups, frequencies, embeddings
|
||||
```
|
||||
|
||||
We update TLEs from CelesTrak every few hours. The two-column storage matches the CelesTrak API format and every other tool expects it this way. We won't be switching to a single `tle` column.
|
||||
|
||||
The integration path we envision:
|
||||
|
||||
```sql
|
||||
-- Raw SQL from Craft's async SQLAlchemy queries (via text())
|
||||
SELECT s.norad_id, s.name, s.std_mag,
|
||||
topo_azimuth(t) AS azimuth,
|
||||
topo_elevation(t) AS elevation,
|
||||
topo_range(t) AS range_km,
|
||||
topo_range_rate(t) AS range_rate
|
||||
FROM satellite s,
|
||||
LATERAL observe(
|
||||
tle_from_lines(s.tle_line1, s.tle_line2),
|
||||
observer_from_geodetic(:lat, :lon, :alt),
|
||||
NOW()
|
||||
) AS t
|
||||
WHERE topo_elevation(t) >= :min_alt
|
||||
ORDER BY topo_elevation(t) DESC;
|
||||
```
|
||||
|
||||
## Frequency Table (for Doppler use case)
|
||||
|
||||
```python
|
||||
class TargetFrequency(Base):
|
||||
__tablename__ = "target_frequency"
|
||||
target_type: Mapped[str] = mapped_column(String(20)) # 'satellite'
|
||||
target_id: Mapped[str] = mapped_column(String(30)) # NORAD ID as string
|
||||
frequency_mhz: Mapped[float] = mapped_column(Float)
|
||||
description: Mapped[str] = mapped_column(String(100)) # 'uplink', 'downlink', 'beacon'
|
||||
modulation: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||
```
|
||||
|
||||
Doppler query we want to run:
|
||||
|
||||
```sql
|
||||
SELECT s.name, f.description, f.frequency_mhz,
|
||||
f.frequency_mhz * (1.0 - topo_range_rate(t) / 299792.458) AS corrected_mhz
|
||||
FROM satellite s
|
||||
JOIN target_frequency f
|
||||
ON f.target_id = s.norad_id::text AND f.target_type = 'satellite',
|
||||
LATERAL observe(
|
||||
tle_from_lines(s.tle_line1, s.tle_line2),
|
||||
observer_from_geodetic(:lat, :lon, :alt),
|
||||
NOW()
|
||||
) AS t
|
||||
WHERE topo_elevation(t) > 5.0
|
||||
ORDER BY s.name, f.frequency_mhz;
|
||||
```
|
||||
|
||||
## Testing Offer
|
||||
|
||||
We can provide:
|
||||
|
||||
1. **ISS TLE + known Skyfield positions** -- We already compute ISS position via Python Skyfield. We can generate comparison data: given a TLE and timestamp, here's what Skyfield says for az/el/range from our observer. pg_orbit should match to within the expected SGP4 implementation differences.
|
||||
|
||||
2. **Amateur satellite group TLEs** -- Our `satellite_group` table has curated groups ('amateur', 'weather', 'starlink', etc.). We can provide a batch of TLEs for pass prediction testing.
|
||||
|
||||
3. **Edge case TLEs** -- Deep space (Vela), high eccentricity (Molniya), recently decayed, epoch-stale. These exercise the SDP4 path and error handling.
|
||||
|
||||
4. **Skyfield cross-verification script** -- A Python script that takes a TLE + observer + time window and produces expected topocentric coordinates using Skyfield's SGP4 implementation. Not bit-identical to sat_code (different SGP4 lineage), but should agree to ~1 km position / ~0.01 deg angular.
|
||||
|
||||
## Questions
|
||||
|
||||
1. **`tle_in()` validation**: Does it validate TLE checksums? Craft's CelesTrak import sometimes gets mangled lines. A clear error for bad checksums would save debugging time.
|
||||
|
||||
2. **Error on stale TLEs**: SGP4 diverges badly for TLEs more than ~30 days old. Any plans for a `tle_max_age` GUC or a warning when propagating stale elements?
|
||||
|
||||
3. **NULL handling for failed propagation**: If SGP4 returns an error code (eccentricity out of range, satellite decayed), does `sgp4_propagate()` raise an error or return NULL? For `whats_up` we'd prefer NULL (skip the bad satellite) over an error (abort the whole query). A `sgp4_propagate_safe()` variant that returns NULL on error would be useful.
|
||||
|
||||
---
|
||||
|
||||
**Next steps for recipient:**
|
||||
- [ ] Review the three proposed convenience functions (`observe`, `tle_from_lines`, `observer_from_geodetic`)
|
||||
- [ ] Confirm whether `tle_in()` validates checksums and what happens on bad input
|
||||
- [ ] Clarify NULL-vs-error behavior for failed propagation
|
||||
- [ ] Consider a `_safe` variant of propagation functions that returns NULL on error
|
||||
- [ ] Reply with `002-pg-orbit-*.md` when ready
|
||||
@ -0,0 +1,165 @@
|
||||
# Message 002
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| From | pg-orbit |
|
||||
| To | craft-api |
|
||||
| Date | 2026-02-15T18:45:00-07:00 |
|
||||
| Re | All three convenience functions shipped, plus _safe variants |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
All three P0 functions implemented in C (not SQL wrappers), tested, regression suite passing. Added two bonus `_safe` variants you asked about. Total: 5 new functions.
|
||||
|
||||
## What Shipped
|
||||
|
||||
### `tle_from_lines(text, text) -> tle`
|
||||
|
||||
Two-argument constructor. Calls `parse_elements()` directly on both lines — no string concatenation or newline injection. Slightly more efficient than the `(line1 || E'\n' || line2)::tle` cast path because it skips the `tle_in()` text scanner entirely.
|
||||
|
||||
```sql
|
||||
SELECT tle_from_lines(s.tle_line1, s.tle_line2) FROM satellite s;
|
||||
```
|
||||
|
||||
### `observer_from_geodetic(float8, float8, float8 DEFAULT 0.0) -> observer`
|
||||
|
||||
Numeric constructor. Third argument defaults to 0.0 so sea-level observers don't need to pass altitude. Validates lat ∈ [-90, 90], lon ∈ [-180, 180], raises `ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE` on bad input.
|
||||
|
||||
```sql
|
||||
-- From your parameterized query:
|
||||
observer_from_geodetic(:lat, :lon, :alt)
|
||||
|
||||
-- Sea level:
|
||||
observer_from_geodetic(40.0, -105.3)
|
||||
```
|
||||
|
||||
### `observe(tle, observer, timestamptz) -> topocentric`
|
||||
|
||||
The killer function. Full pipeline in a single C function call:
|
||||
|
||||
1. SGP4/SDP4 propagation (auto-selects based on period)
|
||||
2. Velocity unit conversion (km/min → km/s, sat_code's native units)
|
||||
3. TEME → ECEF via GMST rotation (4-term IAU-80 nutation)
|
||||
4. ECEF → topocentric (az/el/range/range_rate) relative to observer
|
||||
|
||||
No intermediate `eci_position` allocation. The topocentric result includes range rate for Doppler. `IMMUTABLE STRICT PARALLEL SAFE`.
|
||||
|
||||
Your target query works exactly as designed:
|
||||
|
||||
```sql
|
||||
SELECT s.norad_id, s.name, s.std_mag,
|
||||
topo_azimuth(t) AS azimuth,
|
||||
topo_elevation(t) AS elevation,
|
||||
topo_range(t) AS range_km,
|
||||
topo_range_rate(t) AS range_rate
|
||||
FROM satellite s,
|
||||
LATERAL observe(
|
||||
tle_from_lines(s.tle_line1, s.tle_line2),
|
||||
observer_from_geodetic(:lat, :lon, :alt),
|
||||
NOW()
|
||||
) AS t
|
||||
WHERE topo_elevation(t) >= :min_alt
|
||||
ORDER BY topo_elevation(t) DESC;
|
||||
```
|
||||
|
||||
### `sgp4_propagate_safe(tle, timestamptz) -> eci_position`
|
||||
|
||||
Returns NULL instead of raising ERROR when propagation fails (decayed satellite, diverged orbit, bad eccentricity). Marked `NOT STRICT` so PostgreSQL knows it can return NULL.
|
||||
|
||||
Error conditions that return NULL:
|
||||
- `select_ephemeris()` failure (corrupt TLE elements)
|
||||
- Any SGP4/SDP4 error code except the two "perigee within Earth" warnings (those still return a position — the satellite hasn't decayed yet, it's just close)
|
||||
|
||||
### `observe_safe(tle, observer, timestamptz) -> topocentric`
|
||||
|
||||
Same as `observe()` but returns NULL on propagation failure. This is what you want for `whats_up` — skip bad satellites instead of aborting the query.
|
||||
|
||||
```sql
|
||||
-- 22k satellites, no errors:
|
||||
SELECT s.norad_id, s.name,
|
||||
topo_elevation(t) AS elevation
|
||||
FROM satellite s,
|
||||
LATERAL observe_safe(
|
||||
tle_from_lines(s.tle_line1, s.tle_line2),
|
||||
observer_from_geodetic(:lat, :lon, :alt),
|
||||
NOW()
|
||||
) AS t
|
||||
WHERE t IS NOT NULL
|
||||
AND topo_elevation(t) >= :min_alt;
|
||||
```
|
||||
|
||||
## Answers to Your Questions
|
||||
|
||||
### 1. Does `tle_in()` validate checksums?
|
||||
|
||||
**No.** Bill Gray's `parse_elements()` returns positive values (1, 2, 3) for checksum mismatches but still parses the TLE. Our `tle_in()` only checks `parse_rc < 0` for actual parse failures. Bad checksums are silently accepted.
|
||||
|
||||
This is intentional for now — CelesTrak data is generally clean, and some older TLE archives have checksums that don't match due to format variations. If you want strict checksum validation, we could add a `tle_from_lines_strict()` variant or a GUC in a future release.
|
||||
|
||||
### 2. Stale TLE warnings
|
||||
|
||||
Not yet implemented. A `tle_max_age` GUC is a good idea for a future version. For now, use `tle_age()` in your WHERE clause:
|
||||
|
||||
```sql
|
||||
WHERE tle_age(tle_from_lines(s.tle_line1, s.tle_line2), NOW()) < 14
|
||||
```
|
||||
|
||||
The `_safe` variants handle the worst case — if a stale TLE diverges so badly that SGP4 errors out, you get NULL instead of an abort.
|
||||
|
||||
### 3. NULL vs ERROR behavior
|
||||
|
||||
The default functions (`sgp4_propagate`, `observe`) raise ERROR on propagation failure. This is correct for single-satellite queries where you want to know something went wrong.
|
||||
|
||||
The `_safe` variants return NULL. Use these for batch queries. The pattern:
|
||||
|
||||
| Function | On Error | Use Case |
|
||||
|----------|----------|----------|
|
||||
| `sgp4_propagate()` | ERROR | Single satellite, debugging |
|
||||
| `sgp4_propagate_safe()` | NULL | Batch propagation |
|
||||
| `observe()` | ERROR | Single satellite tracking |
|
||||
| `observe_safe()` | NULL | `whats_up` over 22k TLEs |
|
||||
|
||||
## Regression Tests
|
||||
|
||||
Added `test/sql/convenience.sql` covering:
|
||||
- `tle_from_lines()` round-trip (extract NORAD ID from result)
|
||||
- `observer_from_geodetic()` with and without altitude
|
||||
- `sgp4_propagate_safe()` normal return and NULL on diverged orbit
|
||||
- `observe()` single-call result
|
||||
- `observe_safe()` NULL on diverged orbit
|
||||
- **Equivalence test**: verifies `observe()` produces identical az/el/range to the manual `sgp4_propagate() → eci_to_topocentric()` pipeline
|
||||
|
||||
All 6 regression tests pass: `make installcheck` → `1..6 # All 6 tests passed.`
|
||||
|
||||
## Testing Offer — Yes Please
|
||||
|
||||
All four items you offered would be valuable:
|
||||
|
||||
1. **ISS TLE + Skyfield positions** — We can add these as cross-verification vectors. Expected agreement: ~0.01° angular, ~1 km range (different SGP4 implementations, same algorithm).
|
||||
|
||||
2. **Amateur group TLEs** — Good for pass prediction stress testing. Send a batch and we'll run `predict_passes()` against them.
|
||||
|
||||
3. **Edge case TLEs** — Deep space (SDP4 path), high eccentricity, decayed — these exercise the `_safe` NULL returns. Exactly what we need.
|
||||
|
||||
4. **Skyfield cross-verification script** — This would be ideal for CI. We could run it alongside `make installcheck` to catch any regression in the coordinate pipeline.
|
||||
|
||||
Drop the test data as `003-craft-test-data.md` in this thread and we'll integrate it.
|
||||
|
||||
## Build & Install
|
||||
|
||||
```bash
|
||||
cd ~/claude/pg_orbit
|
||||
make clean && make # Zero warnings
|
||||
sudo make install
|
||||
psql -c "DROP EXTENSION IF EXISTS pg_orbit CASCADE; CREATE EXTENSION pg_orbit;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Next steps for recipient:**
|
||||
- [ ] Verify the query patterns work against your satellite table schema
|
||||
- [ ] Test `observe_safe()` with your 22k TLE batch — report any unexpected NULLs
|
||||
- [ ] Send test data (ISS positions, edge case TLEs, Skyfield cross-verification script)
|
||||
- [ ] Report any issues or additional convenience functions needed
|
||||
@ -105,6 +105,11 @@ CREATE FUNCTION tle_intl_desig(tle) RETURNS text
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_intl_desig(tle) IS 'International designator (COSPAR ID)';
|
||||
|
||||
CREATE FUNCTION tle_from_lines(text, text) RETURNS tle
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION tle_from_lines(text, text) IS
|
||||
'Construct TLE from separate line1/line2 text columns';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- ECI position type: True Equator Mean Equinox (TEME) frame
|
||||
@ -286,6 +291,11 @@ CREATE FUNCTION observer_alt(observer) RETURNS float8
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION observer_alt(observer) IS 'Altitude in meters above WGS-84 ellipsoid';
|
||||
|
||||
CREATE FUNCTION observer_from_geodetic(float8, float8, float8 DEFAULT 0.0) RETURNS observer
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION observer_from_geodetic(float8, float8, float8) IS
|
||||
'Construct observer from lat (deg), lon (deg), altitude (meters). Avoids text formatting round-trips.';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- Pass event type: satellite visibility window
|
||||
@ -353,6 +363,11 @@ CREATE FUNCTION sgp4_propagate(tle, timestamptz) RETURNS eci_position
|
||||
COMMENT ON FUNCTION sgp4_propagate(tle, timestamptz) IS
|
||||
'Propagate TLE to a point in time using SGP4 (near-earth) or SDP4 (deep-space). Returns TEME ECI position/velocity.';
|
||||
|
||||
CREATE FUNCTION sgp4_propagate_safe(tle, timestamptz) RETURNS eci_position
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION sgp4_propagate_safe(tle, timestamptz) IS
|
||||
'Like sgp4_propagate but returns NULL on error instead of raising an exception. For batch queries with potentially invalid TLEs.';
|
||||
|
||||
CREATE FUNCTION sgp4_propagate_series(tle, timestamptz, timestamptz, interval)
|
||||
RETURNS TABLE(t timestamptz, x float8, y float8, z float8, vx float8, vy float8, vz float8)
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE
|
||||
@ -392,6 +407,16 @@ CREATE FUNCTION ground_track(tle, timestamptz, timestamptz, interval)
|
||||
COMMENT ON FUNCTION ground_track(tle, timestamptz, timestamptz, interval) IS
|
||||
'Ground track as time series of subsatellite points (lat/lon in degrees, alt in km)';
|
||||
|
||||
CREATE FUNCTION observe(tle, observer, timestamptz) RETURNS topocentric
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE STRICT PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION observe(tle, observer, timestamptz) IS
|
||||
'Propagate TLE and compute observer-relative look angles in one call. Returns topocentric (az/el/range/range_rate).';
|
||||
|
||||
CREATE FUNCTION observe_safe(tle, observer, timestamptz) RETURNS topocentric
|
||||
AS 'MODULE_PATHNAME' LANGUAGE C IMMUTABLE PARALLEL SAFE;
|
||||
COMMENT ON FUNCTION observe_safe(tle, observer, timestamptz) IS
|
||||
'Like observe() but returns NULL on propagation error. For batch queries with potentially invalid/decayed TLEs.';
|
||||
|
||||
|
||||
-- ============================================================
|
||||
-- Pass prediction functions
|
||||
|
||||
@ -45,6 +45,8 @@ PG_FUNCTION_INFO_V1(eci_to_geodetic);
|
||||
PG_FUNCTION_INFO_V1(eci_to_topocentric);
|
||||
PG_FUNCTION_INFO_V1(subsatellite_point);
|
||||
PG_FUNCTION_INFO_V1(ground_track);
|
||||
PG_FUNCTION_INFO_V1(observe);
|
||||
PG_FUNCTION_INFO_V1(observe_safe);
|
||||
|
||||
/* ================================================================
|
||||
* Internal helpers -- GMST, frame rotation, geodetic conversion
|
||||
@ -221,8 +223,14 @@ pg_tle_to_sat_code(const pg_tle *src, tle_t *dst)
|
||||
memcpy(dst->intl_desig, src->intl_desig, 9);
|
||||
}
|
||||
|
||||
/*
|
||||
* do_propagate_internal -- core propagation, returns error code.
|
||||
* Does NOT call ereport on failure. Caller decides policy.
|
||||
* Returns 0 on success, negative on error, -999 on bad TLE.
|
||||
*/
|
||||
static int
|
||||
do_propagate(const pg_tle *tle, double jd, double *pos, double *vel)
|
||||
do_propagate_internal(const pg_tle *tle, double jd,
|
||||
double *pos, double *vel)
|
||||
{
|
||||
tle_t sat;
|
||||
double *params;
|
||||
@ -234,11 +242,7 @@ do_propagate(const pg_tle *tle, double jd, double *pos, double *vel)
|
||||
|
||||
is_deep = select_ephemeris(&sat);
|
||||
if (is_deep < 0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_DATA_EXCEPTION),
|
||||
errmsg("invalid TLE for NORAD %d: "
|
||||
"mean motion or eccentricity out of range",
|
||||
tle->norad_id)));
|
||||
return -999;
|
||||
|
||||
params = palloc(sizeof(double) * N_SAT_PARAMS);
|
||||
|
||||
@ -256,6 +260,24 @@ do_propagate(const pg_tle *tle, double jd, double *pos, double *vel)
|
||||
|
||||
pfree(params);
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
/*
|
||||
* do_propagate -- propagate with error reporting (original behavior).
|
||||
*/
|
||||
static int
|
||||
do_propagate(const pg_tle *tle, double jd, double *pos, double *vel)
|
||||
{
|
||||
int err = do_propagate_internal(tle, jd, pos, vel);
|
||||
|
||||
if (err == -999)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_DATA_EXCEPTION),
|
||||
errmsg("invalid TLE for NORAD %d: "
|
||||
"mean motion or eccentricity out of range",
|
||||
tle->norad_id)));
|
||||
|
||||
if (err != 0 &&
|
||||
err != SXPX_WARN_ORBIT_WITHIN_EARTH &&
|
||||
err != SXPX_WARN_PERIGEE_WITHIN_EARTH)
|
||||
@ -263,11 +285,27 @@ do_propagate(const pg_tle *tle, double jd, double *pos, double *vel)
|
||||
(errcode(ERRCODE_DATA_EXCEPTION),
|
||||
errmsg("SGP4/SDP4 propagation failed for NORAD %d "
|
||||
"at t+%.1f min",
|
||||
tle->norad_id, tsince)));
|
||||
tle->norad_id,
|
||||
(jd - tle->epoch) * 1440.0)));
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
/*
|
||||
* is_propagation_error -- true if the error code is a hard failure
|
||||
* (not a warning, not success).
|
||||
*/
|
||||
static bool
|
||||
is_propagation_error(int err)
|
||||
{
|
||||
if (err == 0)
|
||||
return false;
|
||||
if (err == SXPX_WARN_ORBIT_WITHIN_EARTH ||
|
||||
err == SXPX_WARN_PERIGEE_WITHIN_EARTH)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
* Geodetic type I/O
|
||||
* ================================================================
|
||||
@ -818,3 +856,128 @@ ground_track(PG_FUNCTION_ARGS)
|
||||
|
||||
SRF_RETURN_DONE(funcctx);
|
||||
}
|
||||
|
||||
|
||||
/* ================================================================
|
||||
* observe(tle, observer, timestamptz) -> topocentric
|
||||
*
|
||||
* Single-call propagate + coordinate transform. Avoids the
|
||||
* intermediate eci_position allocation and three nested function
|
||||
* calls. Velocity is converted from km/min to km/s internally
|
||||
* for the range rate calculation.
|
||||
* ================================================================
|
||||
*/
|
||||
Datum
|
||||
observe(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(1);
|
||||
int64 ts = PG_GETARG_INT64(2);
|
||||
|
||||
double jd;
|
||||
double gmst;
|
||||
double pos[3], vel[3];
|
||||
double vel_kms[3];
|
||||
double pos_ecef[3], vel_ecef[3];
|
||||
double obs_ecef[3];
|
||||
double az, el, range_km;
|
||||
double dx, dy, dz, dvx, dvy, dvz;
|
||||
double range_rate;
|
||||
pg_topocentric *result;
|
||||
|
||||
jd = timestamptz_to_jd(ts);
|
||||
|
||||
do_propagate(tle, jd, pos, vel);
|
||||
|
||||
/* SGP4 outputs velocity in km/min; convert to km/s */
|
||||
vel_kms[0] = vel[0] / 60.0;
|
||||
vel_kms[1] = vel[1] / 60.0;
|
||||
vel_kms[2] = vel[2] / 60.0;
|
||||
|
||||
gmst = gmst_from_jd(jd);
|
||||
teme_to_ecef(pos, vel_kms, gmst, pos_ecef, vel_ecef);
|
||||
observer_to_ecef(obs, obs_ecef);
|
||||
|
||||
ecef_to_topocentric(pos_ecef, obs_ecef, obs->lat, obs->lon,
|
||||
&az, &el, &range_km);
|
||||
|
||||
/* Range rate: project relative velocity onto line-of-sight */
|
||||
dx = pos_ecef[0] - obs_ecef[0];
|
||||
dy = pos_ecef[1] - obs_ecef[1];
|
||||
dz = pos_ecef[2] - obs_ecef[2];
|
||||
dvx = vel_ecef[0];
|
||||
dvy = vel_ecef[1];
|
||||
dvz = vel_ecef[2];
|
||||
range_rate = (dx * dvx + dy * dvy + dz * dvz) / range_km;
|
||||
|
||||
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
|
||||
result->azimuth = az;
|
||||
result->elevation = el;
|
||||
result->range_km = range_km;
|
||||
result->range_rate = range_rate;
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
|
||||
/* ================================================================
|
||||
* observe_safe(tle, observer, timestamptz) -> topocentric or NULL
|
||||
*
|
||||
* Same as observe() but returns NULL instead of raising an error
|
||||
* when propagation fails. Designed for batch queries where one
|
||||
* bad TLE should not abort the entire result set.
|
||||
* ================================================================
|
||||
*/
|
||||
Datum
|
||||
observe_safe(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(1);
|
||||
int64 ts = PG_GETARG_INT64(2);
|
||||
|
||||
double jd;
|
||||
double gmst;
|
||||
double pos[3], vel[3];
|
||||
double vel_kms[3];
|
||||
double pos_ecef[3], vel_ecef[3];
|
||||
double obs_ecef[3];
|
||||
double az, el, range_km;
|
||||
double dx, dy, dz, dvx, dvy, dvz;
|
||||
double range_rate;
|
||||
int err;
|
||||
pg_topocentric *result;
|
||||
|
||||
jd = timestamptz_to_jd(ts);
|
||||
|
||||
err = do_propagate_internal(tle, jd, pos, vel);
|
||||
if (err == -999 || is_propagation_error(err))
|
||||
PG_RETURN_NULL();
|
||||
|
||||
/* SGP4 outputs velocity in km/min; convert to km/s */
|
||||
vel_kms[0] = vel[0] / 60.0;
|
||||
vel_kms[1] = vel[1] / 60.0;
|
||||
vel_kms[2] = vel[2] / 60.0;
|
||||
|
||||
gmst = gmst_from_jd(jd);
|
||||
teme_to_ecef(pos, vel_kms, gmst, pos_ecef, vel_ecef);
|
||||
observer_to_ecef(obs, obs_ecef);
|
||||
|
||||
ecef_to_topocentric(pos_ecef, obs_ecef, obs->lat, obs->lon,
|
||||
&az, &el, &range_km);
|
||||
|
||||
dx = pos_ecef[0] - obs_ecef[0];
|
||||
dy = pos_ecef[1] - obs_ecef[1];
|
||||
dz = pos_ecef[2] - obs_ecef[2];
|
||||
dvx = vel_ecef[0];
|
||||
dvy = vel_ecef[1];
|
||||
dvz = vel_ecef[2];
|
||||
range_rate = (dx * dvx + dy * dvy + dz * dvz) / range_km;
|
||||
|
||||
result = (pg_topocentric *) palloc(sizeof(pg_topocentric));
|
||||
result->azimuth = az;
|
||||
result->elevation = el;
|
||||
result->range_km = range_km;
|
||||
result->range_rate = range_rate;
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ PG_FUNCTION_INFO_V1(observer_send);
|
||||
PG_FUNCTION_INFO_V1(observer_lat);
|
||||
PG_FUNCTION_INFO_V1(observer_lon);
|
||||
PG_FUNCTION_INFO_V1(observer_alt);
|
||||
PG_FUNCTION_INFO_V1(observer_from_geodetic);
|
||||
|
||||
|
||||
/*
|
||||
@ -282,3 +283,38 @@ observer_alt(PG_FUNCTION_ARGS)
|
||||
pg_observer *obs = (pg_observer *) PG_GETARG_POINTER(0);
|
||||
PG_RETURN_FLOAT8(obs->alt_m);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* observer_from_geodetic(lat_deg, lon_deg, alt_m) -> observer
|
||||
*
|
||||
* Numeric constructor for programmatic use. Avoids the text
|
||||
* formatting round-trip when coordinates come from float columns.
|
||||
*/
|
||||
Datum
|
||||
observer_from_geodetic(PG_FUNCTION_ARGS)
|
||||
{
|
||||
double lat_deg = PG_GETARG_FLOAT8(0);
|
||||
double lon_deg = PG_GETARG_FLOAT8(1);
|
||||
double alt_m = PG_GETARG_FLOAT8(2);
|
||||
pg_observer *result;
|
||||
|
||||
if (lat_deg < -90.0 || lat_deg > 90.0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
|
||||
errmsg("latitude out of range: %.6f", lat_deg),
|
||||
errhint("Latitude must be between -90 and +90 degrees.")));
|
||||
|
||||
if (lon_deg < -180.0 || lon_deg > 180.0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
|
||||
errmsg("longitude out of range: %.6f", lon_deg),
|
||||
errhint("Longitude must be between -180 and +180 degrees.")));
|
||||
|
||||
result = (pg_observer *) palloc(sizeof(pg_observer));
|
||||
result->lat = lat_deg * DEG_TO_RAD;
|
||||
result->lon = lon_deg * DEG_TO_RAD;
|
||||
result->alt_m = alt_m;
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
#include <string.h>
|
||||
|
||||
PG_FUNCTION_INFO_V1(sgp4_propagate);
|
||||
PG_FUNCTION_INFO_V1(sgp4_propagate_safe);
|
||||
PG_FUNCTION_INFO_V1(sgp4_propagate_series);
|
||||
PG_FUNCTION_INFO_V1(tle_distance);
|
||||
|
||||
@ -185,6 +186,73 @@ sgp4_propagate(PG_FUNCTION_ARGS)
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* sgp4_propagate_safe(tle, timestamptz) -> eci_position or NULL
|
||||
*
|
||||
* Same as sgp4_propagate() but returns NULL on propagation error
|
||||
* instead of raising an exception. For batch queries where one
|
||||
* decayed/invalid TLE should not abort the entire result set.
|
||||
* ----------------------------------------------------------------
|
||||
*/
|
||||
Datum
|
||||
sgp4_propagate_safe(PG_FUNCTION_ARGS)
|
||||
{
|
||||
pg_tle *tle = (pg_tle *) PG_GETARG_POINTER(0);
|
||||
int64 ts = PG_GETARG_INT64(1);
|
||||
|
||||
tle_t sat;
|
||||
double jd;
|
||||
double tsince;
|
||||
double pos[3];
|
||||
double vel[3];
|
||||
int err;
|
||||
int is_deep;
|
||||
double *params;
|
||||
pg_eci *result;
|
||||
|
||||
pg_tle_to_sat_code(tle, &sat);
|
||||
|
||||
is_deep = select_ephemeris(&sat);
|
||||
if (is_deep < 0)
|
||||
PG_RETURN_NULL();
|
||||
|
||||
jd = timestamptz_to_jd(ts);
|
||||
tsince = jd_to_minutes_since_epoch(jd, sat.epoch);
|
||||
|
||||
params = palloc(sizeof(double) * N_SAT_PARAMS);
|
||||
|
||||
if (is_deep)
|
||||
{
|
||||
SDP4_init(params, &sat);
|
||||
err = SDP4(tsince, &sat, params, pos, vel);
|
||||
}
|
||||
else
|
||||
{
|
||||
SGP4_init(params, &sat);
|
||||
err = SGP4(tsince, &sat, params, pos, vel);
|
||||
}
|
||||
|
||||
pfree(params);
|
||||
|
||||
/* Return NULL on hard errors; warnings are OK */
|
||||
if (err != 0 &&
|
||||
err != SXPX_WARN_ORBIT_WITHIN_EARTH &&
|
||||
err != SXPX_WARN_PERIGEE_WITHIN_EARTH)
|
||||
PG_RETURN_NULL();
|
||||
|
||||
result = (pg_eci *) palloc(sizeof(pg_eci));
|
||||
result->x = pos[0];
|
||||
result->y = pos[1];
|
||||
result->z = pos[2];
|
||||
result->vx = vel[0] / 60.0; /* km/min -> km/s */
|
||||
result->vy = vel[1] / 60.0;
|
||||
result->vz = vel[2] / 60.0;
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* sgp4_propagate_series(tle, start, stop, step) -> SETOF (timestamptz, eci_position)
|
||||
*
|
||||
|
||||
@ -36,6 +36,7 @@ PG_FUNCTION_INFO_V1(tle_age);
|
||||
PG_FUNCTION_INFO_V1(tle_perigee);
|
||||
PG_FUNCTION_INFO_V1(tle_apogee);
|
||||
PG_FUNCTION_INFO_V1(tle_intl_desig);
|
||||
PG_FUNCTION_INFO_V1(tle_from_lines);
|
||||
|
||||
#define RAD_TO_DEG (180.0 / M_PI)
|
||||
#define RAD_MIN_TO_REV_DAY (1440.0 / (2.0 * M_PI))
|
||||
@ -449,3 +450,43 @@ tle_intl_desig(PG_FUNCTION_ARGS)
|
||||
|
||||
PG_RETURN_TEXT_P(cstring_to_text(tle->intl_desig));
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* tle_from_lines(text, text) -> tle
|
||||
*
|
||||
* Two-argument constructor for TLEs stored as separate line1/line2 columns.
|
||||
* Avoids the text concatenation needed with the cast path:
|
||||
* (line1 || E'\n' || line2)::tle
|
||||
*/
|
||||
Datum
|
||||
tle_from_lines(PG_FUNCTION_ARGS)
|
||||
{
|
||||
text *line1_text = PG_GETARG_TEXT_PP(0);
|
||||
text *line2_text = PG_GETARG_TEXT_PP(1);
|
||||
char *line1;
|
||||
char *line2;
|
||||
pg_tle *result;
|
||||
tle_t sat;
|
||||
int parse_rc;
|
||||
|
||||
line1 = text_to_cstring(line1_text);
|
||||
line2 = text_to_cstring(line2_text);
|
||||
|
||||
memset(&sat, 0, sizeof(tle_t));
|
||||
parse_rc = parse_elements(line1, line2, &sat);
|
||||
|
||||
pfree(line1);
|
||||
pfree(line2);
|
||||
|
||||
if (parse_rc < 0)
|
||||
ereport(ERROR,
|
||||
(errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
|
||||
errmsg("invalid TLE: parse_elements returned %d",
|
||||
parse_rc)));
|
||||
|
||||
result = (pg_tle *) palloc0(sizeof(pg_tle));
|
||||
tle_t_to_pg_tle(&sat, result);
|
||||
|
||||
PG_RETURN_POINTER(result);
|
||||
}
|
||||
|
||||
108
test/expected/convenience.out
Normal file
108
test/expected/convenience.out
Normal file
@ -0,0 +1,108 @@
|
||||
-- convenience functions requested by Craft (Astrolock) integration
|
||||
CREATE EXTENSION IF NOT EXISTS pg_orbit;
|
||||
NOTICE: extension "pg_orbit" already exists, skipping
|
||||
-- tle_from_lines: two-argument constructor
|
||||
SELECT tle_norad_id(tle_from_lines(
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002',
|
||||
'2 25544 51.6400 208.5000 0007417 68.5000 291.5000 15.49560000100001'
|
||||
)) AS iss_norad_id;
|
||||
iss_norad_id
|
||||
--------------
|
||||
25544
|
||||
(1 row)
|
||||
|
||||
-- observer_from_geodetic: numeric constructor
|
||||
SELECT observer_from_geodetic(40.0, -105.3, 1655);
|
||||
observer_from_geodetic
|
||||
--------------------------
|
||||
40.0000N 105.3000W 1655m
|
||||
(1 row)
|
||||
|
||||
-- observer_from_geodetic: default altitude = 0
|
||||
SELECT observer_from_geodetic(40.0, -105.3);
|
||||
observer_from_geodetic
|
||||
------------------------
|
||||
40.0000N 105.3000W 0m
|
||||
(1 row)
|
||||
|
||||
-- sgp4_propagate_safe: normal propagation returns result
|
||||
SELECT (sgp4_propagate_safe(
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||
2 25544 51.6400 208.5000 0007417 68.5000 291.5000 15.49560000100001'::tle,
|
||||
'2024-01-01 12:00:00+00'::timestamptz
|
||||
) IS NOT NULL) AS safe_returns_result;
|
||||
safe_returns_result
|
||||
---------------------
|
||||
t
|
||||
(1 row)
|
||||
|
||||
-- sgp4_propagate_safe: diverged orbit returns NULL
|
||||
SELECT sgp4_propagate_safe(
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||
2 25544 51.6400 208.5000 0007417 68.5000 291.5000 15.49560000100001'::tle,
|
||||
'2124-01-01 12:00:00+00'::timestamptz
|
||||
) IS NULL AS safe_null_on_diverge;
|
||||
safe_null_on_diverge
|
||||
----------------------
|
||||
t
|
||||
(1 row)
|
||||
|
||||
-- observe: single-call propagate + topocentric
|
||||
SELECT observe(
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||
2 25544 51.6400 208.5000 0007417 68.5000 291.5000 15.49560000100001'::tle,
|
||||
observer_from_geodetic(40.0, -105.3, 1655),
|
||||
'2024-01-01 12:00:00+00'::timestamptz
|
||||
);
|
||||
observe
|
||||
----------------------------------------
|
||||
(134.5927,-21.2008,5580.842,-2.373498)
|
||||
(1 row)
|
||||
|
||||
-- observe_safe: returns NULL on diverged orbit
|
||||
SELECT observe_safe(
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||
2 25544 51.6400 208.5000 0007417 68.5000 291.5000 15.49560000100001'::tle,
|
||||
'40.0N 105.3W 1655m'::observer,
|
||||
'2124-01-01 12:00:00+00'::timestamptz
|
||||
) IS NULL AS observe_safe_null;
|
||||
observe_safe_null
|
||||
-------------------
|
||||
t
|
||||
(1 row)
|
||||
|
||||
-- observe matches manual eci_to_topocentric pipeline
|
||||
-- (verify observe() produces same result as the two-step approach)
|
||||
WITH propagated AS (
|
||||
SELECT sgp4_propagate(
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||
2 25544 51.6400 208.5000 0007417 68.5000 291.5000 15.49560000100001'::tle,
|
||||
'2024-01-01 12:00:00+00'::timestamptz
|
||||
) AS eci
|
||||
),
|
||||
manual AS (
|
||||
SELECT eci_to_topocentric(
|
||||
eci,
|
||||
'40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00'::timestamptz
|
||||
) AS topo
|
||||
FROM propagated
|
||||
),
|
||||
single_call AS (
|
||||
SELECT observe(
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||
2 25544 51.6400 208.5000 0007417 68.5000 291.5000 15.49560000100001'::tle,
|
||||
'40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00'::timestamptz
|
||||
) AS topo
|
||||
)
|
||||
SELECT
|
||||
topo_azimuth(m.topo) = topo_azimuth(s.topo) AS az_match,
|
||||
topo_elevation(m.topo) = topo_elevation(s.topo) AS el_match,
|
||||
topo_range(m.topo) = topo_range(s.topo) AS range_match
|
||||
FROM manual m, single_call s;
|
||||
az_match | el_match | range_match
|
||||
----------+----------+-------------
|
||||
t | t | t
|
||||
(1 row)
|
||||
|
||||
75
test/sql/convenience.sql
Normal file
75
test/sql/convenience.sql
Normal file
@ -0,0 +1,75 @@
|
||||
-- convenience functions requested by Craft (Astrolock) integration
|
||||
CREATE EXTENSION IF NOT EXISTS pg_orbit;
|
||||
|
||||
-- tle_from_lines: two-argument constructor
|
||||
SELECT tle_norad_id(tle_from_lines(
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002',
|
||||
'2 25544 51.6400 208.5000 0007417 68.5000 291.5000 15.49560000100001'
|
||||
)) AS iss_norad_id;
|
||||
|
||||
-- observer_from_geodetic: numeric constructor
|
||||
SELECT observer_from_geodetic(40.0, -105.3, 1655);
|
||||
|
||||
-- observer_from_geodetic: default altitude = 0
|
||||
SELECT observer_from_geodetic(40.0, -105.3);
|
||||
|
||||
-- sgp4_propagate_safe: normal propagation returns result
|
||||
SELECT (sgp4_propagate_safe(
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||
2 25544 51.6400 208.5000 0007417 68.5000 291.5000 15.49560000100001'::tle,
|
||||
'2024-01-01 12:00:00+00'::timestamptz
|
||||
) IS NOT NULL) AS safe_returns_result;
|
||||
|
||||
-- sgp4_propagate_safe: diverged orbit returns NULL
|
||||
SELECT sgp4_propagate_safe(
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||
2 25544 51.6400 208.5000 0007417 68.5000 291.5000 15.49560000100001'::tle,
|
||||
'2124-01-01 12:00:00+00'::timestamptz
|
||||
) IS NULL AS safe_null_on_diverge;
|
||||
|
||||
-- observe: single-call propagate + topocentric
|
||||
SELECT observe(
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||
2 25544 51.6400 208.5000 0007417 68.5000 291.5000 15.49560000100001'::tle,
|
||||
observer_from_geodetic(40.0, -105.3, 1655),
|
||||
'2024-01-01 12:00:00+00'::timestamptz
|
||||
);
|
||||
|
||||
-- observe_safe: returns NULL on diverged orbit
|
||||
SELECT observe_safe(
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||
2 25544 51.6400 208.5000 0007417 68.5000 291.5000 15.49560000100001'::tle,
|
||||
'40.0N 105.3W 1655m'::observer,
|
||||
'2124-01-01 12:00:00+00'::timestamptz
|
||||
) IS NULL AS observe_safe_null;
|
||||
|
||||
-- observe matches manual eci_to_topocentric pipeline
|
||||
-- (verify observe() produces same result as the two-step approach)
|
||||
WITH propagated AS (
|
||||
SELECT sgp4_propagate(
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||
2 25544 51.6400 208.5000 0007417 68.5000 291.5000 15.49560000100001'::tle,
|
||||
'2024-01-01 12:00:00+00'::timestamptz
|
||||
) AS eci
|
||||
),
|
||||
manual AS (
|
||||
SELECT eci_to_topocentric(
|
||||
eci,
|
||||
'40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00'::timestamptz
|
||||
) AS topo
|
||||
FROM propagated
|
||||
),
|
||||
single_call AS (
|
||||
SELECT observe(
|
||||
'1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
|
||||
2 25544 51.6400 208.5000 0007417 68.5000 291.5000 15.49560000100001'::tle,
|
||||
'40.0N 105.3W 1655m'::observer,
|
||||
'2024-01-01 12:00:00+00'::timestamptz
|
||||
) AS topo
|
||||
)
|
||||
SELECT
|
||||
topo_azimuth(m.topo) = topo_azimuth(s.topo) AS az_match,
|
||||
topo_elevation(m.topo) = topo_elevation(s.topo) AS el_match,
|
||||
topo_range(m.topo) = topo_range(s.topo) AS range_match
|
||||
FROM manual m, single_call s;
|
||||
Loading…
x
Reference in New Issue
Block a user