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. Full Python walkthrough with TLE fetch, elevation masks, Doppler, and iCalendar export.
Contents
- Why you need a local pass scheduler
- Install
- Step 1: Fetch current TLEs
- Step 2: Define your ground station
- Step 3: Find rise/culminate/set events
- Step 4: Doppler shift over a pass
- Step 5: Link budget for each pass
- Step 6: Emit iCalendar so passes appear in your calendar
- Step 7: Run it on cron
- Replacing STK Cloud, piece by piece
- Further reading
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 |
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
Related Articles
Scripting Satellite Link Budgets with ITU-Rpy (Python Examples)
STK-free link-budget automation: sweep frequency, rain availability, and elevation in pure Python using the ITU-Rpy reference implementation of P.618/P.676/P.840. Companion to the rftools Satellite Link Budget Analyzer.
Apr 30, 2026
Satellite CommunicationsSizing a 9600-baud UHF Downlink for a 3U CubeSat: Full Walkthrough
End-to-end link budget for an amateur-band 3U cubesat: EIRP, ground-station G/T, ITU-R propagation losses, and Monte Carlo availability. Uses the Amateur CubeSat preset.
Apr 29, 2026
Satellite CommunicationsMigrating from STK Cloud: Free Alternatives for Link Budget and Orbit Analysis
Ansys is sunsetting STK Cloud in March 2026. Here are the free open-source replacements for the two things it did best — ITU-R link budgets and orbital pass prediction.
Apr 29, 2026