Skyfield Pipeline
Every object Craft tracks — whether it is the ISS in low Earth orbit, Jupiter at 600 million kilometers, or Vega fixed against the celestial sphere — flows through a computation pipeline matched to the scale of the query. This page explains how the SkyEngine works, the three computation tiers (pg_orrery for batch satellite queries, Skyfield for single-object tracking, satellite.js for client animation), and why each tier exists.
The unified observation pipeline
Section titled “The unified observation pipeline”Skyfield provides a consistent computation model for any celestial object. The core sequence is:
observer.at(t).observe(target) --> apparent() --> altaz()This three-step process does different work depending on what the target is, but the calling code does not need to care:
-
observe(target)computes the geometric position of the target relative to the observer. For a planet, this means looking up the position from a JPL ephemeris (DE421). For a satellite, it runs the SGP4 propagator on the stored TLE. For a star, it uses the catalog RA/Dec with proper motion corrections. For a comet, it solves the Keplerian orbit from MPC orbital elements. -
apparent()applies corrections for light-travel time and aberration. Light from Jupiter takes about 35 minutes to reach Earth, so the “apparent” position accounts for where Jupiter was when the light left it, not where it is “now.” For nearby objects like satellites (light-travel time under a millisecond), this correction is negligible but still applied for consistency. -
altaz()converts from the equatorial coordinate frame (right ascension and declination) to the observer’s local horizon frame (altitude and azimuth). This is the step that answers the question operators actually need answered: “where do I point my antenna?”
Why one pipeline for everything
Section titled “Why one pipeline for everything”An earlier version of the code had separate computation paths: one function for satellites, another for planets, a third for stars. They diverged over time — the satellite path skipped aberration correction, the planet path used a different observer model, and the star path did not handle proper motion. Bugs in one path did not get fixed in the others.
The SkyEngine class now dispatches to type-specific helper methods (_planet_position, _satellite_position, _star_position, _comet_position, _celestial_position) that each construct the appropriate Skyfield target object, then all feed into the same observe-apparent-altaz sequence. The helper methods differ only in how they build the target:
- Planets, Sun, Moon: Looked up directly from the DE421 ephemeris by name (
planets["mars barycenter"]) - Satellites: Constructed as
EarthSatellite(tle_line1, tle_line2, name, ts)from the stored TLE - Stars and fixed objects: Constructed as
Star(ra_hours=..., dec_degrees=...)from catalog coordinates - Comets: Built from Keplerian orbital elements using
skyfield.data.mpc.comet_orbit(), then placed relative to the Sun
The result is always a TargetPosition with altitude, azimuth, RA, declination, distance, and a timestamp. The rotor control system, the WebSocket tracking feed, and the REST API all consume this same structure regardless of what kind of object was observed.
The observer model
Section titled “The observer model”Every observation is topocentric — computed from a specific point on the Earth’s surface. The SkyEngine builds the observer using Skyfield’s WGS84 model:
observer_topo = wgs84.latlon(latitude, longitude, altitude_m)observer = EARTH + observer_topoThe observer location comes from either the observer_location table in the database (if one is marked active) or from the default settings in the configuration. This means the same API can serve multiple observer locations by updating the active observer record, without restarting the service.
The EARTH + observer_topo construction is Skyfield’s vector addition: the observer’s position is the Earth’s center (from the ephemeris) plus the topocentric offset. When you then call observer.at(t).observe(target), Skyfield computes the vector from this specific point on Earth to the target, accounting for the Earth’s rotation at that instant.
Coordinate frames
Section titled “Coordinate frames”Three coordinate frames appear throughout Craft, and understanding when each is used prevents confusion:
TEME (True Equator, Mean Equinox) — This is what SGP4 outputs natively. The TLE format was designed around TEME, and the SGP4 propagator produces positions in this frame. Skyfield internally converts from TEME to GCRS when it wraps an EarthSatellite, so user code never needs to handle TEME directly.
GCRS (Geocentric Celestial Reference System) — Skyfield’s internal working frame. All observe/apparent computations happen here. GCRS is the modern successor to the older J2000 equatorial frame. The difference between GCRS and J2000 is small (tens of milliarcseconds) but matters for precise astrometry.
Topocentric alt/az — The observer’s local horizon frame, where altitude is degrees above the horizon and azimuth is degrees clockwise from north. This is the frame that antenna rotors understand. The altaz() call at the end of the pipeline produces coordinates in this frame, accounting for the observer’s latitude, longitude, altitude, and the Earth’s rotation angle at the observation time.
Three computation tiers
Section titled “Three computation tiers”Craft uses three different SGP4 implementations, running at different layers, for different reasons.
Database layer: pg_orrery
Section titled “Database layer: pg_orrery”The whats_up() query needs to answer “which of the 12,000+ active satellites are above the horizon right now?” Calling Python’s Skyfield for each satellite would take seconds. Instead, the sky engine delegates this query entirely to PostgreSQL via the pg_orrery extension, which runs the SGP4/SDP4 propagator in C inside the database process:
SELECT s.norad_id, s.name, topo_elevation(t) AS elevation, topo_azimuth(t) AS azimuth, topo_range(t) AS range_kmFROM satellite s, LATERAL observe_safe( tle_from_lines(s.tle_line1, s.tle_line2), observer_from_geodetic(:lat, :lon, :alt), NOW() ) AS tWHERE s.is_active = true AND t IS NOT NULL AND topo_elevation(t) >= :min_altThis single query propagates every active TLE, transforms the result to topocentric coordinates relative to the observer, and filters for satellites above the minimum elevation. The entire operation completes in under 20 milliseconds — fast enough to run on every API request without caching.
The observe_safe() function is the key design choice. The non-safe variant (observe()) raises a PostgreSQL ERROR when propagation fails, which aborts the entire query. The _safe variant returns NULL instead, allowing the query to skip decayed satellites or corrupt TLEs without affecting the other 12,000 results. The sky engine catches ProgrammingError (which fires if pg_orrery is not installed) and falls back gracefully — the API still returns planets, stars, and comets without satellites.
Comets via pg_orrery
Section titled “Comets via pg_orrery”The unified whats_up() query also computes comet positions in the database using pg_orrery’s comet_observe() function. Unlike observe_safe() which takes a TLE, comet_observe() accepts individual Keplerian orbital elements — perihelion distance (q), eccentricity (e), inclination (i), argument of perihelion (w), longitude of the ascending node (node), and the Julian date of perihelion passage (peri_jd). Earth’s heliocentric position is obtained via planet_heliocentric(3, NOW()) with the helio_x(), helio_y(), and helio_z() extractor functions, and passed in as a reference vector.
This replaced an earlier approach that attempted to pass orbital elements through a nonexistent orbital_elements composite type. With comet_observe() taking scalar parameters directly, the unified query now returns satellites, planets, stars, comets, deep sky objects, and Galilean moons in a single database round-trip.
Galilean moons via pg_orrery
Section titled “Galilean moons via pg_orrery”The unified query includes the four Galilean moons (Io, Europa, Ganymede, Callisto) as a planetary_moon target type, computed via pg_orrery’s galilean_observe() function. The query evaluates the moons only when Jupiter is above the horizon, since they are not independently visible otherwise. In the result set, each moon appears at approximately the same elevation as Jupiter (within ~0.1 degrees) with its own distinct azimuth offset. This target type was not previously available through Skyfield alone and is a pg_orrery-native computation.
Server side: Skyfield
Section titled “Server side: Skyfield”Skyfield handles single-satellite tracking, planet/star/comet positions, and any query where the full IAU precession-nutation model matters. Skyfield’s EarthSatellite runs SGP4 and then applies several additional corrections:
- Polar motion (the Earth’s rotational axis wanders by about 10 meters)
- Delta-T correction (the difference between UTC and dynamical time)
- Full IAU 2000/2006 precession-nutation model
- Frame rotation from TEME to GCRS
These corrections matter when you need to point a physical antenna. At UHF frequencies, the beam width of a typical Yagi is a few degrees, and at microwave frequencies it narrows to under a degree. An error of a few tenths of a degree in pointing can mean the difference between receiving a signal and missing it entirely.
The server computes positions on demand (for REST API queries) and at 1 Hz (for WebSocket tracking streams). Pass prediction uses Skyfield’s find_events() method, which searches for rise/culmination/set triplets over a time window.
Client side: satellite.js
Section titled “Client side: satellite.js”The Astro frontend uses satellite.js (a JavaScript port of the SGP4 propagator) for visual animation. The CesiumJS globe renders satellite positions at 60 frames per second, and making 60 HTTP requests per second to the API would be absurd — both for the server load and for the latency.
Instead, the frontend loads TLE data once (fetched from the API) and runs SGP4 locally in the browser. This produces positions accurate to within a few kilometers for well-maintained TLEs, which is more than sufficient for drawing dots on a 3D globe.
The two systems stay loosely synchronized:
- When a user starts tracking a satellite, the frontend opens a WebSocket connection to
/ws/tracking/satellite/{norad_id} - The server computes and pushes authoritative positions at 1 Hz
- Between server updates, the frontend interpolates using its local SGP4 computation
- If the server and client positions diverge by more than the visual threshold, the client snaps to the server’s position
This three-tier architecture matches computation to context. pg_orrery handles the “what’s up?” question where speed over 12,000 TLEs matters more than sub-arcsecond precision. Skyfield handles single-satellite tracking where the full IAU nutation model and polar motion corrections produce the accuracy needed for antenna pointing. satellite.js handles the 60fps visual animation where latency matters more than either accuracy or throughput. The globe animation never stutters waiting for a network round-trip, and the server’s 1 Hz corrections keep the visual representation honest.
TLE management
Section titled “TLE management”TLEs have a shelf life. A TLE describes a satellite’s orbit at a specific epoch, and the SGP4 propagator’s accuracy degrades as you propagate further from that epoch. For most satellites, a TLE more than a few days old produces positions that are off by kilometers.
Ingestion
Section titled “Ingestion”TLEs enter the system through the TLEFetcher service, which downloads satellite data from CelesTrak’s GP (General Perturbations) API. The fetcher supports both 3LE text format and GP JSON format. It processes satellites in named groups:
- Default groups: amateur, visual, stations, weather, noaa, starlink
- Debris groups: fengyun-1c-debris, cosmos-2251-debris, iridium-33-debris, cosmos-1408-debris, analyst objects
For each satellite in a group, the fetcher either updates the existing database record (if the TLE epoch is newer) or creates a new record. It also maintains group membership in the satellite_group table and rebuilds the search_text column for the vectorizer.
On-demand refresh
Section titled “On-demand refresh”A single satellite’s TLE can be refreshed individually via the API (refresh_satellite). This is useful when a specific TLE is stale — for instance, after a maneuver that changes the satellite’s orbit. The fetcher queries CelesTrak by NORAD catalog number and updates the database record.
Staleness
Section titled “Staleness”The tle_epoch column records when the TLE was generated (not when it was fetched). This lets the system warn when a TLE is getting old. In practice, CelesTrak updates TLEs for most active satellites within 24 hours of the source epoch, and active payloads typically have TLEs refreshed multiple times per day. Debris and inactive objects may go weeks between updates.
Ephemeris data
Section titled “Ephemeris data”Skyfield needs two kinds of reference data beyond the TLEs themselves:
Planetary ephemeris (DE421) — A 17 MB binary file from NASA JPL containing the positions of the Sun, Moon, and planets from 1900 to 2050. Skyfield downloads this automatically on first use and caches it in the /data/skyfield directory (mounted as a Docker volume). The DE421 ephemeris is accurate to about 1 meter for the inner planets and a few kilometers for the outer planets.
Timescale data — Skyfield maintains files that track the difference between UTC and dynamical time (delta-T), which changes by about one second per year due to the Earth’s irregular rotation. These files are also cached in the skyfield data directory.
Both are downloaded once and reused across restarts because the data volume (api-data) persists. The Skyfield Loader class handles downloading and caching transparently — the SkyEngine module just points it at the data directory and calls load("de421.bsp").
Background services and the lifespan
Section titled “Background services and the lifespan”The FastAPI application uses a lifespan context manager to start and stop background services. When the API starts, the lifespan handler launches background refresh tasks for space weather, comets, launches, reentry predictions, space events, radiosondes, atmospheric data, and ADS-B aircraft positions. Each fetcher runs on its own configurable interval.
When the API shuts down, the lifespan handler stops each fetcher in reverse order and disposes the database engine. This ensures that background tasks do not outlive the database connection pool.
The SkyEngine itself is stateless — it is instantiated fresh for each request or tracking loop iteration. The shared state (ephemeris, timescale, planet positions) is exposed as module-level attributes (ts, planets, EARTH), but these are now lazily initialized rather than loaded on import. A _LazyAttr proxy class wraps each attribute and transparently triggers initialization on first access, delegating to a _get_skyfield() function that performs the actual download and setup.
This lazy-init design has a practical consequence: when pg_orrery handles all computation (the happy path for whats_up() queries), Skyfield is never initialized and the 17 MB DE421 ephemeris download never happens. Container startup is instant — no ephemeris download delay, no blocking on network I/O during import. Skyfield is only needed when pg_orrery is unavailable (the fallback path) or for specific single-object tracking scenarios where the full IAU precession-nutation model is required.