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
 ## RHC-ACS-ASS Algorithm
 Step 1: Initialization. Set up parameters for
 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
 the best solution. Moreover, the current best solution is compared with the historically best solution
 to determine 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