Skip to content
RFrftools.io
Satellite CommunicationsApril 30, 202610 min read

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

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.

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 featureFree replacement
Pass predictionSkyfield
Link budgetrftools Satellite Link Budget Analyzer
Doppler / range curvesSkyfield range-rate
Elevation masks + terrainSkyfield + local DEM (SRTM)
3D visualizationCesium.js
Conjunction screeningSOCRATES
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

Related Articles