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
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(".")) 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