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.
12 KiB
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 accepts40.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 SAFEon 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:
-- 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.
-- 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:
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:
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:
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:
-- 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:
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):
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:
-- 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)
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:
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:
-
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.
-
Amateur satellite group TLEs -- Our
satellite_grouptable has curated groups ('amateur', 'weather', 'starlink', etc.). We can provide a batch of TLEs for pass prediction testing. -
Edge case TLEs -- Deep space (Vela), high eccentricity (Molniya), recently decayed, epoch-stale. These exercise the SDP4 path and error handling.
-
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
-
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. -
Error on stale TLEs: SGP4 diverges badly for TLEs more than ~30 days old. Any plans for a
tle_max_ageGUC or a warning when propagating stale elements? -
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? Forwhats_upwe'd prefer NULL (skip the bad satellite) over an error (abort the whole query). Asgp4_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
_safevariant of propagation functions that returns NULL on error - Reply with
002-pg-orbit-*.mdwhen ready