Browse Source

Merge branch 'feature/setup' into 'develop'

Feature/setup

See merge request nav/aman/aman-sys!1
Sven Czarnian 3 years ago
parent
commit
d07751cf77

+ 8 - 0
.gitignore

@@ -0,0 +1,8 @@
+Backend.ini
+main.py
+.vscode/
+*_pb2.py
+__pycache__
+*.egg-info
+build/
+dist/

+ 4 - 0
.gitmodules

@@ -0,0 +1,4 @@
+[submodule "src/protobuf"]
+	path = src/protobuf
+	url = git@git.vatsim-germany.org:nav/aman-com.git
+	branch = feature/protobuf

+ 28 - 3
README.md

@@ -1,7 +1,23 @@
-# aman-sys
+# Arrival MANanager (AMAN)
 
-Dieses Repository stellt den VATGER AMAN Server bereit, dessen Aufgabe es ist, einen optimalen Arrivalflow für die Flugplätze in der VACC zu errechnen.
+## System description
 
+AMAN is splitted up into four different components.
+* aman-com defines the diffent message types
+* aman-es implements an EuroScope plugin to communicate with [aman-sys](https://git.vatsim-germany.org/nav/aman-sys)
+* aman-sys implements the backend system to plan an optimal arrival sequence for the different airports
+* aman-web implements a web-interface to configure [aman-sys](https://git.vatsim-germany.org/nav/aman-sys) and visualize sequences
+
+## Component description
+
+AMAN uses [Protocol Buffers](https://developers.google.com/protocol-buffers)
+for the message serialization and message definition between the EuroScope instance and the AMAN backend.
+
+Additionally is [ZeroMQ](https://zeromq.org/) used for the communication abstraction layer.
+
+This component provides the server backend with the planning and optimization system per airport.
+It is designed as a python framework that can run on a webserver.
+ZMQ based encryption and authentication methods are used to authenticate controllers.
 
 ## RHC-ACS-ASS Algorithm
 Step 1: Initialization. Set up parameters for
@@ -41,4 +57,13 @@ Step 3.3: Calculate the fitness of each ant and determine
 the best solution. Moreover, the current best solution is compared with the historically best solution
 to determine the historically best solution.
 
-Step 3.4: Perform the global pheromone updating as (10).
+Step 3.4: Perform the global pheromone updating as (10).
+
+# Additional libraries
+
+* [ZeroMQ](https://github.com/zeromq) - GNU GPLv3
+* [Protocol Buffers](https://github.com/protocolbuffers/protobuf) - BSD-3
+
+# License
+
+AMAN is released under the [GNU General Public License v3](LICENSE)

+ 82 - 0
aman/AMAN.py

@@ -0,0 +1,82 @@
+#!/usr/bin/env python
+
+import glob
+import os
+import sys
+
+from aman.com import AircraftReport_pb2
+from aman.com.Euroscope import Euroscope
+from aman.config.AircraftPerformance import AircraftPerformance
+from aman.config.Airport import Airport
+from aman.config.System import System
+from aman.sys.Worker import Worker
+
+class AMAN:
+    def findConfigPath():
+        envvar = os.environ.get('AMAN_CONFIG_PATH')
+        if None == envvar:
+            print('No AMAN_CONFIG_PATH in environment variables found. Using execution directory.')
+            path = os.getcwd()
+        else:
+            print('AMAN_CONFIG_PATH found.')
+            path = envvar
+
+        print('Config-path: ' + path)
+        return path
+
+    def __init__(self):
+        # default initialization of members
+        self.systemConfig = None
+        self.aircraftPerformance = None
+        self.receiver = None
+        self.workers = []
+        self.inbounds = {}
+
+        configPath = AMAN.findConfigPath()
+
+        # read all system relevant configuration files
+        self.systemConfig = System(os.path.join(configPath, 'System.ini'))
+        print('Parsed System.ini')
+
+        # read the aircraft performance data
+        self.aircraftPerformance = AircraftPerformance(os.path.join(configPath, 'PerformanceData.ini'))
+        if None == self.aircraftPerformance:
+            sys.stderr.write('No aircraft performance data found!')
+            sys.exit(-1)
+        else:
+            print('Parsed PerformanceData.ini. Extracted ' + str(len(self.aircraftPerformance.aircrafts)) + ' aircrafts')
+
+        # find the airport configurations and create the workers
+        airportsPath = os.path.join(os.path.join(configPath, 'airports'), '*.ini')
+        for file in glob.glob(airportsPath):
+            icao = os.path.splitext(os.path.basename(file))[0]
+
+            print('Parsing planner configuration for ' + icao)
+            airportConfig = Airport(file, icao)
+
+            # initialize the worker thread
+            worker = Worker(icao, airportConfig)
+            worker.start()
+            self.workers.append(worker)
+            print('Starter worker for ' + icao)
+
+        # create the EuroScope receiver
+        self.receiver = Euroscope(configPath, self.systemConfig.Server, self)
+
+    def __del__(self):
+        if None != self.receiver:
+            del self.receiver
+        self.receiver = None
+
+        for worker in self.workers:
+            worker.stop()
+
+    def updateAircraftReport(self, report : AircraftReport_pb2.AircraftReport):
+        # find the correct worker for the inbound
+        for worker in self.workers:
+            if worker.icao == report.destination:
+                print('Updated ' + report.aircraft.callsign + ' for ' + worker.icao)
+                worker.acquire()
+                worker.reportQueue[report.aircraft.callsign] = report
+                worker.release()
+                break

+ 2 - 0
aman/__init__.py

@@ -0,0 +1,2 @@
+import com
+import tools

+ 104 - 0
aman/com/Euroscope.py

@@ -0,0 +1,104 @@
+#!/usr/bin/env python
+
+import ctypes
+import glob
+import os
+import sys
+import threading
+import time
+
+import zmq
+import zmq.auth
+
+from aman.com import AircraftReport_pb2
+from aman.config.Server import Server
+
+class ReceiverThread(threading.Thread):
+    def __init__(self, socket, aman):
+        threading.Thread.__init__(self)
+        self.socket = socket
+        self.aman = aman
+
+    def run(self):
+        while True:
+            try:
+                msg = self.socket.recv(zmq.NOBLOCK)
+
+                # parse the received message
+                report = AircraftReport_pb2.AircraftReport()
+                report.ParseFromString(msg)
+
+                # try to associate the received aircraft to an airport
+                self.aman.updateAircraftReport(report)
+
+            except zmq.ZMQError as error:
+                if zmq.EAGAIN == error.errno:
+                    time.sleep(0.5)
+                    continue
+                else:
+                    return
+
+    def threadId(self):
+        if hasattr(self, '_thread_id'):
+            return self._thread_id
+        for id, thread in threading._active.items():
+            if thread is self:
+                return id
+
+    def stopThread(self):
+        id = self.threadId()
+        res = ctypes.pythonapi.PyThreadState_SetAsyncExc(id, ctypes.py_object(SystemExit))
+        if 1 < res:
+            ctypes.pythonapi.PyThreadState_SetAsyncExc(id, 0)
+
+# @brief Receives and sends messages to EuroScope plugins
+class Euroscope:
+    # @brief Initializes the ZMQ socket
+    # @param[in] config The server configuration
+    def __init__(self, configPath : str, config : Server, aman):
+        self.context = zmq.Context()
+
+        # find the key directories
+        serverKeyPath = os.path.join(os.path.join(configPath, 'keys'), 'server')
+        if False == os.path.isdir(serverKeyPath):
+            sys.stderr.write('No directory for the server key found')
+            sys.exit(-1)
+        print('Path to the server key: ' + serverKeyPath)
+
+        clientKeyPath = os.path.join(os.path.join(configPath, 'keys'), 'clients')
+        if False == os.path.isdir(clientKeyPath):
+            sys.stderr.write('No directory for the client keys found')
+            sys.exit(-1)
+        print('Path to the client keys: ' + clientKeyPath)
+
+        # read the certificates
+        keyPairPath = glob.glob(os.path.join(serverKeyPath, '*.key_secret'))
+        if 1 != len(keyPairPath):
+            sys.stderr.write('No public-private keypair found for the server certificate')
+            sys.exit(-1)
+        keyPair = zmq.auth.load_certificate(keyPairPath[0])
+
+        # initialize the receiver
+        self.receiverSocket = zmq.Socket(self.context, zmq.SUB)
+        self.receiverSocket.setsockopt(zmq.CURVE_PUBLICKEY, keyPair[0])
+        self.receiverSocket.setsockopt(zmq.CURVE_SECRETKEY, keyPair[1])
+        self.receiverSocket.setsockopt(zmq.CURVE_SERVER, True)
+        self.receiverSocket.bind('tcp://' + config.Address + ':' + str(config.PortReceiver))
+        self.receiverSocket.setsockopt(zmq.SUBSCRIBE, b'')
+        self.receiverThread = ReceiverThread(self.receiverSocket, aman)
+        self.receiverThread.start()
+        print('Listening at tcp://' + config.Address + ':' + str(config.PortReceiver))
+
+        # initialize the notification
+        self.notificationSocket = zmq.Socket(self.context, zmq.PUB)
+        self.notificationSocket.setsockopt(zmq.CURVE_PUBLICKEY, keyPair[0])
+        self.notificationSocket.setsockopt(zmq.CURVE_SECRETKEY, keyPair[1])
+        self.notificationSocket.setsockopt(zmq.CURVE_SERVER, True)
+        self.notificationSocket.bind('tcp://' + config.Address + ':' + str(config.PortNotification))
+        print('Publishing at tcp://' + config.Address + ':' + str(config.PortNotification))
+
+    def __del__(self):
+        self.receiverThread.stopThread()
+        self.receiverThread.join()
+        self.receiverSocket.close()
+        self.notificationSocket.close()

+ 0 - 0
aman/com/__init__.py


+ 28 - 0
aman/config/AircraftPerformance.py

@@ -0,0 +1,28 @@
+#!/usr/bin/env python
+
+import configparser
+
+from aman.types.PerformanceData import PerformanceData
+
+class AircraftPerformance:
+    def __init__(self, filepath : str):
+        config = configparser.ConfigParser()
+        config.read(filepath)
+        self.aircrafts = { }
+
+        # iterate over all entries
+        for key in config:
+            if 'DEFAULT' == key:
+                continue
+
+            aircraft = PerformanceData(key)
+
+            aircraft.speedAboveFL240 = config[key]['speedabovefl240']
+            aircraft.rodAboveFL240 = config[key]['rodabovefl240']
+            aircraft.speedAboveFL100 = config[key]['speedabovefl100']
+            aircraft.rodAboveFL100 = config[key]['rodabovefl100']
+            aircraft.speedBelowFL100 = config[key]['speedbelowfl100']
+            aircraft.rodBelowFL100 = config[key]['rodbelowfl100']
+            aircraft.speedApproach = config[key]['speedapproach']
+
+            self.aircrafts[aircraft.icao] = aircraft

+ 66 - 0
aman/config/Airport.py

@@ -0,0 +1,66 @@
+#!/usr/bin/env python
+
+import configparser
+import glob
+import os
+import sys
+
+from formats.SctEseFormat import SctEseFormat
+
+class Airport:
+    def findGngData(data, path):
+        if None == data.get('gngwildcard'):
+            return None, None
+
+        # find the newest ESE file
+        files = glob.glob(os.path.join(path, data['gngwildcard'] + '.ese'))
+        latestEse = max(files, key=os.path.getctime)
+
+        # search for the corresponding SCT file
+        latestSct = os.path.splitext(latestEse)[0] + '.sct'
+
+        # check if the files exist
+        if False == os.path.isfile(latestEse) or False == os.path.isfile(latestSct):
+            return None, None
+
+        return latestSct, latestEse
+
+    def parsePlanning(self, planning):
+        if None == planning.get('routes'):
+            return []
+        return planning['routes'].split(':')
+
+    def __init__(self, filepath : str, icao : str):
+        self.arrivalRoutes = {}
+
+        config = configparser.ConfigParser()
+        config.read(filepath)
+
+        dataConfig = None
+        planningConfig = None
+
+        # search the required sections
+        for key in config:
+            if 'DATA' == key:
+                dataConfig = config['DATA']
+            elif 'PLANNING' == key:
+                planningConfig = config['PLANNING']
+
+        # find the GNG-file data
+        sctFile, eseFile = Airport.findGngData(dataConfig, os.path.dirname(filepath))
+        if None == sctFile or None == eseFile:
+            sys.stderr.write('No GNG-files found')
+            sys.exit(-1)
+
+        # parse the planning information
+        if None == planningConfig or False == self.parsePlanning(planningConfig):
+            sys.stderr.write('No planning configuration found')
+            sys.exit(-1)
+        requiredArrivalRoutes = self.parsePlanning(planningConfig)
+        if 0 == len(requiredArrivalRoutes):
+            sys.stderr.write('No valid planning configuration found')
+            sys.exit(-1)
+
+        # parse the GNG data
+        print('Used GNG-Data: ' + eseFile)
+        self.gngData = SctEseFormat(sctFile, eseFile, icao, requiredArrivalRoutes)

+ 29 - 0
aman/config/Server.py

@@ -0,0 +1,29 @@
+#!/usr/bin/env python
+
+import configparser;
+import sys
+
+class Server():
+    def __init__(self, config : configparser.ConfigParser):
+        self.Address = None
+        self.PortReceiver = None
+        self.PortNotification = None
+
+        # search the required sections
+        for key in config:
+            if 'address' == key:
+                self.Address = config['address']
+            elif 'portreceiver' == key:
+                self.PortReceiver = int(config['portreceiver'])
+            elif 'portnotification' == key:
+                self.PortNotification = int(config['portnotification'])
+
+        if self.Address is None:
+            sys.stderr.write('No server-address configuration found!')
+            sys.exit(-1)
+        if self.PortReceiver is None:
+            sys.stderr.write('No server-port-receiver configuration found!')
+            sys.exit(-1)
+        if self.PortNotification is None:
+            sys.stderr.write('No server-port-notification configuration found!')
+            sys.exit(-1)

+ 23 - 0
aman/config/System.py

@@ -0,0 +1,23 @@
+#!/usr/bin/env python
+
+import configparser
+import sys
+
+from aman.config.Server import Server
+
+class System:
+    def __init__(self, filepath : str):
+        config = configparser.ConfigParser()
+        config.read(filepath)
+
+        # search the required sections
+        serverSectionAvailable = False
+        for key in config:
+            if 'SERVER' == key:
+                serverSectionAvailable = True
+
+        if not serverSectionAvailable:
+            sys.stderr.write('No server-configuration section found!')
+            sys.exit(-1)
+
+        self.Server = Server(config['SERVER'])

+ 0 - 0
aman/config/__init__.py


+ 152 - 0
aman/formats/SctEseFormat.py

@@ -0,0 +1,152 @@
+#!/usr/bin/env python
+
+import sys
+
+from aman.types.ArrivalRoute import ArrivalRoute
+from aman.types.Waypoint import Waypoint
+
+class SctEseFormat:
+    def readFile(filename : str):
+        fileBlocks = {}
+        block = None
+
+        # read the file line by line and create segments based on []-entries
+        with open(filename) as file:
+            for line in file:
+                line = line.strip()
+
+                # found a new segment
+                if line.startswith('['):
+                    block = line[1:-1]
+                    fileBlocks.setdefault(block, [])
+                # append the last backend
+                elif None != block and 0 != len(line):
+                    fileBlocks[block].append(line)
+
+        return fileBlocks
+
+    def parseWaypoint(waypoint : str, nameIdx : int, latitudeIdx : int, longitudeIdx : int):
+        split = list(filter(None, waypoint.split(' ')))
+        if len(split) <= longitudeIdx:
+            sys.stderr.write('Invalid waypoint format: ' + waypoint)
+            sys.exit(-1)
+        return Waypoint(split[nameIdx], Waypoint.dms2dd(split[latitudeIdx]), Waypoint.dms2dd(split[longitudeIdx]))
+
+    def extractWaypoints(self, sctFilepath : str):
+        config = SctEseFormat.readFile(sctFilepath)
+        foundAirports = False
+        foundVOR = False
+        foundNDB = False
+        foundFix = False
+
+        for key in config:
+            if 'VOR' == key:
+                foundVOR = True
+            elif 'NDB' == key:
+                foundNDB = True
+            elif 'FIXES' == key:
+                foundFix = True
+            elif 'AIRPORT' == key:
+                foundAirports = True
+
+        if False == foundVOR:
+            sys.stderr.write('Unable to find VOR-entries in the sector file')
+            sys.exit(-1)
+        if False == foundNDB:
+            sys.stderr.write('Unable to find NDB-entries in the sector file')
+            sys.exit(-1)
+        if False == foundFix:
+            sys.stderr.write('Unable to find FIX-entries in the sector file')
+            sys.exit(-1)
+        if False == foundAirports:
+            sys.stderr.write('Unable to find AIRPORT-entries in the sector file')
+            sys.exit(-1)
+
+        # extract all waypoints
+        for waypoint in config['VOR']:
+            waypoint = SctEseFormat.parseWaypoint(waypoint, 0, 2, 3)
+            self.waypoints.setdefault(waypoint.name, []).append(waypoint)
+        for waypoint in config['NDB']:
+            waypoint = SctEseFormat.parseWaypoint(waypoint, 0, 1, 2)
+            self.waypoints.setdefault(waypoint.name, []).append(waypoint)
+        for waypoint in config['FIXES']:
+            waypoint = SctEseFormat.parseWaypoint(waypoint, 0, 1, 2)
+            self.waypoints.setdefault(waypoint.name, []).append(waypoint)
+
+        # extract the airports
+        for airport in config['AIRPORT']:
+            airport  = SctEseFormat.parseWaypoint(airport,0, 2, 3)
+            self.airports.setdefault(airport.name, []).append(airport)
+
+    def parseArrivalRoute(self, route : str, airport : Waypoint):
+        # split the route and validate that it is a STAR for the airport
+        split = route.split(':')
+        if 5 != len(split) or 'STAR' != split[0] or split[1] != airport.name:
+            return None
+
+        # find all waypoints
+        waypoints = []
+        route = list(filter(None, split[4].split(' ')))
+        for waypoint in route:
+            # find the waypoint in the route
+            coordinates = self.waypoints[waypoint]
+            # no waypoint with this name defined
+            if None == coordinates:
+                sys.stderr.write('Unable to find waypoint ' + waypoint)
+                sys.exit(-1)
+            # multiple waypoints, but use Haversine distance to distinct between candidates
+            elif 1 != len(coordinates):
+                minDistance = sys.float_info.max
+                nearest = None
+
+                # we assume that waypoints with the same name are not close each other
+                for coordinate in coordinates:
+                    distance = coordinate.haversine(airport)
+                    # found a closer waypoint
+                    if minDistance > distance:
+                        minDistance = distance
+                        nearest = coordinate
+
+                if None == nearest:
+                    sys.stderr.write('Unable to find a close waypoint for ' + waypoint)
+                    sys.exit(-1)
+
+                waypoints.append(nearest)
+            # extend the list of waypoints
+            else:
+                waypoints.append(coordinates[0])
+
+        # create the arrival route
+        return ArrivalRoute(split[3], split[2], waypoints)
+
+    def extractArrivalRoutes(self, eseFilepath : str, airport : str, allowedRoutes : list):
+        config = SctEseFormat.readFile(eseFilepath)
+        foundSidsStars = False
+
+        # search the airport in the extracted list
+        if not airport in self.airports:
+            sys.stderr.write(airport + 'in self.airports', 'Unable to find the requested airport')
+            sys.exit(-1)
+        airport = self.airports[airport][0]
+
+        for key in config:
+            if 'SIDSSTARS' == key:
+                foundSidsStars = True
+
+        if False == foundSidsStars:
+            sys.stderr.write('Unable to find SIDSSTARS-entries in the sector file')
+            sys.exit(-1)
+
+        # parse all arrival routes
+        for line in config['SIDSSTARS']:
+            route = self.parseArrivalRoute(line, airport)
+            if None != route and route.name in allowedRoutes:
+                self.arrivalRoutes.setdefault(route.runway, []).append(route)
+
+    def __init__(self, sctFilepath : str, eseFilepath : str, airport : str, allowedRoutes : list):
+        self.arrivalRoutes = {}
+        self.waypoints = {}
+        self.airports = {}
+
+        self.extractWaypoints(sctFilepath)
+        self.extractArrivalRoutes(eseFilepath, airport, allowedRoutes)

+ 0 - 0
aman/formats/__init__.py


+ 33 - 0
aman/sys/Worker.py

@@ -0,0 +1,33 @@
+#!/usr/bin/env python
+
+from threading import Thread, Lock
+import time
+
+from aman.config.Airport import Airport
+
+class Worker(Thread):
+    def __init__(self, icao : str, configuration : Airport):
+        Thread.__init__(self)
+
+        self.stopThread = None
+        self.icao = icao
+        self.configuration = configuration
+        self.arrivalRoutes = configuration.gngData.arrivalRoutes
+        self.updateLock = Lock()
+        self.reportQueue = {}
+
+    def stop(self):
+        self.stopThread = True
+
+    def run(self):
+        counter = 0
+
+        while None == self.stopThread:
+            time.sleep(1)
+            counter += 1
+            if 0 != (counter % 60):
+                continue
+
+            # TODO handle the report queue and update internal information
+            # TODO execute planning, etc.
+            continue

+ 0 - 0
aman/sys/__init__.py


+ 44 - 0
aman/tools/KeyPairCreator.py

@@ -0,0 +1,44 @@
+#!/usr/bin/env python
+
+import argparse
+import os
+from typing import Tuple
+import zmq.auth
+
+# @brief Creates a new keypair for ZMQ encryption
+# @param[in] directory The location where to store the keys
+# @return The public and private key tuple
+def KeyPairCreator(directory: str, server: bool) -> Tuple[str, str]:
+    if not server:
+        print('Creating a new pair for a client...')
+        target = 'client'
+    else:
+        print('Creating a new pair for the server...')
+        target = 'server'
+
+    public, private = zmq.auth.create_certificates(directory, target)
+    return (public, private)
+
+def str2bool(value):
+    if isinstance(value, bool):
+        return value
+    elif value.lower() in ('yes', 'true', 't', 'y', '1'):
+        return True
+    elif value.lower() in ('no', 'false', 'f', 'n', '0'):
+        return False
+    else:
+        raise argparse.ArgumentTypeError('Boolean value expected')
+
+if __name__ == '__main__':
+    # create the commandline parser
+    parser = argparse.ArgumentParser(description='Create a new key-value pair')
+    parser.add_argument('directory', help='Directory where to store the key pair')
+    parser.add_argument('--server', default=False, action='store_true', help="Creates server key pair")
+    args = parser.parse_args()
+
+    # create the directory if it does not exist
+    if not os.path.exists(args.directory):
+        os.makedirs(args.directory)
+
+    # create the keys
+    KeyPairCreator(args.directory, args.server)

+ 151 - 0
aman/tools/SkybraryAircraftCrawler.py

@@ -0,0 +1,151 @@
+#!/usr/bin/env python
+
+import argparse
+import configparser
+import os
+import urllib.request
+from bs4 import BeautifulSoup
+from aman.types.PerformanceData import PerformanceData
+
+def findAircraftPages(rooturl : str, suburl : str):
+    aircrafts = []
+
+    with urllib.request.urlopen(rooturl + suburl) as site:
+        data = site.read().decode('utf-8')
+        site.close()
+
+        parsed = BeautifulSoup(data, features='lxml')
+
+        for link in parsed.body.find_all('a', title=True):
+            split = link['href'].split('/')
+            if 3 == len(split) and split[2] == link['title'] and 'Category' not in link['title'] and 'Special' not in link['href']:
+                aircrafts.append(rooturl + link['href'])
+
+        for link in parsed.body.find_all('a', attrs={ 'title': 'Category:Aircraft' }):
+            if 'previous' not in link.text:
+                aircrafts.extend(findAircraftPages(rooturl, link['href']))
+
+    return aircrafts
+
+def findAndParseEntry(tableRow, startIdx, substring, default):
+    while 0 < startIdx:
+        if substring in tableRow[startIdx].text:
+            split = tableRow[startIdx].text.split(' ')
+            if 1 >= len(split):
+                return default, startIdx - 2
+            else:
+                return int(split[0]), startIdx - 2
+        else:
+            startIdx -= 1
+
+    return 0, -1
+
+def findAndParseSpeedEntry(tableRow, startIdx, default):
+    return findAndParseEntry(tableRow, startIdx, 'kts', default)
+
+def findAndParseRodEntry(tableRow, startIdx, default):
+    return findAndParseEntry(tableRow, startIdx, 'ft/min', default)
+
+def parsePerformanceEntries(tableRowSpeeds, tableRowRODs):
+    speeds = []
+    rods = []
+
+    # parse the speed data
+    idx = len(tableRowSpeeds) - 1
+    while 0 < idx:
+        parsed = findAndParseSpeedEntry(tableRowSpeeds, idx, 140 if 0 == len(speeds) else 250)
+        if 0 < idx:
+            speeds.append(parsed[0])
+            idx = parsed[1]
+
+    # parse the ROD data
+    idx = len(tableRowRODs) - 1
+    while 0 < idx:
+        parsed = findAndParseRodEntry(tableRowRODs, idx, 2000)
+        if 0 < idx:
+            rods.append(parsed[0])
+            idx = parsed[1]
+
+    return speeds, rods
+
+def parsePerformanceData(url : str):
+    with urllib.request.urlopen(url) as site:
+        data = site.read().decode('utf-8')
+        site.close()
+
+        # check if we find the ICAO code
+        parsed = BeautifulSoup(data, features='lxml')
+        icao = parsed.body.find('h5', attrs={ 'id' : 'siteSub', 'class' : 'subtitle'})
+        if None == icao or '' == icao.text:
+            return False, None
+
+        aircraft = PerformanceData(icao.text)
+        performanceTable = parsed.body.find('table', attrs={ 'class' : 'wikitable', 'style' : 'font-size: 90%;' })
+        if None == performanceTable or None == performanceTable.find_all('tr')[1] or None == performanceTable.find_all('tr')[2]:
+            return False, None
+
+        speeds, rods = parsePerformanceEntries(performanceTable.find_all('tr')[1].find_all('td'),
+                                               performanceTable.find_all('tr')[2].find_all('td'))
+        if 10 > len(speeds):
+            speeds.insert(1, speeds[1])
+
+        # create the speed data
+        if len(speeds) >= 4:
+            aircraft.speedApproach = speeds[0]
+            aircraft.speedBelowFL100 = speeds[1]
+            aircraft.speedAboveFL100 = speeds[2]
+            aircraft.speedAboveFL240 = speeds[3]
+        # create the ROD data
+        if len(rods) >= 3:
+            aircraft.rodBelowFL100 = rods[0]
+            aircraft.rodAboveFL100 = rods[1]
+            aircraft.rodAboveFL240 = rods[2]
+
+        return len(speeds) >= 4 and len(rods) >= 3, aircraft
+
+if __name__ == '__main__':
+    # create the commandline parser
+    parser = argparse.ArgumentParser(description='Extract the aircraft performace data')
+    parser.add_argument('directory', help='Directory where to store the performance data configuration')
+    args = parser.parse_args()
+
+    # create the directory if it does not exist
+    if not os.path.exists(args.directory):
+        os.makedirs(args.directory)
+
+    # parse the aircrafts
+    links = findAircraftPages('https://www.skybrary.aero', '/index.php?title=Category:Aircraft')
+    print('Found ' + str(len(links)) + ' aircrafts')
+
+    aircrafts = []
+    parsed = 0
+    for link in links:
+        valid, aircraft = parsePerformanceData(link)
+
+        parsed += 1
+        print('Parsed ' + str(parsed) + ' of ' + str(len(links)), end='\r')
+
+        if False == valid:
+            print('Unable to find performance data for ' + link)
+            continue
+
+        aircrafts.append(aircraft)
+
+    print('Successfully parsed ' + str(len(aircrafts)) + ' of ' + str(len(links)) + ' aircrafts')
+
+    # create the configuration file
+    config = configparser.ConfigParser()
+    for aircraft in aircrafts:
+        config[aircraft.icao] = {
+            'speedAboveFL240' : aircraft.speedAboveFL240,
+            'rodAboveFL240' : aircraft.rodAboveFL240,
+            'speedAboveFL100' : aircraft.speedAboveFL100,
+            'rodAboveFL100' : aircraft.rodAboveFL100,
+            'speedBelowFL100' : aircraft.speedBelowFL100,
+            'rodBelowFL100' : aircraft.rodBelowFL100,
+            'speedApproach' : aircraft.speedApproach
+        }
+
+    # write the configuration data
+    with open(args.directory + '/PerformanceData.ini', 'w') as file:
+        config.write(file)

+ 0 - 0
aman/tools/__init__.py


+ 11 - 0
aman/types/ArrivalRoute.py

@@ -0,0 +1,11 @@
+#!/usr/bin/env python
+
+class ArrivalRoute:
+    def __init__(self, name : str, runway : str, waypoints : list):
+        self.name = name
+        self.runway = runway
+        self.iaf = waypoints[0]
+        self.route = waypoints
+
+    def __str__(self):
+        return 'Name: ' + self.name + ', IAF: ' + self.iaf.name + ', RWY: ' + self.runway

+ 7 - 0
aman/types/Inbound.py

@@ -0,0 +1,7 @@
+#!/usr/bin/env python
+
+from aman.com import AircraftReport_pb2
+
+class Inbound:
+    def __init__(self, report : AircraftReport_pb2.AircraftReport):
+        self.report = report

+ 18 - 0
aman/types/PerformanceData.py

@@ -0,0 +1,18 @@
+#!/usr/bin/env python
+
+class PerformanceData:
+    def __init__(self, icao : str):
+        self.icao = icao
+        self.speedAboveFL240 = 0.0
+        self.speedAboveFL100 = 0.0
+        self.speedBelowFL100 = 0.0
+        self.speedApproach = 0.0
+        self.rodAboveFL240 = 0.0
+        self.rodAboveFL100 = 0.0
+        self.rodBelowFL100 = 2000.0
+
+    def __str__(self):
+        return 'ICAO: ' + self.icao + ', FL240: ' + str(self.rodAboveFL240) + '@' + str(self.speedAboveFL240) + \
+               ', +FL100: ' + str(self.rodAboveFL100) + '@' + str(self.speedAboveFL100) + \
+               ', -FL100: ' + str(self.rodBelowFL100) + '@' + str(self.speedBelowFL100) + \
+               ', Vapp: ' + str(self.speedApproach)

+ 33 - 0
aman/types/Waypoint.py

@@ -0,0 +1,33 @@
+#!/usr/bin/env python
+
+from sklearn.metrics.pairwise import haversine_distances
+import numpy as np
+
+class Waypoint:
+    def dms2dd(coordinate : str):
+        split = coordinate.split('.')
+        if 4 != len(split):
+            return 0.0
+
+        direction = split[0][1]
+        degrees = float(split[0][1:])
+        minutes = float(split[1])
+        seconds = float(split[2]) * (float(split[3]) / 1000.0)
+
+        dd = degrees + minutes / 60.0 + seconds / (60 * 60)
+        if 'E' == direction or 'S' == direction:
+            dd *= -1.0
+
+        return dd
+
+    def __init__(self, name : str, latitude : float, longitude : float):
+        self.name = name
+        self.coordinate = np.array([ latitude, longitude ])
+
+    def __str__(self):
+        return 'Name: ' + self.name + ', Lat: ' + str(self.coordinate[0]) + ', Lon: ' + str(self.coordinate[1])
+
+    def haversine(self, other):
+        self_radians = [np.radians(_) for _ in self.coordinate]
+        other_radians = [np.radians(_) for _ in other.coordinate]
+        return 6371.0 * haversine_distances([self_radians, other_radians])[0][1]

+ 0 - 0
aman/types/__init__.py


BIN
external/bin/protoc.exe


+ 32 - 0
external/licenses/ProtoBuf-3.17.3

@@ -0,0 +1,32 @@
+Copyright 2008 Google Inc.  All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+    * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+Code generated by the Protocol Buffer compiler is owned by the owner
+of the input file used when generating it.  This code is not
+standalone and requires a support library to be linked with it.  This
+support library is itself covered by the above license.

+ 101 - 0
setup.py

@@ -0,0 +1,101 @@
+#!/usr/bin/env python
+
+import os
+import re
+import shutil
+import subprocess
+import sys
+from setuptools import setup
+from distutils.command.clean import clean as _clean
+from distutils.command.build_py import build_py_2to3 as _build_py
+
+# @brief Creates Protobuf python files to encode and decode messages
+# @param[in] source The protobuf source file
+def generateProtobuf(source):
+    output = source.replace('.proto', '_pb2.py').replace('src/protobuf/', 'aman/com/')
+
+    if (not os.path.exists(output) or (os.path.exists(source) and os.path.getmtime(source) > os.path.getmtime(output))):
+        print('Generating %s...' % output)
+
+        if not os.path.exists(source):
+            sys.stderr.write('Cannot find %s' % source)
+            sys.exit(-1)
+
+        if not os.path.exists('external/bin/protoc.exe'):
+            sys.stderr.write('Cannot find proto-compiler')
+            sys.exit(-1)
+
+        command = [ 'external/bin/protoc.exe', '-Isrc/protobuf/', '-I.', '--python_out=aman/com/', source]
+        if 0 != subprocess.call(command):
+            sys.exit(-1)
+
+        # check if we need to replace some import commands
+        replaced = False
+        content = open(output, 'r').read()
+        for entry in re.findall('import.[A-Z].*.as.*', content):
+            content = content.replace(entry, 'from . ' + entry)
+            replaced = True
+
+        # update the content
+        if replaced:
+            with open(output, 'w') as file:
+                file.write(content)
+
+# @brief Cleans up all auto-generated files and folders
+# @param[in] _clean Instance of setuptools to clean up the system
+class clean(_clean):
+    def run(self):
+        for (dirpath, dirnames, filenames) in os.walk('.'):
+            for filename in filenames:
+                filepath = os.path.join(dirpath, filename)
+                if filepath.endswith('_pb2.py') or filepath.endswith('.pyc'):
+                    os.remove(filepath)
+            for dirname in dirnames:
+                if 'Arrival_MANager.egg-info' == dirname or 'build' == dirname or 'dist' == dirname or '__pycache__' == dirname:
+                    shutil.rmtree(os.path.join(dirpath, dirname))
+        _clean.run(self)
+
+# @brief Generates the python files and folders to set up the development/runtime environment
+# @param[in] _build_py Instance of setuptools to build the system
+class build_py(_build_py):
+    def run(self):
+        generateProtobuf('src/protobuf/Aircraft.proto')
+        generateProtobuf('src/protobuf/AircraftReport.proto')
+        generateProtobuf('src/protobuf/AircraftSchedule.proto')
+        generateProtobuf('src/protobuf/BaseTypes.proto')
+        _build_py.run(self)
+
+with open('README.md', 'r') as f:
+    longDescription = f.read()
+
+setup(
+    name = 'Arrival MANager',
+    version = '0.1.0',
+    packages = [
+        'aman',
+        'aman.com',
+        'aman.config',
+        'aman.formats',
+        'aman.sys',
+        'aman.tools',
+        'aman.types'
+    ],
+    namespace_packages = [ 'aman' ],
+    description = 'AMAN optimization backend',
+    long_description = longDescription,
+    author = 'Sven Czarnian',
+    author_email = 'devel@svcz.de',
+    license = 'GPLv3',
+    cmdclass = { 'clean': clean, 'build_py': build_py },
+    install_requires=[
+        'argparse',
+        'bs4',
+        'configparser',
+        'numpy',
+        'protobuf',
+        'pyzmq',
+        'scikit-learn',
+        'scipy',
+        'setuptools'
+    ]
+)

+ 1 - 0
src/protobuf

@@ -0,0 +1 @@
+Subproject commit 3c74c96cfd2e9a4bfe352af5a6d41e9ffd683cd5