An existing product called PG Orbit (a mobile PostgreSQL client) creates a naming conflict. pg_orrery — a database orrery built from Keplerian parameters and SQL instead of brass gears. Build system: control file, Makefile, Dockerfile, docker init script. C source: GUC prefix, PG_FUNCTION_INFO_V1 symbol, header guards, ereport prefixes, comments across ~30 files including vendored SGP4. SQL: all 5 install/migration scripts, function name pg_orrery_ephemeris_info. Tests: 9 SQL suites, 8 expected outputs, standalone DE reader test. Documentation: CLAUDE.md, README.md, DESIGN.md, Starlight site infra, 36 MDX pages, OG renderer, logo SVG, docker-compose, agent threads. All 13 regression suites pass. Docs site builds (37 pages).
268 lines
12 KiB
Markdown
268 lines
12 KiB
Markdown
# Message 001
|
|
|
|
| Field | Value |
|
|
|-------|-------|
|
|
| From | craft-api |
|
|
| To | pg-orrery |
|
|
| 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_orrery 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_orrery 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_orrery 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_orrery 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_orrery 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_orrery 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-orrery-*.md` when ready
|