<- Back to work

apsis

Shipped

Open-source satellite pass prediction on real orbital data.

apsis is a small open-source backend that answers one operational question: when is the next contact window between a ground station and a satellite, and where does the satellite cross the sky? It ingests two-line elements (TLEs) from CelesTrak, propagates them with SGP4 through skyfield, and returns every pass above the elevation mask of the station for the next 48 hours, each with its sub-satellite ground track as GeoJSON. It runs on FastAPI and PostgreSQL/PostGIS, with scheduled jobs and a transactional outbox built into Postgres itself. There is no message broker anywhere in the system.

ISS (NORAD 25544) passes over two ESA ground stations in Spain, Cebreros and Maspalomas, computed by apsis from real orbital data. Cyan lines are the sub-satellite ground tracks; rings mark culmination. Click a track for the times (AOS to LOS, UTC) and peak elevation of each pass.Static snapshot computed by apsis from real TLEs; passes above a 10-degree elevation mask. Basemap: Natural Earth (public domain).

The problem: ground-segment visibility

A ground station can only talk to a satellite while it sits above the local horizon, and in practice above an elevation mask, because low-elevation signals cross too much atmosphere and terrain. Planning a contact window means propagating the orbit forward, finding when the satellite climbs past the mask (AOS), where it culminates, and when it drops back below (LOS), then projecting the path onto the ground. It is the same family of problem I work on at Telespazio around SIBA and Sentinel/Copernicus. apsis distills it into a public reference with none of the confidential parts.

The thesis: no broker

apsis has two kinds of background work: recurring (refresh TLEs every couple of hours) and reactive (when an orbit changes, recompute its passes). Instead of adding a message broker plus a worker framework, apsis runs both on PostgreSQL: a scheduled_jobs table with a lease for the recurring side, and a transactional outbox (LISTEN/NOTIFY plus SELECT ... FOR UPDATE SKIP LOCKED) that only fires an event once the database write has committed. The dual-write problem disappears because there is no second system to write to.

The geometry: PostGIS

Ground stations are points and ground tracks are linestrings, everything in WGS84 (longitude/latitude, SRID 4326). PostGIS stores and indexes the geometry, and the API serialises each track straight to GeoJSON, which is exactly what the map above renders. Spatial questions (which passes cross this bounding box, which stations sit inside this area) run on a GiST index instead of application code.

When I would not do this

A Postgres-native queue fits a single service that already owns its database and needs reliable, transactional background work at human scale. It stops fitting at very high throughput, with fan-out to many independent consumers, or across languages and services: those are broker problems, and pretending otherwise is how this pattern gets a bad name. The full trade-off is written up as a note.

Architecture

Layered per context: API to use cases to services and repositories to models. The compute core (orbit propagation, pass finding, geometry) is pure, synchronous and side-effect free, and a small run_blocking helper keeps it off the async event loop. Every non-obvious decision is recorded as an ADR in the repository.