Satellite Tracking Dashboard Widget

A satellite tracking widget for my monitoring dashboard project
2 min read

A continuation of my revisit of my monitoring dashboard software. See the original post.

Tracking Map

For the background of the map, I used https://geojson-maps.kyd.au/ to generate a GEOJSON file for the world boundaries. We can easily parse this file and render the Polygon / MultiPolygon entries within, to render a world map at any resolution we choose. This is a similar approach I took when I was hacking on macOS screensavers for satellite positioning in the past

Upon widget startup, we know the size that we’ll need for the background. We pre-render the background, since this doesn’t change and we know all the data beforehand. We then clone the image and draw paths on top of it.

Output

Before we dive in, this’s an example of what this outputs, combined with some other space-centric dashboard widgets: space dashboard

Satellite Positioning

For orbital position calculation, I’m using the Skyfield module, which provides a nice interface for the sgp4 and convenience mechanisms for loading TLEs from Celestrak.

Using Skyfield

This is the core of the satellite positioning code from the dashboard widget. I’ve omitted the dashboard specific code, which can be found here

from skyfield.api import load
from skyfield.iokit import parse_tle_file
from skyfield.api import wgs84

pathname = 'stations.tle'
tle_url = 'https://celestrak.org/NORAD/elements/gp.php?GROUP=stations&FORMAT=tle'

# load the TLE
if not load.exists(pathname) or load.days_old(pathname) > 1:
    print("TLE is stale, downloading new data")
    load.download(tle_url, pathname)

ts = load.timescale()

# Parse the TLE file
tle_file = open(pathname, "rb")
satellites = list(parse_tle_file(tle_file, ts))
for satellite in satellites:
    # propagate using SGP4 and convert to lat/lon
    geocentric = satellite.at(ts.now())
    lat, lon = wgs84.latlon_of(geocentric)

    # print
    print(satellite.name, lat, lon)

Hourly Tracks

Within the module, I do this calculation for ± 30 minutes from the current timestamp. This yields a nice sine-wave of the future/past track for the satellite.

There is one issue - since we’re using a WGS84 projection, if a satellite crosses the international date line, it wraps around to the other end of the screen. So, we need to detect this and correct for it.

I took a statistical approach to doing so. I take the vertical/horizontal differences and then find the median. If anything is over 4x the median, I determine this as a location to break the track as it’s a wrap-around.

# store the track for an hour
hr_track = []

# propagate for each minute
for i in range(0, 60):
    g_pos = satellite.at(
        ts.now() - timedelta(minutes=30) + timedelta(minutes=i)
    )
    clat, clon = wgs84.latlon_of(g_pos)
    screen_coord = self.latlon_to_xy(clat.degrees, clon.degrees)
    hr_track.append(screen_coord)

# find the differences between each point in the track
dx_track = []
dy_track = []
for i in range(1, len(hr_track)):
    dx_track.append(abs(hr_track[i][0] - hr_track[i - 1][0]))
    dy_track.append(abs(hr_track[i][1] - hr_track[i - 1][1]))

# find the median of the differences
median_dx = sorted(dx_track)[len(dx_track) // 2]
median_dy = sorted(dy_track)[len(dy_track) // 2]

out_tracks = []
cur_track = []

# split the track into multiple, where there's a large jump indicating a wrap-around
for i, coord in enumerate(hr_track):
    cur_track.append(coord)

    if i == len(hr_track) - 1:
        pass  # last coord is always added

    # if the difference between the current point and the previous point is > 4* the median, split the track
    elif dx_track[i] > median_dx * 4 or dy_track[i] > median_dy * 4:
        out_tracks.append(cur_track)
        cur_track = []

out_tracks.append(cur_track)

# render using PIL
for track in out_tracks:
    if len(track) > 1:
        draw.line(track, fill=self.track_color)

Subscribe to my Newsletter

Like this post? Subscribe to get notified for future posts like this.

Change Log

  • 8/5/2024 - Initial Revision

Found a typo or technical problem? file an issue!