Importing the UK’s Airspaces

We import data on the UK’s ATC regions from the UK NATS and extract the useful information including the shape of each region.

Setup

Modify the URL below to point to the latest version of the UK’s AIP.

dataset_location = 'https://www.aurora.nats.co.uk/htmlAIP/Publications/2020-06-18-AIRAC/html/eAIP/EG-ENR-2.1-en-GB.html#ENR-2.1'
import requests
from datetime import datetime, timezone
import time
import numpy as np
import pandas as pd
import descartes, geopandas
import cartopy
from shapely.geometry import LineString, Point, Polygon, MultiPolygon, base
import pyproj
from shapely.ops import transform, unary_union
import re
import math
import matplotlib.pyplot as plt

from rtree import index # requires spatialindex_devel

import cartopy.crs as ccrs
import cartopy.geodesic as cgeo
import cartopy.io.img_tiles as cimgt
import cartopy.feature as cfeature

from traffic.data import opensky
from traffic.core import Airspace
from traffic.core.airspace import ExtrudedPolygon

import networkx as nx
import holoviews as hv
import hvplot.networkx as hvnx
namestring = r"^(?P<name>.+  )(?P<lateral_limits>Same extent as .+|( ?-?\d{6}(N|S) \d{7}(E|W)( \(.*\))?| ?thence (anti-)?clockwise by the arc of a circle radius \d+(.\d+)? NM centred on -?\d{6}(N|S) \d{7}(E|W) to)+( but excluding .*)?)  ?(Upper limit: (?P<upper_limit>FL\d+|\d+ FT (ALT|SFC)))  ?(Lower limit: (?P<lower_limit>FL\d+|\d+ FT (ALT|SFC)|SFC))  ?(Class: (?P<class>[A-G]))(?P<second>  ?(Upper limit: (?P<upper_limit_2>FL\d+|\d+ FT (ALT|SFC)))  ?(Lower limit: (?P<lower_limit_2>FL\d+|\d+ FT (ALT|SFC)|SFC))  ?(Class: (?P<class_2>[A-G])))?$"
callsignstring = r"^((?P<callsign>[A-Z ]+)  )?((?P<language>English)?(  )?)(?!English)(?P<hours>.*)$"
freqstring = r"^(?P<frequency>\d{3}\.\d{3})(  (?P<purpose>.*))?$"
lateralstring = r"((?P<edge1>Same extent as .+)| ?-?(?P<point>(?P<point_lat>\d{6})(?P<point_ns>(N|S)) (?P<point_long>\d{7})(?P<point_ew>(E|W)))| ?(?P<circle>thence (?P<circle_anti>(anti-)?)clockwise by the arc of a circle radius (?P<circle_radius>\d+(.\d+)?) NM centred on -?(?P<circle_lat>\d{6})(?P<circle_ns>(N|S)) (?P<circle_long>\d{7})(?P<circle_ew>(E|W)) to ?-?((?P<circle_lat2>\d{6})(?P<circle_ns2>(N|S)) (?P<circle_long2>\d{7})(?P<circle_ew2>(E|W))))|(?P<edge2> but excluding the .*))"
verticalstring = r"^((?P<fl>FL(?P<fl_num>\d+))|(?P<ft>(?P<ft_num>\d+) FT (?P<ft_alt_sfc>ALT|SFC))|(?P<sfc>SFC))$"

expression_namestring = re.compile(namestring)
expression_callsignstring = re.compile(callsignstring)
expression_freqstring = re.compile(freqstring)
expression_lateral = re.compile(lateralstring)
expression_vertical = re.compile(verticalstring)

Initial Data Processing

We parse the HTML table using a built-in pandas method and parse each row with a series of regular expressions to extract the data in a useful format.

dfs = pd.read_html(dataset_location, flavor='bs4')

df = dfs[0].fillna(method='ffill')
def process_row(row):
    name_long = row[0]
    unit = row[1]
    callsign_long = row[2]
    freq_long = row[3]
    remarks = row[4]
    name_processed = process_namestring(name_long)
    callsign_processed = process_callsignstring(callsign_long)
    freq_processed = process_freqstring(freq_long)
    return [
        name_processed.get('name'),
        name_processed.get('lateral_limits'),
        name_processed.get('upper_limit'),
        name_processed.get('lower_limit'),
        name_processed.get('class_of_airspace'),
        name_processed.get('upper_limit_2'),
        name_processed.get('lower_limit_2'),
        name_processed.get('class_of_airspace_2'),
        unit,
        callsign_processed.get('callsign'),
        callsign_processed.get('language'),
        callsign_processed.get('hours'),
        freq_processed.get('frequency'),
        freq_processed.get('purpose'),
        remarks
    ]

def process_vertical_limit(string):
    match = expression_vertical.match(string)
    if match is not None:
        if match.group('fl') is not None:
            return int(match.group('fl_num')) * 100
        elif match.group('ft') is not None:
            return int(match.group('ft_num'))
        elif match.group('sfc') is not None:
            return 0
        else:
            raise Exception(string)
    else:
        raise Exception(string)

def process_namestring(string):
    match = expression_namestring.match(string)
    if match is not None:
        ret = dict()
        ret['original'] = match.group(0)
        ret['name'] = match.group('name').strip()
        ret['lateral_limits'] = match.group('lateral_limits')
        ret['upper_limit'] = process_vertical_limit(match.group('upper_limit'))
        ret['lower_limit'] = process_vertical_limit(match.group('lower_limit'))
        ret['class_of_airspace'] = match.group('class')
        ret['second'] = match.group('second') is not None
        if ret['second']: # second upper/lower limit and class
            ret['upper_limit_2'] = process_vertical_limit(match.group('upper_limit_2'))
            ret['lower_limit_2'] = process_vertical_limit(match.group('lower_limit_2'))
            ret['class_of_airspace_2'] = match.group('class_2')
        return ret
    else:
        raise Exception(string)

def process_callsignstring(string):
    match = expression_callsignstring.match(string)
    if match is not None:
        ret = dict()
        ret['original'] = match.group(0)
        ret['callsign'] = match.group('callsign')
        ret['language'] = match.group('language')
        ret['hours'] = match.group('hours')
        return ret
    else:
        raise Exception(string)

def process_freqstring(string):
    match = expression_freqstring.match(string)
    if match is not None:
        ret = dict()
        ret['original'] = match.group(0)
        ret['frequency'] = match.group('frequency')
        ret['purpose'] = match.group('purpose')
        return ret
    else:
        raise Exception(string)


df2 = df.apply(process_row, axis=1, result_type='expand')

columns = [
    'name',
    'lateral_limits',
    'upper_limit',
    'lower_limit',
    'class_of_airspace',
    'upper_limit_2',
    'lower_limit_2',
    'class_of_airspace_2',
    'unit',
    'callsign',
    'language',
    'hours',
    'frequency',
    'purpose',
    'remarks'
]
df2.columns = columns
fir_index = (~df2.name.str.contains("FIR") & ~df2.name.str.contains("UIR")).idxmax()
fir = df2.head(fir_index).copy()
fir.head()
name lateral_limits upper_limit lower_limit class_of_airspace upper_limit_2 lower_limit_2 class_of_airspace_2 unit callsign language hours frequency purpose remarks
0 LONDON FIR 550000N 0050000E -513000N 0020000E -510700N 00... 24500 19500 C 19500.0 0.0 G LONDON CONTROL (SWANWICK) LONDON CONTROL English H24 118.480 None LONDON CONTROL (SWANWICK):Note 1: The Le Touqu...
1 LONDON FIR 550000N 0050000E -513000N 0020000E -510700N 00... 24500 19500 C 19500.0 0.0 G LONDON CONTROL (SWANWICK) LONDON CONTROL English H24 118.825 London TMA outbound radar: See London Terminal... LONDON CONTROL (SWANWICK):Note 1: The Le Touqu...
2 LONDON FIR 550000N 0050000E -513000N 0020000E -510700N 00... 24500 19500 C 19500.0 0.0 G LONDON CONTROL (SWANWICK) LONDON CONTROL English H24 119.780 London TMA outbound radar: See London Terminal... LONDON CONTROL (SWANWICK):Note 1: The Le Touqu...
3 LONDON FIR 550000N 0050000E -513000N 0020000E -510700N 00... 24500 19500 C 19500.0 0.0 G LONDON CONTROL (SWANWICK) LONDON CONTROL English H24 120.180 None LONDON CONTROL (SWANWICK):Note 1: The Le Touqu...
4 LONDON FIR 550000N 0050000E -513000N 0020000E -510700N 00... 24500 19500 C 19500.0 0.0 G LONDON CONTROL (SWANWICK) LONDON CONTROL English H24 120.475 None LONDON CONTROL (SWANWICK):Note 1: The Le Touqu...
zones = df2.tail(-fir_index).copy()
zones.drop(['upper_limit_2', 'lower_limit_2', 'class_of_airspace_2'], axis=1, inplace=True)
zones.head()
name lateral_limits upper_limit lower_limit class_of_airspace unit callsign language hours frequency purpose remarks
105 ABERDEEN CTA 1 572153N 0015835W -572100N 0015802W -572100N 00... 11500 1500 D ABERDEEN/DYCE ABERDEEN APPROACH English 119.055 DOC 55 NM/25,000 FT. The Airspace remains notified even though the ...
106 ABERDEEN CTA 2 571522N 0015428W -570845N 0015019W thence cloc... 11500 1500 D ABERDEEN/DYCE ABERDEEN APPROACH English 119.055 DOC 55 NM/25,000 FT. The Airspace remains notified even though the ...
107 ABERDEEN CTA 3 572100N 0023356W -570015N 0025056W -565433N 00... 11500 3000 D ABERDEEN/DYCE ABERDEEN APPROACH English 119.055 DOC 55 NM/25,000 FT. The Airspace remains notified even though the ...
108 BALDER CTA 590504N 0013916E -581110N 0030955E -575615N 00... 8500 1500 D NORWAY ACC NORWAY CONTROL English 0600 - 2030 Mon-Fri (1 hr earlier in summer) 134.200 None Mandatory carriage of ADS-B equipment.
109 BANBA CTA 520049N 0060720W -514034N 0060027W -512400N 00... 66000 19500 C SHANNON ACC SHANNON CONTROL English H24 131.150 None ATS within the BANBA CTA is delegated to Shann...

Extracting Geographical Data

We take the text representation of each airspace’s geometry and turn it into a geometry object that we can work with more easily. We then use this to produce a geopandas GeoDataFrame.

Note: we are using buffer(0) to fix self-intersecting polygons. This turns “bow tie” shapes into a MultiPolygon.

TODO: fix curved sections, currently using resolution=1 because they don’t quite work properly

def circle(start, end, centre, clockwise, resolution=10): # start, end, centre tuples (lat,long), clockwise bool
    # correct the centre by projecting it onto the perpendicular bisector of start and end (TODO better implementation)
    x1, y1 = start
    x2, y2 = end
    x3, y3 = centre
    # edge cases
    if y2 == y1:
        centre2 = ((x1 + x2) / 2, y3)
    elif x2 == x1:
        centre2 = (x3, (y1 + y2) / 2)
    else:
        m = (y2 - y1) / (x2 - x1)
        a = (((x1 + x2) / (2 * m)) + ((y1 + y2) / 2) + (m * x3) - y3) / (m + (1 / m))
        centre2 = (a, (m * (a - x3)) + y3)

    radius = math.sqrt(pow(start[0] - centre2[0], 2) + pow(start[1] - centre2[1], 2))
    angle1 = math.atan2(start[1] - centre2[1], start[0] - centre2[0])
    angle2 = math.atan2(end[1] - centre2[1], end[0] - centre2[0])

    step = (angle2 - angle1) / resolution
    if step > 0 and not clockwise:
        step = (-1) * ((2 * math.pi) - (angle2 - angle1)) / resolution
    if step < 0 and clockwise:
        step = ((2 * math.pi) + (angle2 - angle1)) / resolution

    ret = list()
    #ret.append(start)
    for i in range(1, resolution):
        angle = angle1 + i * step
        lat = centre2[0] + (radius * math.cos(angle))
        long = centre2[1] + (radius * math.sin(angle))
        ret.append((lat, long))
    ret.append(end)
    return ret

def strings_to_point(lat, ns, long, ew):
    latitude = int(lat) * (1 if ns == 'N' else -1) / 10000
    longitude = int(long) * (1 if ew == 'E' else -1) / 10000
    return (latitude, longitude)
def lateral_to_geography(lateral_limits):
    geography = list()
    for match in expression_lateral.finditer(lateral_limits):
        groups = match.groupdict()
        if groups.get('edge1') is not None:
            print("Edge case: {}".format(groups.get('edge1')))
            return None
        if groups.get('edge2') is not None:
            print("Edge case: {}".format(groups.get('edge2')))
            return None
        if groups.get('point') is not None:
            point = strings_to_point(groups.get('point_lat'),
                                     groups.get('point_ns'),
                                     groups.get('point_long'),
                                     groups.get('point_ew'))
            geography.append(point)
        if groups.get('circle') is not None:
            start = geography[-1]
            end = strings_to_point(groups.get('circle_lat2'),
                                   groups.get('circle_ns2'),
                                   groups.get('circle_long2'),
                                   groups.get('circle_ew2'))
            centre = strings_to_point(groups.get('circle_lat'),
                                      groups.get('circle_ns'),
                                      groups.get('circle_long'),
                                      groups.get('circle_ew'))
            clockwise = len(groups.get('circle_anti')) == 0
            geography.extend(circle(start, end, centre, clockwise, 50))
            #geography.append(end)
        #print(groups)
    return geography

def geography_to_points(geography):
    if geography is None:
        return geopandas.points_from_xy([], [])
    lats, longs = zip(*geography)
    return geopandas.points_from_xy(longs, lats)
def geography_to_airspace(row):
    if row.geometry is not None and len(row.geometry) > 0:
        poly = row.geometry[0]
    else:
        poly = Polygon(geopandas.points_from_xy([], []))
    extruded_poly = ExtrudedPolygon(poly, row.lower_limit / 100, row.upper_limit / 100)
    airspace = Airspace("", elements = [extruded_poly])
    return airspace

#geography_lambda = lambda x: MultiPolygon([Polygon(geography_to_points(lateral_to_geography(x)))])
def geography_lambda(limit):
    poly = Polygon(geography_to_points(lateral_to_geography(limit))).buffer(0)
    if isinstance(poly, Polygon):
        return MultiPolygon([poly])
    else:
        return MultiPolygon(poly)

zones['geometry'] = zones.lateral_limits.apply(geography_lambda)
zones['airspace'] = zones.apply(geography_to_airspace, axis=1)
Edge case:  but excluding the Gatwick CTR.
Edge case:  but excluding the Gatwick CTR.
Edge case:  but excluding the Gatwick CTR.
Edge case:  but excluding the Gatwick CTR.
gdf = geopandas.GeoDataFrame(zones, geometry=zones.geometry)
gdf.set_crs(epsg=4326, inplace=True)
gdf.crs
<Geographic 2D CRS: EPSG:4326>
Name: WGS 84
Axis Info [ellipsoidal]:
- Lat[north]: Geodetic latitude (degree)
- Lon[east]: Geodetic longitude (degree)
Area of Use:
- name: World
- bounds: (-180.0, -90.0, 180.0, 90.0)
Datum: World Geodetic System 1984
- Ellipsoid: WGS 84
- Prime Meridian: Greenwich

Visualising Airspaces

We can now plot the airspaces on a map, and plot the airspaces which intersect a given point.

loc = Point(-0.388092, 51.590865)
v = 10000
gdf_filtered = gdf[(gdf.lower_limit < v) & (gdf.upper_limit > v) & gdf.geometry.contains(loc)]


fig = plt.figure(dpi=300, figsize=(7,7))

imagery = cimgt.Stamen(style="terrain-background")
ax = plt.axes(projection=imagery.crs)

minlon = -11
maxlon = 6
minlat = 48
maxlat = 61.5

ax.set_extent((minlon, maxlon, minlat, maxlat))
ax.add_image(imagery, 6)

ax.add_geometries(gdf.geometry, crs=ccrs.PlateCarree(), facecolor="none", edgecolor="black")

ax.add_geometries(gdf_filtered.geometry, crs=ccrs.PlateCarree(), facecolor="none", edgecolor="red")
ax.scatter(loc.x, loc.y, transform=ccrs.PlateCarree(), marker = "^", edgecolor="black", facecolor="white", s=100, zorder=10, label="Aircraft Location")

ax.legend(loc="upper right").set_zorder(100)
ax.set_aspect('auto')

plt.show()
_images/airspace_data_uk_17_0.png

Export Data

We save the data to a file.

from flight_processing import DataConfig
config = DataConfig.known_dataset("uk")
out_location = config.dataset_location
out_location
'/mnt/cold_data/josh/processing/regions_uk_wkt.json'
gdf_out = gdf.copy()
del gdf_out['airspace']

gdf_out['wkt'] = gdf_out.geometry.apply(lambda g: g.wkt)
#del gdf_out['geometry']

gdf_out.to_file(config.dataset_location, driver="GeoJSON")

del gdf_out
ERROR:fiona._env:/mnt/cold_data/josh/processing/regions_uk_wkt.json: No such file or directory
WARNING:fiona._env:driver GeoJSON does not support creation option ENCODING