diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9627fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +Backend.ini +main.py +.vscode/ +*_pb2.py +__pycache__ +*.egg-info +build/ +dist/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..334e4f4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "src/protobuf"] + path = src/protobuf + url = git@git.vatsim-germany.org:nav/aman-com.git + branch = feature/protobuf diff --git a/README.md b/README.md index e67164a..83728b3 100644 --- a/README.md +++ b/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). \ No newline at end of file +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) diff --git a/aman/AMAN.py b/aman/AMAN.py new file mode 100644 index 0000000..0375668 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/aman/__init__.py b/aman/__init__.py new file mode 100644 index 0000000..e2d636d --- /dev/null +++ b/aman/__init__.py @@ -0,0 +1,2 @@ +import com +import tools \ No newline at end of file diff --git a/aman/com/Euroscope.py b/aman/com/Euroscope.py new file mode 100644 index 0000000..585ff4f --- /dev/null +++ b/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() diff --git a/aman/com/__init__.py b/aman/com/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aman/config/AircraftPerformance.py b/aman/config/AircraftPerformance.py new file mode 100644 index 0000000..6012e68 --- /dev/null +++ b/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 diff --git a/aman/config/Airport.py b/aman/config/Airport.py new file mode 100644 index 0000000..4e91185 --- /dev/null +++ b/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) diff --git a/aman/config/Server.py b/aman/config/Server.py new file mode 100644 index 0000000..46567f1 --- /dev/null +++ b/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) diff --git a/aman/config/System.py b/aman/config/System.py new file mode 100644 index 0000000..0b47169 --- /dev/null +++ b/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']) diff --git a/aman/config/__init__.py b/aman/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aman/formats/SctEseFormat.py b/aman/formats/SctEseFormat.py new file mode 100644 index 0000000..de5b960 --- /dev/null +++ b/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) diff --git a/aman/formats/__init__.py b/aman/formats/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aman/sys/Worker.py b/aman/sys/Worker.py new file mode 100644 index 0000000..f213366 --- /dev/null +++ b/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 diff --git a/aman/sys/__init__.py b/aman/sys/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aman/tools/KeyPairCreator.py b/aman/tools/KeyPairCreator.py new file mode 100644 index 0000000..b0482c0 --- /dev/null +++ b/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) diff --git a/aman/tools/SkybraryAircraftCrawler.py b/aman/tools/SkybraryAircraftCrawler.py new file mode 100644 index 0000000..7c188fd --- /dev/null +++ b/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) diff --git a/aman/tools/__init__.py b/aman/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aman/types/ArrivalRoute.py b/aman/types/ArrivalRoute.py new file mode 100644 index 0000000..b9fd6fe --- /dev/null +++ b/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 \ No newline at end of file diff --git a/aman/types/Inbound.py b/aman/types/Inbound.py new file mode 100644 index 0000000..a670f49 --- /dev/null +++ b/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 diff --git a/aman/types/PerformanceData.py b/aman/types/PerformanceData.py new file mode 100644 index 0000000..149a7e9 --- /dev/null +++ b/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) diff --git a/aman/types/Waypoint.py b/aman/types/Waypoint.py new file mode 100644 index 0000000..040d8e9 --- /dev/null +++ b/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] diff --git a/aman/types/__init__.py b/aman/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/external/bin/protoc.exe b/external/bin/protoc.exe new file mode 100644 index 0000000..052bf85 Binary files /dev/null and b/external/bin/protoc.exe differ diff --git a/external/licenses/ProtoBuf-3.17.3 b/external/licenses/ProtoBuf-3.17.3 new file mode 100644 index 0000000..19b305b --- /dev/null +++ b/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. diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e7882cf --- /dev/null +++ b/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' + ] +) diff --git a/src/protobuf b/src/protobuf new file mode 160000 index 0000000..3c74c96 --- /dev/null +++ b/src/protobuf @@ -0,0 +1 @@ +Subproject commit 3c74c96cfd2e9a4bfe352af5a6d41e9ffd683cd5