Identifying Planes Flying Overhead with Python and the OpenSky REST API

One of the many things I enjoy about living in Santa Monica is that it sits directly underneath the Western/Northern approach to LAX. Every 4 minutes, or so, a plane will fly directly overhead, having come across from Malibu, before continuing inland towards Downtown Los Angeles. There, it will execute a big sweeping turn that aligns it with the incoming traffic from the east, which it will zipper into for the final descent westward towards the sea.

LAX Westerly Operations Flow Map

With all this activity happening overhead, I will frequently open up the FlightAware app on my phone to check the details of planes overhead - especially when I notice big ones! LAX is lucky to still have scheduled service on the 747 and A380 from a variety of airlines (Emirates, Asiana, Qantas, Korean, Lufthansa, British Airways, off the top of my head), and it’s extremely cool to spot a one of those giants in the air above Santa Monica and see it’s an A380 from Seoul, completing its twelve hour journey across the Pacific Ocean.

I’ve written before about some of the tinkering I did to build a flight arrivals app for my Tidbyt smart display - given the amount of enjoyment I get from checking FlightAware, I thought that this “tell me about the planes overhead” use case could be another compelling ambient information display.

Before turning this into a Tidbyt app, though, I needed to figure out how to source the data about planes overhead in a way that is easy, sustainable, and informative - that topic is what I’ll be blogging about today!

Capturing Planes Overhead

What is OpenSky?

After finding that most commercial flight tracking APIs are prohibitively expensive for what I am looking to do, I found myself reading about OpenSky. OpenSky is a crowd-sourced, non-profit community, which pools data from volunteer-operated ADS-B receivers around the world to monitor global air traffic. Regulators around the world such as the FAA have required planes to have active ADS-B transponders emitting position and heading information, so that air-traffic controllers can better understand the traffic in their airspace. Hence, the OpenSky network has excellent coverage of almost all commercial and private flight activity happening, especially in a place like Los Angeles.

Querying the OpenSky REST API

The most basic thing to do with OpenSky is to look at the state vectors of aircraft in a defined area - their API, when prompted with a time and bounding box, will return all set of all the aircraft in that space at that time.

OpenSky API Get States

Define Bounding Box

To define my area of interest, I’ve drawn a bounding box centered on Santa Monica, skewed a bit west out to Malibu - as that is a core part of the western approach to LAX. We’ll pass this box into our OpenSky API request to only capture aircraft inside these bounds.

Bounding Box over Santa Monica on a Map

Example Query

After making an OpenSky account (free, ups your daily API calls), it’s easy to call the API, passing the bounding box in the params and OpenSky credentials in the auth tuple.

import requests

params = {
    'lamin': '33.9748',
    'lomin': '-118.7104',
    'lamax': '34.0979',
    'lomax': '-118.3338',
    'extended': 1
}

r = requests.get('https://opensky-network.org/api/states/all', params=params, auth=(USERNAME, PASSWORD))

r.json()

Outputting the retrieved data in JSON format shows us exactly what we were hoping to see! There is currently one plane overhead in our bounding box: SWA3275, better known as Southwest Airlines Flight 3275.

{'time': 1707885494, 'states': [['ac0bfb', 'SWA3275 ', 'United States', 1707885494, 1707885494, -118.4627, 34.0271, 11750.04, False, 214.8, 349.09, 2.93, None, 11772.9, '6713', False, 0, 0]]}

Double-checking with FlightAware’s site, we can validate that this plane flew directly over Santa Monica on its way to Sacramento from San Diego - pretty cool!

Southwest Airlines Flight 3275 Over Santa Monica

Note: API Access Limits

OpenSky’s page on API limits tells us that:

OpenSky users get 4000 API credits per day. For higher request loads please contact OpenSky.

With 1440 minutes in the course of a day, we clearly have plenty of headroom to acquire fresh data every minute. One request every 30 seconds would also stay well under the usage cap. As such, you will only need to worry about API usage if you plan to query this endpoint with <30 second latency.

Enriching the Data

OpenSky State Vector Fields

To help decode all of the other fields we are seeing in the API response, we can return to OpenSky’s documentation and see that the states array contains a set of state vectors, which feature the following datapoints:

OpenSky API State Vector Fields

Helper Functions

To do a bit more processing of the API response, enabling things such as “what cardinal direction is that track?” and “how far away is that plane from my location?”, we need to set up a few helper functions.

Function to Compute Distance Between Two Coordinates

By defining a function which computes the Haversine Formula, we can easily do quick-and-dirty math to know how far apart two coordinates are along the surface of the earth.

from math import sin, cos, sqrt, atan2, radians

# Returns distance between points in miles
def get_haversine_distance(lat1, lng1, lat2, lng2):

  # Approximate radius of earth in km
  R = 6373.0
  
  lat1 = radians(lat1)
  lon1 = radians(lng1)
  lat2 = radians(lat2)
  lon2 = radians(lng2)
  
  dlon = lon2 - lon1
  dlat = lat2 - lat1
  
  a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2
  c = 2 * atan2(sqrt(a), sqrt(1 - a))
  
  distance = R * c
  
  return round(distance / 1.609, 1)

Function to Compute Bearing

To answer the question “what direction is the plane from my current location?” we will define a function that computes the relative bearing between any two coordinates.

from math import cos, radians, sin, atan2, degrees

# Returns the bearing between two coordinates in degrees from the north
def get_bearing(lat1, long1, lat2, long2):
    dLon = (long2 - long1)
    x = cos(radians(lat2)) * sin(radians(dLon))
    y = cos(radians(lat1)) * sin(radians(lat2)) - sin(radians(lat1)) * cos(radians(lat2)) * cos(radians(dLon))
    brng = atan2(x,y)
    brng = degrees(brng)

    return (brng + 360) % 360

Function to Convert Numeric Heading to Compass Direction

This function does the ugly work of translating a numeric track into a human-readable heading:

ie. not 230.8, but SW

def get_heading(value):
  heading = ""
  
  if value < 11.25:
    heading = "N"
  elif value < 33.75:
    heading = "NNE"
  elif value < 56.25:
    heading = "NE"
  elif value < 78.75:
    heading = "ENE"
  elif value < 101.25:
    heading = "E"
  elif value < 123.75:
    heading = "ESE"
  elif value < 146.25:
    heading = "SE"
  elif value < 168.75:
    heading = "SSE"
  elif value < 191.25:
    heading = "S"
  elif value < 213.75:
    heading = "SSW"
  elif value < 236.25:
    heading = "SW"
  elif value < 258.75:
    heading = "WSW"
  elif value < 281.25:
    heading = "W"
  elif value < 303.75:
    heading = "WNW"
  elif value < 326.25:
    heading = "NW"
  elif value < 348.75:
    heading = "NNW"
  elif value >= 348.75:
    heading = "N"
  
  return heading

Script to Process State Vectors in API Response

With our helper functions defined, we can now loop over all of the items in the states array, and process the important bits of data we want to have for each airplane.

import string

YOUR_COORD = [LATITUDE, LONGITUDE]

output = []

for item in r.json()['states']:
  temp = {}
  
  temp['callsign'] = item[1].strip()
  temp['origin_country'] = item[2]
  temp['lng'] = item[5]
  temp['lat'] = item[6]
  temp['dist_from_you'] = get_haversine_distance(YOUR_COORD[0], YOUR_COORD[1], item[6], item[5])
  temp['location_vs_you'] = get_heading(get_bearing(YOUR_COORD[0], YOUR_COORD[1], item[6], item[5]))
  temp['speed'] = round(item[9] * 2.23694)  # converts meters/sec to miles/hour
  temp['track'] = item[10]
  temp['heading'] = get_heading(item[10])
  temp['climb'] = 'ascending' if item[11] > 0.5 else 'descending' if item[11] < -0.5 else 'stable'
  temp['altitude'] = round((item[13] or item[7]) * 3.28)  # converts meters to feet
  temp['category'] = 'H' if item[17] == 6 else 'L' if item[17] == 5 else 'M' if item[17] == 4 else 'S' if item[17] == 4 else '-'
  
  output.append(temp)

This final snippet of code will print our list in ascending order of distance!

for d in sorted(output, key=lambda i: i["dist_from_you"]):
  for k, v in d.items():
    print(k, ': ', v, sep = '')

With it, we can finally get the full sense of what was happening with Southwest Flight 3275: it was heading North at an altitude of 38,615 feet when it passed us, and was only 1.9 miles to the East when it did so!

callsign: SWA3275
origin_country: United States
lng: -118.4627
lat: 34.0271
dist_from_you: 1.9
location_vs_you: E
speed: 480
track: 349.09
heading: N
climb: ascending
altitude: 38615
category: -

Map of Bounding Box, My Location, and The Plane Location

Next Steps

When I have some more time, I plan to take this code and build out a view for my Tidbyt smart display which shows these data points in an appealing snapshot view… I’m excited about having this in the rotation and getting to see the unique traffic from all over the world flowing into LAX!