Skip to content

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 the same observation pipeline. This page explains how the SkyEngine works, why the pipeline is unified, and how server-side Skyfield and client-side satellite.js divide responsibilities.

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:

  1. 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.

  2. 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.

  3. 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?”

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.

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_topo

The 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.

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.

Craft uses two different SGP4 implementations, running in different contexts, for different reasons.

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.

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:

  1. When a user starts tracking a satellite, the frontend opens a WebSocket connection to /ws/tracking/satellite/{norad_id}
  2. The server computes and pushes authoritative positions at 1 Hz
  3. Between server updates, the frontend interpolates using its local SGP4 computation
  4. If the server and client positions diverge by more than the visual threshold, the client snaps to the server’s position

This architecture trades strict consistency for responsiveness. The globe animation never stutters waiting for a network round-trip, and the server’s 1 Hz corrections keep the visual representation honest.

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.

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.

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.

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.

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").

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) lives at module level and is loaded once when the module is first imported. This means the first request after startup may take a second or two while the ephemeris loads, but subsequent requests are fast.