Building a Ground-Station Pass Schedule with Skyfield
Ansys STK sunset-ready: compute SGP4 pass predictions for your amateur-radio or cubesat ground station using Skyfield.
Why you need a local pass scheduler
If you run a SatNOGS station, an amateur-radio shack tracking AO-91/ISS/NOAA, or an early-stage cubesat ground segment, you need automated pass predictions. Commercial tools (STK, NOVA) are overkill. The free web predictors (AMSAT, Heavens-Above) don't script well. With STK Cloud sunsetting March 2026, the cleanest free alternative is Skyfield — the modern Python successor to PyEphem.
This post walks through a complete ground-station scheduler in about 80 lines of Python: fetch fresh TLEs, compute the next 24 hours of passes for a satellite list, filter by minimum elevation, compute Doppler for your downlink frequency, and emit iCalendar so passes appear in your calendar app.
Install
pip install skyfield requests icalendar
That's it. Skyfield pulls its own ephemerides (DE421, IERS) on first run — about 17 MB cached locally.
Step 1: Fetch current TLEs
TLEs drift — use them within 7 days of epoch. Pull the current element set from Celestrak:
from skyfield.api import load
stations_url = 'https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle'
sats = load.tle_file(stations_url)
by_name = {s.name: s for s in sats}
print(f'Loaded {len(sats)} amateur-radio satellites')
Pick your targets by name (they are case-sensitive in the Celestrak file):
targets = [
by_name['AO-91 (FOX-1B)'],
by_name['ISS (ZARYA)'],
by_name['NOAA 19'],
by_name['GreenCube (IO-117)'],
]
Step 2: Define your ground station
from skyfield.api import wgs84, load
ts = load.timescale()
my_station = wgs84.latlon(40.0150, -105.2705, elevation_m=1624) # Boulder, CO
min_elevation_deg = 10 # horizon mask to skip low passes
Use the elevation_m parameter — it materially affects slant range at low elevation angles.
Step 3: Find rise/culminate/set events
Satellite.find_events() returns a flat list of events (0 = rise, 1 = culminate, 2 = set). Process them in triples:
from datetime import timedelta
t0 = ts.now()
t1 = ts.from_datetime(t0.utc_datetime() + timedelta(hours=24))
for sat in targets:
t, events = sat.find_events(my_station, t0, t1,
altitude_degrees=min_elevation_deg)
# Group into rise/peak/set triples
triples = zip(t[0::3], t[1::3], t[2::3])
for rise, culm, setting in triples:
# Peak elevation
topo = (sat - my_station).at(culm)
alt, az, dist = topo.altaz()
print(f'{sat.name:30s} {rise.utc_iso():20s} peak {alt.degrees:4.1f}° slant {dist.km:4.0f} km')
Ignore satellites already above the horizon at t0 (the event list starts with a culminate/set pair).
Step 4: Doppler shift over a pass
For amateur UHF (435 MHz) a pass can shift your carrier by ±10 kHz — your receiver must track. Compute instantaneous Doppler from range-rate:
import numpy as np
FREQ_HZ = 435_800_000 # AO-91 downlink
C = 299_792_458.0 # m/s
def doppler_shift(sat, observer, t):
"""Positive when satellite approaching (received frequency is higher)."""
# Numerical range rate from 1s apart
dt = 1.0 # seconds
t_next = ts.from_datetime(t.utc_datetime() + timedelta(seconds=dt))
r0 = (sat - observer).at(t).distance().m
r1 = (sat - observer).at(t_next).distance().m
range_rate = (r1 - r0) / dt # m/s, positive = moving away
return -FREQ_HZ * range_rate / C # Hz, positive = approaching
# Sample across a pass
for pct in [0, 25, 50, 75, 100]:
t_sample = ts.from_datetime(
rise.utc_datetime() + (setting.utc_datetime() - rise.utc_datetime()) * pct / 100
)
d = doppler_shift(sat, my_station, t_sample)
print(f' {pct:3d}% pass: doppler = {d:+8.0f} Hz')
For a typical 6-minute AO-91 overhead pass, you'll see Doppler sweep from about +10 kHz (rise) through 0 (zenith) to −10 kHz (set). Track this on the radio's main VFO so you don't lose signal.
Step 5: Link budget for each pass
Once you have slant range, feed it into the rftools Free-Space Path Loss Calculator or compute inline:
def fspl_db(distance_m, freq_hz):
return 20 * np.log10(4 * np.pi * distance_m * freq_hz / C)
# At zenith of an AO-91 pass (~800 km slant at 40° elevation from Boulder)
fspl = fspl_db(800_000, FREQ_HZ)
print(f'FSPL = {fspl:.1f} dB') # ~143 dB
For a complete Monte Carlo link budget with ITU-R propagation models, use the Satellite Link Budget Analyzer and share the scenario URL with your station logbook.
Step 6: Emit iCalendar so passes appear in your calendar
This is where the scheduler becomes useful for humans:
from icalendar import Calendar, Event
from datetime import datetime
import pytz
cal = Calendar()
cal.add('prodid', '-//rftools ground station//')
cal.add('version', '2.0')
for sat in targets:
t, events = sat.find_events(my_station, t0, t1, altitude_degrees=10)
triples = zip(t[0::3], t[1::3], t[2::3])
for rise, culm, setting in triples:
topo = (sat - my_station).at(culm)
alt, az, _ = topo.altaz()
ev = Event()
ev.add('summary', f'{sat.name} (peak {alt.degrees:.0f}°)')
ev.add('dtstart', rise.utc_datetime())
ev.add('dtend', setting.utc_datetime())
ev.add('description', f'Culminate at {culm.utc_iso()}, AZ {az.degrees:.0f}°')
cal.add_component(ev)
with open('passes.ics', 'wb') as f:
f.write(cal.to_ical())
print('Wrote passes.ics — import into Google Calendar / Apple Calendar / Outlook')
Import passes.ics into Apple Calendar / Google Calendar / Outlook and you get push notifications 5 minutes before every pass.
Step 7: Run it on cron
Drop the script in ~/bin/station-passes.py and cron it once a day:
# Refresh pass schedule nightly at 03:00 local
0 3 * * * /usr/bin/python3 ~/bin/station-passes.py > ~/passes.ics 2>> ~/station.log
Pair it with curl --upload-file to a webdav calendar and your phone sees every pass automatically.
Replacing STK Cloud, piece by piece
For amateur / cubesat / small commercial operations, the combined toolkit replaces STK Cloud's daily-driver features:
| STK Cloud feature | Free replacement |
|---|---|
| Pass prediction | Skyfield |
| Link budget | rftools Satellite Link Budget Analyzer |
| Doppler / range curves | Skyfield range-rate |
| Elevation masks + terrain | Skyfield + local DEM (SRTM) |
| 3D visualization | Cesium.js |
| Conjunction screening | SOCRATES |
For mission-design trade studies or institutional programs, you'll still want desktop STK or NASA GMAT. For everything short of that — including AMSAT design reviews — the Skyfield + rftools stack is complete.
Further reading
- Skyfield documentation
- Celestrak TLE catalogs
- rftools Satellite Pass Predictor — hosted version of this scheduler for the most common amateur satellites
- Migrating from STK Cloud: Free Alternatives — the companion post on replacing the full STK workflow
- Sizing a UHF CubeSat Link Budget — end-to-end worked example