Creating a positioning satellite skyplot using Python

category: gnss
tags: galileo

Introduction

To know one’s coordinates using only radio waves from positioning satellites, positioning signals from at least four satellites are required. This is because four variables at the user’s position are unknown: latitude, longitude, ellipsoid height, and time.

It is preferable to be able to observe as many satellites as possible for positioning. I created a Python code that displays the positions of such positioning satellites in the sky.

Skyplot

For example, the satellite position display app [GNSS View] (https://qzss.go.jp/technical/gnssview/index.html) released by the Cabinet Office, Government of Japan, allows you to see the visible positions of positioning satellites at any location and any time. This is a screenshot of the position of the European Galileo satellites that will be observable in Hiroshima on November 23, 2023 at 23:20:00 UTC (November 24, 08:20:00 Japan time), plotted in GNSS View.

GNSS View Application provided by Cabinet Office, Government of Japan

This diagram shows the direction and elevation angle at which positioning satellites can be seen from your own position, and is called a skyplot.

The open source GNSS-Radar, released by Professor Taro Suzuki of Chiba Institute of Technology, can display the positions of positioning satellites in a browser without an Internet connection. I use GNSS-Radar to find out the locations of positioning satellites.

GNSS-Radar provided by Prof. Taro Suzuki of Chiba Institute of Technology, Japan

As you can see, there are several ways to know the locations of positioning satellites.

On the other hand, grayscale images in PDF format are preferable for creating reports, and you would like to create your own skyplots to highlight specific satellites and show observation conditions on the diagram. Also, to load such diagrams into, say LaTeX, a PDF file containing images in vector format is preferable, rather than a bitmap file such as a PNG or JPG file.

In order to create a skyplot with your own code, you will need to obtain the satellite orbits.

Two line element

Satellite positioning requires ephemeris, which shows the exact orbit of the satellite. On the other hand, to display a sky plot, it is sufficient to know the approximate satellite position, and for this purpose, two line elements (TLE) are often used. This is a two-line text format that shows the orbital inclination angle and other information required to represent the satellite orbit.

Such TLEs can be obtained from CelesTrak. Strictly speaking, satellite orbits may change from moment to moment, but unless orbital corrections are being made, the TLE is not expected to change significantly. The lifespan of an ephemeris is two hours, but once downloaded, the TLE can be used for a while. For example, the TLE of the recently launched Advanced Radar Satellite Daichi-4 is expressed as follows:

ALOS-4 (DAICHI-4)       
1 60182U 24123A   24229.54940804  .00000500  00000+0  73855-4 0  9998
2 60182  97.9215 325.3457 0001395  98.1122  28.2230 14.79473854  6861

Here, we will try to obtain the TLEs of all European Galileo satellites from CelesTrak.

Galileo TLE provided by CelesTrak

Click the document icon in the Latest Data column to display the TLE in your browser, then copy and paste it into a text file. Repeat this process for all Galileo satellites.

Skyplot creation using Python

Next, based on the TLE, we calculate the direction and elevation of the satellite from an arbitrary coordinate point. For coordinate calculation and display, we used Python’s non-standard modules skyfield, numpy, and matplotlib.

pip install skyfield numpy matplotlib

The code will display available satellites in the dictionary satellites1 with a circle, while unavailable satellites in satellites2 will be displayed with a cross. In addition, unavailable satellite numbers will be grayed out. In this example, the available satellites are E03, E07, E08, E13, and E15. E stands for Europe, which is a Galileo satellite. The order of the TLEs is arbitrary. The observation point is Hiroshima (coordinates 34.23N, 132.27E), and the date and time are 23:30:00 UTC on November 22, 2023.

skyplot.py

#! /usr/bin/env python
# -*- coding: utf-8 -*-

FILENAME = 'skyplot'
FONTSIZE = 16

# GAL TLE can be obtained from:
# https://celestrak.org/norad/elements/table.php?GROUP=galileo&FORMAT=tle

from skyfield.api import Topos, load, wgs84
from skyfield.sgp4lib import EarthSatellite
import numpy as np
from matplotlib import pyplot as plt
import datetime

# TLE data
satellites1 = {
"E03": ["1 41860U 16069B   24075.03714384  .00000000  00000+0  00000+0 0  9997",
    "2 41860  55.0417 123.9214 0003428 309.7138  50.3288  1.70474893 45607"],
"E07": ["1 41859U 16069A   24075.76931632 -.00000003  00000+0  00000+0 0  9996",
    "2 41859  55.0449 123.9045 0005163 290.7537  69.2607  1.70474715 45362"],
"E08": ["1 41175U 15079B   24077.45505889 -.00000001  00000+0  00000+0 0  9994",
    "2 41175  55.3797 123.8317 0004223 301.5343  58.4922  1.70474689 51340"],
"E13": ["1 43567U 18060D   24073.22747821 -.00000102  00000+0  00000+0 0  9991",
    "2 43567  57.3051   4.0560 0004572 309.8424  50.1177  1.70475162 35092"],
"E15": ["1 43564U 18060A   24073.15310948 -.00000102  00000+0  00000+0 0  9996",
    "2 43564  57.3028   4.0542 0005044 303.0742  56.8777  1.70475060 35093"],
}
satellites2 = {
"E11": ["1 37846U 11060A   24070.59038482 -.00000094  00000+0  00000+0 0  9990",
    "2 37846  57.1269   4.2009 0004048   0.9151 359.0813  1.70475681 76996"],
"E12": ["1 37847U 11060B   24075.36853573 -.00000108  00000+0  00000+0 0  9993",
    "2 37847  57.1277   4.0716 0005128 340.5417 117.7805  1.70475226 77083"],
"E19": ["1 38857U 12055A   24077.08212499 -.00000003  00000+0  00000+0 0  9991",
    "2 38857  55.3621 124.1355 0005270 267.6966  92.3107  1.70473587 70991"],
"E20": ["1 38858U 12055B   24075.29159274 -.00000001  00000+0  00000+0 0  9992",
    "2 38858  55.3626 124.1864 0002996 263.1608  96.8769  1.70473746 70965"],
"E18": ["1 40128U 14050A   24076.38068143 -.00000063  00000+0  00000+0 0  9995",
    "2 40128  49.7505 306.7902 1607231 147.6212 223.3476  1.85519539 63054"],
"E14": ["1 40129U 14050B   24077.17831918 -.00000057  00000+0  00000+0 0  9995",
    "2 40129  49.7668 305.8335 1605771 148.4561 222.2616  1.85520304 65236"],
"E26": ["1 40544U 15017A   24073.30254099 -.00000102  00000+0  00000+0 0  9991",
    "2 40544  56.9222   4.0745 0004144 291.6220  68.3342  1.70475448 55129"],
"E22": ["1 40545U 15017B   24074.14320105 -.00000105  00000+0  00000+0 0  9990",
    "2 40545  56.9283   4.0665 0003375 281.1733  78.7899  1.70475327 18189"],
"E24": ["1 40889U 15045A   24076.84456596  .00000028  00000+0  00000+0 0  9998",
    "2 40889  55.2656 244.3185 0005887  44.3383 315.6720  1.70473651 52993"],
"E30": ["1 40890U 15045B   24075.30543699  .00000030  00000+0  00000+0 0  9994",
    "2 40890  55.2642 244.3563 0003840  46.2777 313.7129  1.70473690 52990"],
"E09": ["1 41174U 15079A   24077.23534912 -.00000002  00000+0  00000+0 0  9999",
    "2 41174  55.3811 123.8397 0004688 303.6832  56.3397  1.70474783 50768"],
"E02": ["1 41549U 16030A   24076.99092123  .00000028  00000+0  00000+0 0  9995",
    "2 41549  55.4130 244.3111 0003383  18.6658 341.3102  1.70473716 48653"],
"E01": ["1 41550U 16030B   24076.10899448  .00000030  00000+0  00000+0 0  9998",
    "2 41550  55.4111 244.3364 0001211 357.6320   2.3289  1.70473751 48631"],
"E04": ["1 41861U 16069C   24077.75112543 -.00000001  00000+0  00000+0 0  9990",
    "2 41861  55.0457 123.8527 0005491 274.6415  85.3637  1.70473447 45518"],
"E05": ["1 41862U 16069D   24074.96434658  .00000001  00000+0  00000+0 0  9994",
    "2 41862  55.0430 123.9236 0004700 275.5406  84.4789  1.70474973 45591"],
"E21": ["1 43055U 17079A   24076.04030420  .00000030  00000+0  00000+0 0  9994",
    "2 43055  55.3883 244.1605 0000736  22.1381 337.8261  1.70474387 38952"],
"E25": ["1 43056U 17079B   24076.91865947  .00000028  00000+0  00000+0 0  9998",
    "2 43056  55.3883 244.1362 0001720 316.3645  43.5851  1.70474520 38986"],
"E27": ["1 43057U 17079C   24073.03300014  .00000027  00000+0  00000+0 0  9997",
    "2 43057  55.3924 244.2419 0002160 350.7256   9.2233  1.70474360 38903"],
"E31": ["1 43058U 17079D   24076.77253818  .00000029  00000+0  00000+0 0  9993",
    "2 43058  55.3864 244.1366 0001974 300.9948  58.9487  1.70474451 38996"],
"E33": ["1 43565U 18060B   24075.13123991 -.00000108  00000+0  00000+0 0  9993",
    "2 43565  57.3054   4.0028 0003358 282.1792  77.7846  1.70475082 35161"],
"E36": ["1 43566U 18060C   24073.59351374 -.00000103  00000+0  00000+0 0  9995",
    "2 43566  57.3052   4.0464 0004542 314.0609  45.9024  1.70475319 35115"],
"E34": ["1 49809U 21116A   24074.42529437 -.00000106  00000+0  00000+0 0  9990",
    "2 49809  57.2961   3.9185 0001376 281.4869 183.9787  1.70475239 14122"],
"E10": ["1 49810U 21116B   24075.39474642 -.00000108  00000+0  00000+0 0  9998",
    "2 49810  57.2968   3.8907 0001282 308.3744 144.5609  1.70475412 14162"],
}

# Load timescale and observer position
ts = load.timescale()
t = ts.utc(2023, 11, 22, 23, 30, 0)
observer = Topos(latitude_degrees=34.23, longitude_degrees=132.27)

# Initialize satellite objects
satellite_objects1 = {sat: EarthSatellite(satellites1[sat][0], satellites1[sat][1], sat, ts) for sat in satellites1}
satellite_objects2 = {sat: EarthSatellite(satellites2[sat][0], satellites2[sat][1], sat, ts) for sat in satellites2}

# Plotting
fig, ax = plt.subplots(subplot_kw={'projection': 'polar'},
    figsize=(6, 6))
ax.set_theta_zero_location('N')
ax.set_theta_direction(-1)
ax.set_ylim(0, 90)  # Altitude 0° (horizon) to 90° (zenith)

for sat in satellite_objects1:
    difference = satellite_objects1[sat] - observer
    topocentric = difference.at(t)
    alt, az, distance = topocentric.altaz()

    if alt.degrees > 0:  # Only plot if above the horizon
        r = 90 - alt.degrees
        theta = np.radians(az.degrees)
        ax.plot(theta, r, 'o', c='black')
        ax.text(theta, r, f'{sat}', fontsize=FONTSIZE, ha='right', va='bottom')

for sat in satellite_objects2:
    difference = satellite_objects2[sat] - observer
    topocentric = difference.at(t)
    alt, az, distance = topocentric.altaz()

    if alt.degrees > 0:  # Only plot if above the horizon
        r = 90 - alt.degrees
        theta = np.radians(az.degrees)
        ax.plot(theta, r, 'x', c='gray')
        ax.text(theta, r, f'{sat}', fontsize=FONTSIZE, ha='right', va='bottom', c='gray')

ax.set_rmax(90)
ax.set_rticks([0, 30, 60, 90])  # Altitude rings
ax.set_rlabel_position(-22.5)  # Move radial labels
ax.set_rgrids([0, 30, 60, 90], labels=["90", "60", "30", "0"], fontsize=FONTSIZE)
ax.grid(True)
ax.set_thetalim([0, 2*np.pi])
ax.set_thetagrids(np.rad2deg(np.linspace(0, 2*np.pi, 5)[1:]), 
        labels=["E", "S", "W", "N"], fontsize=FONTSIZE)

plt.savefig(FILENAME + '.pdf')
#plt.savefig(FILENAME + '.svg')
#plt.savefig(FILENAME + '.tiff', dpi=600)
#plt.savefig(FILENAME + '.jpg', dpi=600)
#plt.savefig(FILENAME + '.png', transparent=True, dpi=600)

plt.show()

When you run this code with ./skyplot.py, the sky plot will be displayed on the screen and a PDF file of it will be output. By modifying the source code, you can change the output format to SVG, TIFF, JPG, or PNG, change the observation point or observation time continuously, or display various additional information.

Skyplot

Conclusion

I got the 2-line orbital elements TLE from CelesTrak and created a sky plot with my own Python code. With such a code at hand, you can create a sky plot of any satellite at any point, which is useful for various verifications.