From cfb4e13aedf8681226b101091adce8c2b73be67d Mon Sep 17 00:00:00 2001 From: Mark Jessop Date: Thu, 19 Jul 2018 22:14:01 +0930 Subject: [PATCH] Add chasemapper module. Settings now loaded from configuration file. --- chasemapper/__init__.py | 7 ++ chasemapper/config.py | 90 ++++++++++++++ chasemapper/predictor.py | 7 ++ horusmapper.cfg.example | 68 +++++++++++ chasemapper.py => horusmapper.py | 193 +++++++++++++++++++------------ templates/index.html | 23 ++-- 6 files changed, 300 insertions(+), 88 deletions(-) create mode 100644 chasemapper/__init__.py create mode 100644 chasemapper/config.py create mode 100644 chasemapper/predictor.py create mode 100644 horusmapper.cfg.example rename chasemapper.py => horusmapper.py (71%) diff --git a/chasemapper/__init__.py b/chasemapper/__init__.py new file mode 100644 index 0000000..e9f5dfd --- /dev/null +++ b/chasemapper/__init__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +# +# Project Horus - Browser-Based Chase Mapper +# +# Copyright (C) 2018 Mark Jessop +# Released under GNU GPL v3 or later +# diff --git a/chasemapper/config.py b/chasemapper/config.py new file mode 100644 index 0000000..873dbc6 --- /dev/null +++ b/chasemapper/config.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python +# +# Project Horus - Browser-Based Chase Mapper - Config Reader +# +# Copyright (C) 2018 Mark Jessop +# Released under GNU GPL v3 or later +# +import logging + +try: + # Python 2 + from ConfigParser import RawConfigParser +except ImportError: + # Python 3 + from configparser import RawConfigParser + + +default_config = { + # Start location for the map (until either a chase car position, or balloon position is available.) + 'default_lat': -34.9, + 'default_lon': 138.6, + + # Predictor settings + 'pred_enabled': False, # Enable running and display of predicted flight paths. + # Default prediction settings (actual values will be used once the flight is underway) + 'pred_model': "Disabled", + 'pred_desc_rate': 6.0, + 'pred_burst': 28000, + 'show_abort': True, # Show a prediction of an 'abort' paths (i.e. if the balloon bursts *now*) + 'pred_update_rate': 15 # Update predictor every 15 seconds. + } + + +def parse_config_file(filename): + """ Parse a Configuration File """ + + chase_config = default_config.copy() + + config = RawConfigParser() + config.read(filename) + + # Map Defaults + chase_config['flask_host'] = config.get('map', 'flask_host') + chase_config['flask_port'] = config.getint('map', 'flask_port') + chase_config['default_lat'] = config.get('map', 'default_lat') + chase_config['default_lon'] = config.get('map', 'default_lon') + + # Source Selection + chase_config['data_source'] = config.get('source', 'type') + chase_config['ozimux_port'] = config.getint('source', 'ozimux_port') + chase_config['horus_udp_port'] = config.getint('source', 'horus_udp_port') + + # Car GPS Data + chase_config['car_gps_source'] = config.get('car_gps','source') + chase_config['car_gpsd_host'] = config.get('car_gps','gpsd_host') + chase_config['car_gpsd_port'] = config.getint('car_gps','gpsd_port') + + # Predictor + chase_config['pred_enabled'] = config.getboolean('predictor', 'predictor_enabled') + chase_config['pred_burst'] = config.getfloat('predictor', 'default_burst') + chase_config['pred_desc_rate'] = config.getfloat('predictor', 'default_descent_rate') + chase_config['pred_binary'] = config.get('predictor','pred_binary') + chase_config['pred_gfs_directory'] = config.get('predictor', 'gfs_directory') + chase_config['pred_model_download'] = config.get('predictor', 'model_download') + + return chase_config + + + + +def read_config(filename, default_cfg="horusmapper.cfg.example"): + """ Read in a Horus Mapper configuration file,and return as a dict. """ + + try: + config_dict = parse_config_file(filename) + except Exception as e: + logging.error("Could not parse %s, trying default: %s" % (filename, str(e))) + try: + config_dict = parse_config_file(default_cfg) + except Exception as e: + logging.critical("Could not parse example config file! - %s" % str(e)) + config_dict = None + + return config_dict + +if __name__ == "__main__": + import sys + print(read_config(sys.argv[1])) + + diff --git a/chasemapper/predictor.py b/chasemapper/predictor.py new file mode 100644 index 0000000..bfd1a2b --- /dev/null +++ b/chasemapper/predictor.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +# +# Project Horus - Browser-Based Chase Mapper - Predictor +# +# Copyright (C) 2018 Mark Jessop +# Released under GNU GPL v3 or later +# diff --git a/horusmapper.cfg.example b/horusmapper.cfg.example new file mode 100644 index 0000000..5131f09 --- /dev/null +++ b/horusmapper.cfg.example @@ -0,0 +1,68 @@ +# +# Project Horus Chase-Mapper Configuration File +# +# Copy this file to horusmapper.cfg and modify as required. +# + +# Telemetry Data Source +[source] +# Data source type: +# ozimux - Read data in OziMux format +# horus_udp - Read Horus UDP Broadcast 'Payload Summary' messages +type = ozimux + +# Ozimux Data source UDP port +ozimux_port = 8942 + +# 'Payload Summary' (also car GPS messages) UDP listen port +horus_udp_port = 55672 + +# Chase-Car Position Settings +[car_gps] +# Chase car Position data Source +# none - No Chase-Car GPS +# horus_udp - Read Horus UDP Broadcast 'Car GPS' messages +# gpsd - Poll GPSD for positions. (TO BE IMPLEMENTED) +source = horus_udp + +# GPSD Host/Port +# TO BE IMPLEMENTED +gpsd_host = localhost +gpsd_port = 2947 + + +# Map Defaults +[map] +# Host/port to host webserver on +flask_host = 0.0.0.0 +flask_port = 5001 +# Default map centre +default_lat = -34.9 +default_lon = 138.6 + +# Predictor Settings +# Use of the predictor requires installing the CUSF Predictor Python Wrapper from here: +# https://github.com/darksidelemm/cusf_predictor_wrapper +# You also need to compile the predictor binary, and copy it into this directory. +[predictor] +# Enable Predictor (True/False) +predictor_enabled = False + +# Predictor defaults - these can be modified at runtime in the web interface. +default_burst = 30000 +default_descent_rate = 5.0 + +# Predictory Binary Location +# Where to find the built CUSF predictor binary. This will usually be ./pred or pred.exe (on Windows) +pred_binary = ./pred + +# Directory containing GFS model data. +gfs_directory = ./gfs/ + +# Wind Model Download Command +# Optional command to enable downloading of wind data via a web client button. +# Example: (this will require copying the get_wind_data.py script to this dirctory) +# model_download = python get_wind_data.py --lat=-33 --lon=139 --latdelta=10 --londelta=10 -f 24 -m 0p50 -o gfs +# The gfs directory (above) will be cleared of all .dat files prior to the above command being run. +model_download = none + diff --git a/chasemapper.py b/horusmapper.py similarity index 71% rename from chasemapper.py rename to horusmapper.py index 5906d8f..52ce093 100644 --- a/chasemapper.py +++ b/horusmapper.py @@ -13,13 +13,14 @@ import sys import time import traceback from threading import Thread -from datetime import datetime +from datetime import datetime, timedelta from dateutil.parser import parse from horuslib import * from horuslib.geometry import * from horuslib.atmosphere import time_to_landing from horuslib.listener import OziListener, UDPListener from horuslib.earthmaths import * +from chasemapper.config import * # Define Flask Application, and allow automatic reloading of templates for dev work @@ -34,27 +35,12 @@ socketio = SocketIO(app) # Global stores of data. -# Don't expose these settings to the client! -pred_settings = { - 'pred_binary': "./pred", - 'gfs_path': "./gfs/", -} # These settings are shared between server and all clients, and are updated dynamically. -chasemapper_config = { - # Start location for the map (until either a chase car position, or balloon position is available.) - 'default_lat': -34.9, - 'default_lon': 138.6, +chasemapper_config = {} - # Predictor settings - 'pred_enabled': False, # Enable running and display of predicted flight paths. - # Default prediction settings (actual values will be used once the flight is underway) - 'pred_model': "Disabled", - 'pred_desc_rate': 6.0, - 'pred_burst': 28000, - 'show_abort': True, # Show a prediction of an 'abort' paths (i.e. if the balloon bursts *now*) - 'pred_update_rate': 15 # Update predictor every 15 seconds. - } +# These settings are not editable by the client! +pred_settings = {} # Payload data Stores current_payloads = {} # Archive data which will be passed to the web client @@ -93,11 +79,28 @@ def flask_emit_event(event_name="none", data={}): def client_settings_update(data): global chasemapper_config + _predictor_change = "none" + if (chasemapper_config['pred_enabled'] == False) and (data['pred_enabled'] == True): + _predictor_change = "restart" + elif (chasemapper_config['pred_enabled'] == True) and (data['pred_enabled'] == False): + _predictor_change = "stop" + # Overwrite local config data with data from the client. - # TODO: Some sanitization of this data... this could lead to bad things. chasemapper_config = data - # Updates based on + if _predictor_change == "restart": + # Wait until any current predictions have finished. + while predictor_semaphore: + time.sleep(0.1) + # Attempt to start the predictor. + initPredictor() + elif _predictor_change == "stop": + # Wait until any current predictions have finished. + while predictor_semaphore: + time.sleep(0.1) + + predictor = None + # Push settings back out to all clients. flask_emit_event('server_settings_update', chasemapper_config) @@ -119,7 +122,7 @@ def handle_new_payload_position(data): current_payload_tracks[_callsign] = GenericTrack() current_payloads[_callsign] = { - 'telem': {'callsign': _callsign, 'position':[_lat, _lon, _alt], 'vel_v':0.0, 'speed':0.0, 'short_time':_short_time, 'time_to_landing':""}, + 'telem': {'callsign': _callsign, 'position':[_lat, _lon, _alt], 'vel_v':0.0, 'speed':0.0, 'short_time':_short_time, 'time_to_landing':"", 'server_time':time.time()}, 'path': [], 'pred_path': [], 'pred_landing': [], @@ -168,7 +171,8 @@ def handle_new_payload_position(data): 'vel_v':_vel_v, 'speed':_speed, 'short_time':_short_time, - 'time_to_landing': _ttl} + 'time_to_landing': _ttl, + 'server_time':time.time()} current_payloads[_callsign]['path'].append([_lat, _lon, _alt]) @@ -211,6 +215,9 @@ def run_prediction(): _current_pos = current_payload_tracks[_payload].get_latest_state() _current_pos_list = [0,_current_pos['lat'], _current_pos['lon'], _current_pos['alt']] + _pred_ok = False + _abort_pred_ok = False + if _current_pos['is_descending']: _desc_rate = _current_pos['landing_rate'] else: @@ -256,9 +263,12 @@ def run_prediction(): current_payloads[_payload]['burst'] = _pred_output[_cur_idx] - + _pred_ok = True logging.info("Prediction Updated, %d data points." % len(_pred_path)) else: + current_payloads[_payload]['pred_path'] = [] + current_payloads[_payload]['pred_landing'] = [] + current_payloads[_payload]['burst'] = [] logging.error("Prediction Failed.") # Abort predictions @@ -286,7 +296,7 @@ def run_prediction(): current_payloads[_payload]['abort_path'] = _abort_pred_output current_payloads[_payload]['abort_landing'] = _abort_pred_output[-1] - + _abort_pred_ok = True logging.info("Abort Prediction Updated, %d data points." % len(_pred_path)) else: logging.error("Prediction Failed.") @@ -300,22 +310,23 @@ def run_prediction(): predictor_semaphore = False # Send the web client the updated prediction data. - _client_data = { - 'callsign': _payload, - 'pred_path': current_payloads[_payload]['pred_path'], - 'pred_landing': current_payloads[_payload]['pred_landing'], - 'burst': current_payloads[_payload]['burst'], - 'abort_path': current_payloads[_payload]['abort_path'], - 'abort_landing': current_payloads[_payload]['abort_landing'] - } - flask_emit_event('predictor_update', _client_data) + if _pred_ok or _abort_pred_ok: + _client_data = { + 'callsign': _payload, + 'pred_path': current_payloads[_payload]['pred_path'], + 'pred_landing': current_payloads[_payload]['pred_landing'], + 'burst': current_payloads[_payload]['burst'], + 'abort_path': current_payloads[_payload]['abort_path'], + 'abort_landing': current_payloads[_payload]['abort_landing'] + } + flask_emit_event('predictor_update', _client_data) def initPredictor(): - global predictor, predictor_thread, chasemapper_config + global predictor, predictor_thread, chasemapper_config, pred_settings try: from cusfpredict.predict import Predictor - from cusfpredict.utils import gfs_model_age + from cusfpredict.utils import gfs_model_age, available_gfs # Check if we have any GFS data _model_age = gfs_model_age(pred_settings['gfs_path']) @@ -323,18 +334,31 @@ def initPredictor(): logging.error("No GFS data in directory.") chasemapper_config['pred_model'] = "No GFS Data." flask_emit_event('predictor_model_update',{'model':"No GFS data."}) + chasemapper_config['pred_enabled'] = False else: - chasemapper_config['pred_model'] = _model_age - flask_emit_event('predictor_model_update',{'model':_model_age}) - predictor = Predictor(bin_path=pred_settings['pred_binary'], gfs_path=pred_settings['gfs_path']) + # Check model contains data to at least 4 hours into the future. + (_model_start, _model_end) = available_gfs(pred_settings['gfs_path']) + _model_now = datetime.utcnow() + timedelta(0,60*60*4) + if (_model_now < _model_start) or (_model_now > _model_end): + # No suitable GFS data! + logging.error("GFS Data in directory does not cover now!") + chasemapper_config['pred_model'] = "Old GFS Data." + flask_emit_event('predictor_model_update',{'model':"Old GFS data."}) + chasemapper_config['pred_enabled'] = False - # Start up the predictor thread. - predictor_thread = Thread(target=predictorThread) - predictor_thread.start() + else: + chasemapper_config['pred_model'] = _model_age + flask_emit_event('predictor_model_update',{'model':_model_age}) + predictor = Predictor(bin_path=pred_settings['pred_binary'], gfs_path=pred_settings['gfs_path']) - # Set the predictor to enabled, and update the clients. - chasemapper_config['pred_enabled'] = True - flask_emit_event('server_settings_update', chasemapper_config) + # Start up the predictor thread. + predictor_thread = Thread(target=predictorThread) + predictor_thread.start() + + # Set the predictor to enabled, and update the clients. + chasemapper_config['pred_enabled'] = True + + flask_emit_event('server_settings_update', chasemapper_config) except Exception as e: traceback.print_exc() @@ -426,18 +450,7 @@ def udp_listener_car_callback(data): if __name__ == "__main__": import argparse parser = argparse.ArgumentParser() - group = parser.add_mutually_exclusive_group() - parser.add_argument("-p","--port",default=5001,help="Port to run Web Server on.") - group.add_argument("--ozimux", action="store_true", default=False, help="Take payload input via OziMux (listen on port 8942).") - group.add_argument("--summary", action="store_true", default=False, help="Take payload input data via Payload Summary Broadcasts.") - parser.add_argument("--clamp", action="store_false", default=True, help="Clamp all tracks to ground.") - parser.add_argument("--nolabels", action="store_true", default=False, help="Inhibit labels on placemarks.") - parser.add_argument("--predict", action="store_true", help="Enable Flight Path Predictions.") - parser.add_argument("--predict_binary", type=str, default="./pred", help="Location of the CUSF predictor binary. Defaut = ./pred") - parser.add_argument("--burst_alt", type=float, default=30000.0, help="Expected Burst Altitude (m). Default = 30000") - parser.add_argument("--descent_rate", type=float, default=5.0, help="Expected Descent Rate (m/s, positive value). Default = 5.0") - parser.add_argument("--abort", action="store_true", default=False, help="Enable 'Abort' Predictions.") - parser.add_argument("--predict_rate", type=int, default=15, help="Run predictions every X seconds. Default = 15 seconds.") + parser.add_argument("-c", "--config", type=str, default="horusmapper.cfg", help="Configuration file.") parser.add_argument("-v", "--verbose", action="store_true", default=False, help="Verbose output.") args = parser.parse_args() @@ -455,32 +468,58 @@ if __name__ == "__main__": logging.getLogger('socketio').setLevel(logging.ERROR) logging.getLogger('engineio').setLevel(logging.ERROR) - if args.ozimux: + # Attempt to read in config file. + chasemapper_config = read_config(args.config) + # Die if we cannot read a valid config file. + if chasemapper_config == None: + logging.critical("Could not read any configuration data. Exiting") + sys.exit(1) + + # Copy out the predictor settings to another dictionary. + pred_settings = { + 'pred_binary': chasemapper_config['pred_binary'], + 'gfs_path': chasemapper_config['pred_gfs_directory'], + 'model_download': chasemapper_config['pred_model_download'] + } + + running_threads = [] + + # Start up the primary data source + if chasemapper_config['data_source'] == "ozimux": logging.info("Using OziMux data source.") - _listener = OziListener(telemetry_callback=ozi_listener_callback) + _ozi_listener = OziListener(telemetry_callback=ozi_listener_callback, port=chasemapper_config['ozimux_port']) + running_threads.append(_ozi_listener) # Start up UDP Broadcast Listener (which we use for car positions even if not for the payload) - if args.summary: - logging.info("Using Payload Summary data source.") - _broadcast_listener = UDPListener(summary_callback=udp_listener_summary_callback, - gps_callback=udp_listener_car_callback) - else: - _broadcast_listener = UDPListener(summary_callback=None, - gps_callback=udp_listener_car_callback) + if (chasemapper_config['data_source'] == "horus_udp") or (chasemapper_config['car_gps_source'] == "horus_udp"): + _horus_udp_port = chasemapper_config['horus_udp_port'] + if chasemapper_config['data_source'] == "horus_udp": + _summary_callback = udp_listener_summary_callback + else: + _summary_callback = None - _broadcast_listener.start() + if chasemapper_config['car_gps_source'] == "horus_udp": + _gps_callback = udp_listener_car_callback + else: + _gps_callback = None - if args.predict: + logging.info("Starting Horus UDP Listener") + _horus_udp_listener = UDPListener(summary_callback=_summary_callback, + gps_callback=_gps_callback) + _horus_udp_listener.start() + running_threads.append(_horus_udp_listener) + + + if chasemapper_config['pred_enabled']: initPredictor() # Run the Flask app, which will block until CTRL-C'd. - socketio.run(app, host='0.0.0.0', port=args.port) - - # Attempt to close the listener. - try: - predictor_thread_running = False - _broadcast_listener.close() - _listener.close() - except: - pass + socketio.run(app, host=chasemapper_config['flask_host'], port=chasemapper_config['flask_port']) + # Attempt to close the listeners. + predictor_thread_running = False + for _thread in running_threads: + try: + _thread.close() + except Exception as e: + logging.error("Error closing thread.") diff --git a/templates/index.html b/templates/index.html index 33442d8..0302bc9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -475,6 +475,7 @@ var temp_data = {}; temp_data.telem = data; temp_data.path = [data.position]; + temp_data.burst = []; temp_data.pred_path = []; temp_data.pred_landing = []; temp_data.abort_path = []; @@ -550,21 +551,21 @@ // Add the landing marker if it doesnt exist. if (balloon_positions[_callsign].pred_marker == null){ - balloon_positions[callsign].pred_marker = L.marker(data.pred_landing,{title:callsign + " Landing", icon: balloonLandingIcons[balloon_positions[callsign].colour]}) - .bindTooltip(callsign + " Landing",{permanent:false,direction:'right'}) + balloon_positions[_callsign].pred_marker = L.marker(data.pred_landing,{title:_callsign + " Landing", icon: balloonLandingIcons[balloon_positions[_callsign].colour]}) + .bindTooltip(_callsign + " Landing",{permanent:false,direction:'right'}) .addTo(map); }else{ - balloon_positions[callsign].pred_marker.setLatLng(data.pred_landing); + balloon_positions[_callsign].pred_marker.setLatLng(data.pred_landing); } if(data.burst.length == 3){ // There is burst data! if (balloon_positions[_callsign].burst_marker == null){ - var _burst_txt = callsign + "Burst (" + data.burst[2].toFixed(0) + "m)"; - balloon_positions[callsign].burst_marker = L.marker(data.burst,{title:_burst_txt, icon: burstIcon}) + var _burst_txt = _callsign + "Burst (" + data.burst[2].toFixed(0) + "m)"; + balloon_positions[_callsign].burst_marker = L.marker(data.burst,{title:_burst_txt, icon: burstIcon}) .bindTooltip(_burst_txt,{permanent:false,direction:'right'}) .addTo(map); }else{ - balloon_positions[callsign].burst_marker.setLatLng(data.burst); + balloon_positions[_callsign].burst_marker.setLatLng(data.burst); } }else{ // No burst data, or we are in descent. @@ -579,13 +580,13 @@ if (data.abort_landing.length == 3){ // Only update the abort data if there is actually abort data to show. if (balloon_positions[_callsign].abort_marker == null){ - balloon_positions[callsign].abort_marker = L.marker(data.abort_landing,{title:callsign + " Abort", icon: abortIcon}) - .bindTooltip(callsign + " Abort Landing",{permanent:false,direction:'right'}); + balloon_positions[_callsign].abort_marker = L.marker(data.abort_landing,{title:_callsign + " Abort", icon: abortIcon}) + .bindTooltip(_callsign + " Abort Landing",{permanent:false,direction:'right'}); if(chase_config.show_abort == true){ - balloon_positions[callsign].abort_marker.addTo(map); + balloon_positions[_callsign].abort_marker.addTo(map); } }else{ - balloon_positions[callsign].abort_marker.setLatLng(data.abort_landing); + balloon_positions[_callsign].abort_marker.setLatLng(data.abort_landing); } balloon_positions[_callsign].abort_path.setLatLngs(data.abort_path); @@ -594,7 +595,7 @@ balloon_positions[_callsign].abort_path.setLatLngs([]); if (balloon_positions[_callsign].abort_marker != null){ - balloon_positions[callsign].abort_marker.remove(); + balloon_positions[_callsign].abort_marker.remove(); } } });