Using GNSS-IR data in Python

This document shows the contents of the GNSS-IR data files distributed by the PSMSL, and how to use them in Python. Here we are using the Pandas data analysis toolbox, and we'll also need to have the matplotlib library available to create the plots.

In [1]:
import pandas as pd

Loading data

We're going to be using data from Newlyn in south-west England here. First, we read in the data file. This is a zip files contains a csv file, and pandas will read the csv file within the zip seamlessly.

We're ignoring the header lines beginning with a # (although these contain valuable metadata, so do consider them later), and telling python to use the time column as an index for the data returned, and parse the date strings into python datetime objects.

In [2]:
data = pd.read_csv('https://psmsl.org/data/gnssir/data/main/10049.zip', comment='#', index_col='time', parse_dates=True)

Let's take a look at what the data looks like by showing the first few rows.

In [3]:
data.head()
Out[3]:
raw_height adjusted_height fitted_tide prn signal azimuth elevation
time
2014-01-01 00:24:30 51.435 52.065 51.953 107 1 250.881 12.907
2014-01-01 00:34:45 51.373 52.363 52.159 219 1 258.202 12.845
2014-01-01 00:34:45 51.415 52.405 52.159 219 5 258.202 12.845
2014-01-01 00:34:45 51.218 52.208 52.159 219 7 258.202 12.845
2014-01-01 02:44:29 54.251 54.878 54.795 2 2 37.505 12.921

Full information on these fields is available on the PSMSL website, but we'll give some details here.

Each row of the data represents the analysis from one pass of a GNSS satellite over the location. We process the reflections off the surface during this pass, and use this to calculate a height - here called raw_height, given in metres.

But there's some complications. Firstly, you'll notice that there's three entries for the timestep '2014-01-01T00:34:45'. This isn't an error - each GNSS satellite transmits on a number of different frequencies (recorded in the 'signal' column), which we process separately. So you can get multiple heights for a single timestep.

Also, as the satellite passes overhead, the sea won't be stationary, usually it'll either be rising or falling with the tide. This causes errors in the raw height, which we've tried to account for in the adjusted_height field - more on that in a minute.

For convenience, we've also included our best estimate of the astronomical tide at the site.

The height fields

Let's take a look at one day of data

In [4]:
day = data.loc['2021-07-11']
day['raw_height'].plot(style='.')
Out[4]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0f96142c18>

That looks a bit scruffy, but you can sort of see the two paths but let's try the adjusted height

In [5]:
day['raw_height'].plot(style='.', legend=True)
day['adjusted_height'].plot(style='.', legend=True)
Out[5]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0f95f116d8>

Much better! We call this adjustment Ḣ - you can investigate it by looking at the difference between the two series. Now let's compare that to the estimates of the tidal cycle.

In [6]:
day['adjusted_height'].plot(marker='.',color='#ff7f0e',linestyle='none', legend=True)
day['fitted_tide'].plot(marker='.',color='#2ca02c',linestyle='none', legend=True)
Out[6]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0f96016240>

Comparing GNSS-IR data with tide gauge data

Next, let's compare this data with some data from a the traditional tide gauge at Newlyn. We'll get the data from IOC Sea Level Monitoring Facility. Note that data from that site is real time data that isn't quality controlled - a better source for data from Newlyn is the British Oceanographic Data Centre, but here I've picked a day when there's no issues with the data.

We need to do a bit of work to convert the tide gauge data into a form we can plot in python. We'll be reading it in using the requests library, and using the standard datetime library to convert the date strings.

In [7]:
import requests
import datetime

url = 'http://ioc-sealevelmonitoring.org/service.php?query=data&code=newl2&timestart=2021-07-11&timestop=2021-07-12&format=json'
r = requests.get(url)
json = r.json()
t = [datetime.datetime.strptime(x['stime'].rstrip(),'%Y-%m-%d %H:%M:%S') for x in json]
h = [x['slevel'] for x in json]
tide_gauge_data = pd.Series(h,index=t)

Now we'll plot the adjusted GNSS-IR against the tide gauge data

In [8]:
day['adjusted_height'].plot(marker='.',linestyle='none',legend=True,label='GNSS-IR')
tide_gauge_data.plot(linestyle='-',legend=True,label='Tide Gauge')
Out[8]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0f95f42588>

The shape looks correct, but the vertical datum is clearly wrong. We've used the direct data from the GNSS to express it as heights above the reference ellipsoid. But with some detective work, we can compare them directly.

In this case, I happen to know that datum is given to Admiralty Chart Datum (ACD), as is the Newlyn data on the main PSMSL site. And we can get this information from the Newlyn datum page

ACD is 3.899m above RLR RLR is 46.034m above the ellipsoid

So adding both these values to the tide gauge data should put it in the same reference frame as the GNSS-IR data.

In [9]:
day['adjusted_height'].plot(marker='.',linestyle='none',legend=True,label='GNSS-IR')
shifted_tide_gauge_data = tide_gauge_data + 3.899 + 46.034
shifted_tide_gauge_data.plot(linestyle='-',legend=True,label='Tide Gauge')
Out[9]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0f9555a358>

Much, much better! There still looks to be a slight offset, most likely due to uncertainties about the exact location of the antenna phase centre.

The header of the data files and the site information page contain the estimate of the ellipsoidal height for the GNSS we've used along with other important information - see the metadata explanation page for further information.

PRN and signal variables

As mentioned above, each of the given heights is from a particular frequency from one satellite passing over the site. We've provided the identifier of the satellite (the PRN number), and the channel analysed (the 'signal' variable).

You can also use the 'prn' field to identify which of the GNSS constellations the satellite belonged to, as they're divided into groups of 100 as follows:

  • 1-100: GPS
  • 101-200: GLONASS
  • 201-300: Galileo
  • 301-400: BeiDou

The constellations and frequencies available at each site will change over time, as it depends of the equipment installed and the information and data format provided by the supplier.

Let's take a look at what's been available from Newlyn for each constellation by counting the number of observations available each day. First, add a 'constellation' variable to the data frame by dividing 'prn' by 100 and rounding down, and split into the different constellations.

In [10]:
data['constellation'] = data['prn'] // 100
gps = data[data['constellation'] == 0]['adjusted_height']
glonass = data[data['constellation'] == 1]['adjusted_height']
galileo = data[data['constellation'] == 2]['adjusted_height']
beidou = data[data['constellation'] == 3]['adjusted_height']

Next, count and plot the number of observations each week for each constellation

In [11]:
gps_count = gps.resample('W').count()
glonass_count = glonass.resample('W').count()
galileo_count = galileo.resample('W').count()
beidou_count = beidou.resample('W').count()

gps_count.plot(label='GPS', legend=True)
glonass_count.plot(label='GLONASS', legend=True)
galileo_count.plot(label='Galileo', legend=True)
beidou_count.plot(label='BeiDou', legend=True)
Out[11]:
<matplotlib.axes._subplots.AxesSubplot at 0x7f0f952511d0>

So originally almost all data comes from GPS, (apart from short campaigns with other constellations, mainly GLONASS), then GLONASS and Galileo are introduced, and finally BeiDou later. This is typical - the number of observations available, and hence frequency of observations, will increase with time.

Azimuth and Elevation

We also provide azimuth and elevation variables - these give the average position of the satellite during the pass overhead when it's producing good data. These are included in case you wish to dig further into differences between reflections off certain parts of the water visible to the GNSS, (e.g. inside and outside a harbour).

As an example, here's a histogram of the elevations at Newlyn - we need to explicitly use the matplotlib plotting library here.

In [12]:
import matplotlib.pyplot as plt
elevation_hist = plt.hist(data['elevation'], 100)

So the mean elevation is usually about 12.5 degrees, although you can see a couple of lower peaks in the plot, suggesting there's regular low elevation passes too.

Let's try a polar histogram of the mean azimuth. We need to convert degrees to radians, and make sure it's plotted with north at the top and increasing azimuths moving clockwise.

In [13]:
import math

fig, ax = plt.subplots(subplot_kw={"projection": "polar"})
# But we want north at the top and to go clockwise
ax.set_theta_offset(math.pi/2)
ax.set_theta_direction(-1)
azimuth_histogram = plt.hist(data['azimuth'] / 180.0 * math.pi,72)

So the largest number of passes occur to the northwest