Calibration flights for VOR and ILS systems

What is this plane doing?

This was my first reaction after hitting on the following trajectory during an analysis of approaches at Toulouse airport. After exchanges with ATC people, I learned that these trajectories are flown by small aircraft working at calibrating landing assistance systems, including ILS and VOR.

These trajectories mostly consist of many low passes over an airport and large circles or arcs of circle. A small sample of such trajectories is included in, and the following snippet of code will let you explore those before an attempt of explanation.

Select a different area/airport:

You may click on trajectories for more information.

A short introduction to radio-navigation

A number of navigational systems emerged in the second half of the 20th century, mainly based on VHF communications. Some of them were designed for surveillance, like Secondary surveillance radars; other systems also came to assist pilot in navigation and landing.

VOR (VHF Omnidirectional Range) ground stations send an omnidirectional master signal (on a predefined frequency determined for each station) and a second highly directional signal. Aircraft measure the phase difference between the two signals, which corresponds to the bearing from the station to the aircraft. 1

Historically, airways were laid out between VORs, which stand at the intersections between those routes. Advances in GNSS (understand GPS) make these stations less necessary.

VOR stations often host a DME (Distance Measuring Equipment). The principle is similar to radar ranging, except the roles of the aircraft and of the ground station are reversed. The aircraft sends a signal to the DME; the DME repeats the same signal 50 μs after reception. When the aircraft receives a copy of the sent messages, it measures the time of travel to the DME, subtracts 50 μs and divides the results by 2: speed of light gives an estimation of the distance between the aircraft to the ground station.

ILS (Instrument Landing Systems) consists of two guidance systems: a lateral one (the LOC, for localizer) and a vertical one (the GS, for glide slope, also glide path). The localizer usually consists of several pairs of directional antennas placed beyond the departure end of the runway. 2

Local authorities define very strict thresholds for accuracy: internal monitoring shall switch off the system if the accuracy of the signal is not appropriate. All radio-navigation beacons (including VOR, DME and ILS) are checked periodically by specially equipped aircraft. In particular, the VOR test consists of flying around the beacon in circles at defined distances and along several radials.


Decoding VOR signals can be a fun exercice for the amateur software radio developper. (link)


Check for them next time you drive around an airport!

A basic analysis of VOR calibration trajectories

We can have a look at the first trajectory in the calibration dataset. The aircraft takes off from Ajaccio airport before flying concentric circles and radials. There must be a VOR around, we can search in the navaid database:

# see if any issue on import
from import ajaccio
from import navaids

navaids.extent(ajaccio).query('type == "VOR"')
name type latitude longitude altitude frequency description
126858 AJO VOR 41.770528 8.774667 2142.0 114.8 AJACCIO VOR-DME
127828 FGI VOR 41.502194 9.083417 87.0 116.7 FIGARI VOR-DME

Next step is to compute for each point the distance and bearing from the VOR to each point of the trajectory. The parts of the trajectory that are of interest are the ones with little to no variation in the distance (circles) and in the bearing (radials) to the VOR.

vor = navaids.extent(ajaccio)['AJO']

ajaccio = (
    ajaccio.distance(vor)  # add a distance column (in nm) w.r.t the VOR
    .bearing(vor)  # add a bearing column w.r.t the VOR
        distance_diff=lambda df: df.distance.diff().abs(),  # large circles
        bearing_diff=lambda df: df.bearing.diff().abs(),  # long radials

We can write a simple .query() followed by a .split() method to select all segments with a constant bearing with respect to the selected VOR.

for segment in ajaccio.query('bearing_diff < .01').split('1T'):
    if segment.longer_than('5 minutes'):

# 0 days 00:05:05
# 0 days 00:05:10
# 0 days 00:17:20
# 0 days 00:22:35
# 0 days 00:05:20
# 0 days 00:09:40
# 0 days 00:08:15

We have all we need to enhance the interesting parts of the trajectory now:

import matplotlib.pyplot as plt
import pandas as pd

from import Lambert93
from traffic.drawing import countries
from import airports

point_params = dict(zorder=5, text_kw=dict(fontname="Ubuntu", fontsize=15))
box_params = dict(boxstyle="round", facecolor="lightpink", alpha=.7, zorder=5)


    fig, ax = plt.subplots(subplot_kw=dict(projection=Lambert93()))


    airports["LFKJ"].point.plot(ax, marker="^", **point_params)
    shift_vor = dict(units="dots", x=20, y=10)
    vor.plot(ax, marker="h", shift=shift_vor, **point_params)

    # background with the full trajectory
    ajaccio.plot(ax, color="#aaaaaa", linestyle="--")

    # plot large circles in red
    for segment in ajaccio.query("distance_diff < .02").split("1 minute"):
        # only print the segment if it is long enough
        if segment.longer_than("3 minutes"):
            segment.plot(ax, color="crimson")
            distance_vor =

            # an annotation with the radius of the circle
                ax, alpha=0,  # We don't need the point, only the text
                text_kw=dict(s=f"{distance_vor:.1f} nm", bbox=box_params)

    for segment in ajaccio.query("bearing_diff < .01").split("1 minute"):
        # only print the segment if it is long enough
        if segment.longer_than("3 minutes"):
            segment.plot(ax, color="forestgreen")

    ax.set_extent((7.6, 9.9, 41.3, 43.3))
Situational map

The following map displays the result of a similar processing on the other VOR calibration trajectories from the sample dataset. 3

Select a different VOR:

Time, distance and bearing thresholds may need further ajustments for a proper picture. Note the kiruna14 seems to circle around a position that is not referenced in the database. Any help or insight welcome!

Equipped aircraft for beacon calibration

This list only contains the equipped aircraft for the calibration in the sample dataset. Apart from F-HNAV, registration numbers were found on social networks. Two of the aircraft registrations were not in the provided database at the time of the writing, so we added them manually.

from import traffic as calibration
from import aircraft

# aircraft not in junzis database
other_aircraft = {"4076f1": "G-TACN (DA62)", "750093": "9M-FCL (LJ60)"}

    calibration.groupby(["flight_id"], as_index=False)
    .agg({"timestamp": "min", "icao24": "first"})
        registration=lambda df: df.icao24.apply(
            lambda x: f"{aircraft[x].regid.item()} ({aircraft[x].mdl.item()})"
            if aircraft[x].shape[0] > 0
            else other_aircraft.get(x, None)
        flight_id=lambda df: df.agg(
            # not the most efficient way but quite readable
            lambda x: f"{x.flight_id} ({x.timestamp:%Y-%M-%d})", axis=1
    .sort_values(["registration", "timestamp"])
    .groupby(["registration", "icao24"])
    .apply(lambda df: ", ".join(df.flight_id))
    .pipe(lambda series: pd.DataFrame({"flights": series}))
registration icao24
9M-FCL (LJ60) 750093 kota_kinabalu (2017-03-08)
C-GFIO (CRJ2) c052bb vancouver (2018-10-06)
C-GNVC (CRJ2) c06921 montreal (2018-12-11)
D-CFMD (B350) 3cce6f munich (2019-03-04), vienna (2018-11-20)
F-HNAV (BE20) 39b415 ajaccio (2018-01-12), monastir (2018-11-21), toulouse (2017-06-16), ...
G-GBAS (DA62) 4070f4 london_heathrow (2018-01-12), lisbon (2018-11-13), funchal (2018-11-23)
G-TACN (DA62) 4076f1 cardiff (2019-02-15), london_gatwick (2019-02-28)
SE-LKY (BE20) 4ab179 bornholm (2018-11-26), kiruna (2019-01-30)
VH-FIZ (B350) 7c1a89 noumea (2017-11-05), perth (2019-01-22)
YS-111-N (BE20) 0b206f guatemala (2018-03-26), kingston (2018-06-26)