# 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