Quantitative Assessments of Runway Excursion Precursors using Mode S data
-------------------------------------------------------------------------
Xavier Olive, Pierre Bieber
| *This notebook comes with the paper published at ICRAT 2018.*
| Details are presented in the paper. The following code is provided for
reproducibility concerns.
Data preparation
~~~~~~~~~~~~~~~~
The dataset consists of one month of trajectories around Toulouse
airport. The first step is to isolate trajectories landing. The
following code is a suggestion to select such trajectories.
.. code:: python
from tqdm.autonotebook import tqdm
from typing import List
from traffic.core import Traffic, Flight
february = Traffic.from_file("data/lfbo_february.pkl.gz")
cumul: List[Flight] = []
# The first step is to assign a specific id to all trajectories
for item in tqdm(february.assign_id()):
flight = (
item
# remove samples with no positional data
.query("latitude == latitude")
# default median filters
.filter()
)
# remove trajectories with not enough samples
if len(item) < 100:
continue
# Keep flights landing at LFBO
guess = flight.guess_landing_airport()
if guess.airport.icao != "LFBO" or guess.distance > 6000:
continue
cumul.append(flight)
t_landing = Traffic.from_flights(cumul)
# backup
# t_landing.to_pickle("data/february_landing.pkl")
Since we want to focus on aircraft landing on runway 32, we need to
apply a bit more filtering.
The library offers a tool trying to guess the runway on which an
aircraft lands (only on major airports). The heuristics must be
perfectible: warnings are raised when the assigned runway looks dodgy.
We remove by hand trajectories with a raised warning (this could be
automated in a better way…)
.. code:: python
cumul: List[Flight] = []
for item in tqdm(t_landing):
if item.icao24 in ["38413a", "380efa", "3801da", "398588"]:
# aircraft with buggy data
continue
flight = item.query(
# vertical_rate being NaN is often a sign of buggy data
"~onground and vertical_rate == vertical_rate"
)
# extra sanity checks to take away invalid trajectories
if len(flight) < 30 or flight.data.vertical_rate.mean() > -2.5:
continue
# only keep trajectories landing on runway 32
runway = flight.guess_landing_runway()
if runway.name.startswith("32"):
cumul.append(
flight.between(runway.point.start, runway.point.stop)
)
# We remove the following id because the runway detection seems to have failed
t_32 = Traffic.from_flights(
f for f in final if f.flight_id !='AIB103_1754'
)
# backup
# t_32.to_pickle("data/february_landing_32.pkl")
t_32
.. parsed-literal::
WARNING:root:(AF118VA_2073) Candidate runway 14L is not consistent with average track 323.0394445508367.
WARNING:root:(AIB103_1754) Candidate runway 32L is not consistent with average track 142.5683970215894.
WARNING:root:(AIB1776_555) Candidate runway 14L is not consistent with average track 180.0.
[...]
.. raw:: html
Traffic with 1361 identifiers
|
count |
flight_id |
|
AAF733P_3907 |
598 |
DLH57A_353 |
598 |
DLH53U_4609 |
598 |
DLH53U_4660 |
598 |
DLH53U_4708 |
598 |
DLH53U_4709 |
598 |
DLH57A_320 |
598 |
DLH57A_323 |
598 |
DLH57A_333 |
598 |
DLH57A_336 |
598 |
We end up with a dataset of 1361 trajectories landing at Toulouse
airport in February 2017 on QFU32.
However, trimming trajectories to final approaches requires a bit more
work. We use here navigational beacons in the official procedures. Only
main navaids are provided by the data source embedded in the library: we
add the coordinates manually and automate some plotting for following
figures.
.. code:: python
from traffic.core.mixins import PointMixin
class Point(PointMixin):
"""This mixin provides the interface to plot the elements on maps."""
def __init__(self, lat, lon, name):
self.latitude = lat
self.longitude = lon
self.name = name
# Coordinates for key positions for final approach in LFBO
procedure_points = {
"BO310": Point(lat=43.787917, lon=1.200389, name="BO310"),
"BO410": Point(lat=43.465222, lon=1.536195, name="BO410"),
"BO510": Point(lat=43.794389, lon=1.188889, name="BO510"),
"BO610": Point(lat=43.468472, lon=1.528195, name="BO610"),
"14L": Point(lat=43.6374315, lon=1.3575536, name="14L"),
"14R": Point(lat=43.6446126, lon=1.3454186, name="14R"),
"32L": Point(lat=43.6185805, lon=1.3725227, name="32L"),
"32R": Point(lat=43.6156582, lon=1.3802184, name="32R"),
}
def params(point_id):
left_side = point_id in ["BO610", "32L"]
return dict(
shift=dict(units="dots", x=-15 if left_side else 15),
text_kw=dict(
s=point_id,
horizontalalignment="right" if left_side else "left",
bbox=dict(facecolor="sandybrown", alpha=0.5, boxstyle="round"),
),
)
def plot_points(ax):
for point_id in ["BO610", "BO410", "32L", "32R"]:
value = procedure_points[point_id]
value.plot(ax, s=7, zorder=2, **(params(point_id)))
With the following map, we can position trajectories landing on QFU32 with
respect to the above mentioned navigational beacons.
.. code:: python
from cartopy.crs import EuroPP
from traffic.data import airports
with plt.style.context('traffic'):
fig, ax = plt.subplots(
subplot_kw=dict(projection=EuroPP())
)
airports['LFBO'].plot(ax)
plot_points(ax)
t_32.plot(ax, alpha=.3, zorder=-2)
ax.spines['geo'].set_visible(False)
ax.background_patch.set_visible(False)
.. image:: images/runway_32.png
:scale: 80 %
:align: center
First visual check: so far so good! Some trajectories seem to have their
positions quite drifted.
In order to select the final approach, we trim the trajectories between
their closest timestamp near one of the BOx10 beacon, and near one of
the two runway thresholds. We filter trajectories passing too far away
from this beacons in order to get rid of trajectories seeming to have a
wrong estimation of their position (probable drifting of the inertial
navigation system). We eliminate a few more trajectories but consider it
still acceptable for a statistical analysis.
.. code:: python
from typing import Any, Dict, List
import pandas as pd
from pitot import geodesy
cumul: List[Dict[str, Any]] = []
for flight in tqdm(t_32):
bo = flight.closest_point([proc["BO610"], proc["BO410"]])
rw = flight.closest_point([proc["32L"], proc["32R"]])
cumul.append(
dict(
flight_id=flight.flight_id,
# estimation of roll-out point (closest to BOx10)
bo=bo.name,
bo_ts=bo.point.timestamp,
bo_d=bo.distance,
# time at runway threshold
rw=rw.name,
rw_ts=rw.point.timestamp,
rw_d=rw.distance,
)
)
analysis = pd.DataFrame.from_records(cumul)
# backup
# analysis.to_pickle("analysis.pkl")
t_final = Traffic.from_flights(
t_32[line.flight_id]
# trim the trajectory to final approach
.between(line.bo_ts, line.rw_ts)
# add one column for distance to the proper runway threshold
.assign(
distance=lambda df: geodesy.distance(
df.latitude.values,
df.longitude.values,
df.shape[0] * [procedure_points[line.rw].latitude],
df.shape[0] * [procedure_points[line.rw].longitude],
)
)
# this last filtering removes flight with data which is erroneous or
# irrelevant to our current case study.
for _, line in analysis.query("rw_d < 400 and bo_d < 5000").iterrows()
)
# backup
# t_final.to_pickle("data/february_landing_32_final.pkl")
t_final
.. raw:: html
Traffic with 1309 identifiers
|
count |
flight_id |
|
BGA191A_2873 |
374 |
BGA241B_2939 |
371 |
BGA251B_2940 |
349 |
HOP41FK_4016 |
348 |
GAF612_4783 |
347 |
HOP17VJ_4101 |
347 |
HOP11VJ_4053 |
342 |
N721EE_7072 |
341 |
AIB07EO_1779 |
337 |
BGA121D_3023 |
336 |
.. code:: python
import matplotlib.pyplot as plt
from cartopy.crs import EuroPP
from traffic.data import airports
from traffic.visualize import rivers
with plt.style.context('traffic'):
fig, ax = plt.subplots(
subplot_kw=dict(projection=EuroPP())
)
airports['LFBO'].plot(ax)
plot_points(ax)
t_final.plot(ax, alpha=.3,)
t_final.query('distance < 8 * 1852').plot(ax, color='crimson', alpha=.3,)
ax.spines['geo'].set_visible(False)
ax.background_patch.set_visible(False)
.. image:: images/runway_final.png
:scale: 80 %
:align: center
Visual check: in the paper we consider the final 8 nautical miles (in
red).
For the following, the idea is to consider all track angle signals and
to analyse their modes of variation. Here is the full dataset plotted.
.. code:: python
with plt.style.context('traffic'):
fig, ax = plt.subplots(figsize=(10, 7))
ax.invert_xaxis()
ax.set_xlabel("Distance to runway threshold (in nm)", labelpad=10, fontsize=15)
ax.set_ylabel("Track angle variation (in degrees)", labelpad=10, fontsize=15)
for flight in t_final.query('distance < 8*1852'):
ax.plot(
flight.data.distance/1852,
flight.data.heading,
color='#aaaaaa',
alpha=.5,
linewidth=.5
)
.. image:: images/runway_track.png
:scale: 80 %
:align: center
Functional Principal Component Analysis
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
We resample all trajectories to 50 points and fit a (functional) PCA on
the dataset. The following plot displays some modes of variation around
the average signal.
.. code:: python
import numpy as np
from sklearn.decomposition import PCA
# Prepare a dataset of track angles on final approach
X = np.vstack(
[
# for this demonstration we take 50 samples on final approach
flight.resample(50).data.track
for flight in t_final.query("distance < 8*1852")
]
)
# keep track of the identifier for each trajectory
flight_ids = list(flight.flight_id for flight in t_final)
pca = PCA()
X_t = pca.fit_transform(X)
.. code:: python
with plt.style.context("traffic"):
fig, ax = plt.subplots(2, 1, figsize=(10, 10), sharex=True)
xlim = np.linspace(8, 0, 50)
m_theme = dict(linestyle='solid', color='#ff7f0e')
v_theme = dict(linestyle="--", color="#1f77b4")
for i, a in enumerate(ax):
var_i = np.sqrt(pca.explained_variance_[i + 1])
theta_i = pca.components_[i + 1]
a.plot(xlim, pca.mean_, **m_theme,
label="Average track angle")
a.plot(xlim, pca.mean_ + var_i * theta_i, **v_theme,
label=f"Variation along component {i+1}")
a.plot(xlim, pca.mean_ - var_i * theta_i, **v_theme)
a.legend()
a.invert_xaxis()
fig.tight_layout()
.. image:: images/runway_modes.png
:scale: 70 %
:align: center
One of the main ideas of the paper was to specifically select
trajectories with strong components along these modes of variation. It
appears they follow a specific pattern of late runway changes.
.. code:: python
second_component = np.abs(X_t[:, 1])
selected_flights = Traffic.from_flights(
t_final[flight_id]
for flight_id, component in zip(flight_ids, second_component)
if component > np.percentile(second_component, 98)
)
.. code:: python
from cartopy.crs import EuroPP
from traffic.data import airports
from traffic.visualize import rivers
with plt.style.context('traffic'):
fig, ax = plt.subplots(
subplot_kw=dict(projection=EuroPP())
)
airports['LFBO'].plot(ax)
plot_points(ax)
selected_flights.plot(ax, color='#aaaaaa', alpha=.3)
selected_flights.query('distance < 8 * 1852').plot(
ax, color='crimson', alpha=.3
)
ax.spines['geo'].set_visible(False)
ax.background_patch.set_visible(False)
.. image:: images/runway_change.png
:scale: 80 %
:align: center