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:
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)