Tracking the International Space Station
My project this past weekend was utilizing an old RaspberryPi to build a simple tracker for the International Space Station that shows information about upcoming and current passes, as well as lighting up some LEDs whenever the ISS is above the horizon. I think it turned out pretty well, and was a fun excuse to play with some of the GPIO capabilities of the RaspberryPi.
As I previously posted, LEGO has recently released a model of the ISS, which arrived at my place the end of last week. Over all it’s a nice display model and was a nice couple hours of building fun. While working on it, I thought it would be fun to utilize an old, original RaspberryPi 1 Model B to construct a tracker display so I could easily see when the ISS is above the horizon (but not necessarily visible in the sky).
First, I took a breadboard and setup a row of LEDs (with resisters!) that I could turn on and off via one of the GPIO pins on the RaspberryPi. There’s lots of easy examples of turning LEDs on and off online, and the RPi.GPIO
python module makes programmatic control easy.
Next, for a display, I found a SSD1306-based OLED on Amazon for seven bucks. With a resolution of 128x64 pixels, it’s got 16 rows of yellow on top, and 48 rows of blue underneath which provides a nice visual separation when displaying information. Adafruit has a nice python library that makes interfacing with the display really easy. The refresh rate isn’t more than a few hertz, but that’s plenty fast for displaying updates on the position of the ISS.
Lastly, for computing when the ISS would be above the horizon, there’s the skyfield python library which “computes positions for the stars, planets, and satellites in orbit around the Earth” that I used for the heavy lifting of doing the math. There are very nice examples of use, which was enough to get me started on my own code to figure out when ISS passes would occur, and as they do to compute additional statistics to display. Installing on a RaspberryPi was just a simple pip install
away, although I did have to manually install some underlying libraries that numpy
linked to via apt
.
And, here’s the end result:
For those who are interested, here’s the python code I wrote to power my RaspberryPi:
#!/usr/bin/env python3
### Written by Mathias Gibbens and licensed under the GNU GPLv3.
import adafruit_ssd1306
import busio
import datetime
import time
from board import SCL, SDA
from PIL import Image, ImageDraw, ImageFont
import RPi.GPIO as GPIO
from skyfield import units
from skyfield.api import Topos, load
class Track:
### Upstream https://github.com/skyfielders/python-skyfield/pull/281 may implement a lot of this logic
def __init__(self):
# stations.txt downloaded from http://celestrak.com/NORAD/elements/stations.txt
self.satellites = load.tle("stations.txt")
self.iss = self.satellites["ISS (ZARYA)"]
self.qth = Topos("35.120398 N", "106.495599 W")
self.timescale = load.timescale(builtin=True)
def getPosition(self, time=None):
"""
@param time a timescale object
Given a time, return the alt, az, and distance to the ISS
"""
if time is None:
time = self.timescale.now()
diff = self.iss - self.qth
topocentric = diff.at(time)
return topocentric.altaz()
def _resolvePass(self, time):
"""
@param time a timescale object
Given a time when the ISS is above the horizon, find the start and end of that pass
Returns a tuple of the time when next pass begins, its duration, and max altitude
"""
start = None
end = None
maxAlt = -1
# Find the start
for i in range(0, -20*60, -1): # Go back up to 20 minutes
alt, _, _ = self.getPosition(self.timescale.utc(time.year, time.month, time.day, time.hour, time.minute, i))
if alt.degrees > 0:
start = datetime.datetime(time.year, time.month, time.day, time.hour, time.minute)
start += datetime.timedelta(seconds=i)
if alt.degrees > maxAlt:
maxAlt = alt.degrees
else:
break
print(" starts at " + str(start))
# Find the end
for i in range(0, 20*60): # Go forward up to 20 minutes
alt, _, _ = self.getPosition(self.timescale.utc(time.year, time.month, time.day, time.hour, time.minute, i))
if alt.degrees > 0:
end = datetime.datetime(time.year, time.month, time.day, time.hour, time.minute)
end += datetime.timedelta(seconds=i)
if alt.degrees > maxAlt:
maxAlt = alt.degrees
else:
break
print(" ends at " + str(end))
return (start, str(end - start), str(units.Angle(degrees=maxAlt)))
def _findNextPass(self, secondsStep, maxSeconds=7*24*60*60):
"""
@param secondsStep the number of seconds between steps. This controls how coarse of a search to perform.
@param maxSeconds defaults to one week's worth of seconds beform terminating the search.
Returns a timescale that occurs during the next pass
"""
now = self.timescale.now().utc_datetime()
secondsCounter = 60 # Ensure we don't pick up on the currently ending pass
while secondsCounter <= maxSeconds:
alt, _, _ = self.getPosition(self.timescale.utc(now.year, now.month, now.day, now.hour, now.minute, secondsCounter))
if alt.degrees > 0:
return self.timescale.utc(now.year, now.month, now.day, now.hour, now.minute, secondsCounter)
secondsCounter += secondsStep
return None
def getNextPass(self):
"""
Returns a tuple of the time when next pass begins, its duration, and max altitude
"""
print("Computing next pass...")
nextPass = None
# There's a tradeoff here in terms of how quickly we find the next pass versus a chance of skipping a pass that's shorter than the current granularity and possibly selecting a later pass as we contine to search
for granularity in (300, 145, 60):
nextPass = self._findNextPass(granularity)
if nextPass is not None:
break
print("Next pass will be occuring during " + str(nextPass.utc_datetime()))
return self._resolvePass(nextPass.utc_datetime())
class LED:
def __init__(self):
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
GPIO.setup(18, GPIO.OUT)
def on(self):
GPIO.output(18, GPIO.HIGH)
def off(self):
GPIO.output(18, GPIO.LOW)
class Display:
def __init__(self):
self.i2c = busio.I2C(SCL, SDA)
self.display = adafruit_ssd1306.SSD1306_I2C(128, 64, self.i2c)
self.font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 8)
self.image = Image.new("1", (self.display.width, self.display.height))
self.draw = ImageDraw.Draw(self.image)
self.display.fill(0)
self.display.show()
def clear(self):
self.draw.rectangle((0, 0, self.display.width, self.display.height), outline=0, fill=0)
def show(self):
self.display.image(self.image)
self.display.show()
def drawText(self, row, text):
self.draw.text((0, row*8), text, font=self.font, fill=255)
d = Display()
led = LED()
iss = Track()
d.drawText(2, "Computing next pass...")
d.show()
led.off()
_nextPass, _nextDuration, _nextMaxAlt = iss.getNextPass()
_computeNextPass = False
while True:
d.clear()
alt, az, distance = iss.getPosition()
if alt.degrees > 0:
_computeNextPass = True
led.on()
d.drawText(0, "ISS is above the horizon")
d.drawText(1, "Pass duration: " + _nextDuration)
d.drawText(2, "Altitude: " + str(alt))
d.drawText(3, "Azimuth: " + str(az))
d.drawText(4, "Distance: " + str(int(distance.km)) + " km")
d.drawText(7, " " + datetime.datetime.now().isoformat(timespec="seconds"))
d.show()
time.sleep(0.25)
else:
if _computeNextPass:
d.drawText(2, "Computing next pass...")
d.show()
led.off()
_nextPass, _nextDuration, _nextMaxAlt = iss.getNextPass()
_computeNextPass = False
d.clear()
d.drawText(0, "Next ISS pass in " + str(_nextPass - datetime.datetime.now()).split(".")[0])
d.drawText(1, "Pass duration: " + _nextDuration)
d.drawText(2, "Max alt: " + _nextMaxAlt)
d.drawText(7, " " + datetime.datetime.now().isoformat(timespec="seconds"))
d.show()
time.sleep(0.95) # Just a little less than a second due to compute delays