Mathias' Blog

Occasional musings and technical posts…

11 Feb 2020

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.

ISS pass display

Close up showing information about an occurring ISS pass

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:

ISS model and tracker

LEGO ISS model with tracker and illuminated LEDs

ISS model and tracker (lights off)

LEGO ISS model in the dark with illuminated LEDs

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