diff --git a/chasemapper.py b/chasemapper.py index efacee9..17d5624 100644 --- a/chasemapper.py +++ b/chasemapper.py @@ -6,9 +6,13 @@ # Released under GNU GPL v3 or later # import json +import logging import flask from flask_socketio import SocketIO +import sys import time +import traceback +from threading import Thread from datetime import datetime from dateutil.parser import parse from horuslib import * @@ -37,12 +41,15 @@ chasemapper_config = { 'default_lon': 138.6, # Predictor settings - 'pred_enabled': True, # Enable running and display of predicted flight paths. + '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_asc_rate': 5.0, + 'pred_model': "No Data", '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*) + 'show_abort': True, # Show a prediction of an 'abort' paths (i.e. if the balloon bursts *now*) + 'pred_binary': "./pred", + 'gfs_path': "./gfs/", + 'predictor_update_rate': 15 # Update predictor every 15 seconds. } # Payload data Stores @@ -79,7 +86,6 @@ def flask_emit_event(event_name="none", data={}): socketio.emit(event_name, data, namespace='/chasemapper') - def handle_new_payload_position(data): _lat = data['lat'] @@ -99,6 +105,7 @@ def handle_new_payload_position(data): 'path': [], 'pred_path': [], 'pred_landing': [], + 'burst': [], 'abort_path': [], 'abort_landing': [] } @@ -151,13 +158,183 @@ def handle_new_payload_position(data): flask_emit_event('telemetry_event', current_payloads[_callsign]['telem']) +# +# Predictor Code +# +predictor = None + +predictor_thread_running = True +predictor_thread = None +def predictorThread(): + """ Run the predictor on a regular interval """ + global predictor_thread_running, chasemapper_config + logging.info("Predictor loop started.") + + while predictor_thread_running: + run_prediction() + for i in range(int(chasemapper_config['predictor_update_rate'])): + time.sleep(1) + if predictor_thread_running == False: + return + + +def run_prediction(): + ''' Run a Flight Path prediction ''' + global chasemapper_config, current_payloads, current_payload_tracks, predictor + + if predictor == None: + return + + for _payload in current_payload_tracks: + + _current_pos = current_payload_tracks[_payload].get_latest_state() + _current_pos_list = [0,_current_pos['lat'], _current_pos['lon'], _current_pos['alt']] + + if _current_pos['is_descending']: + _desc_rate = _current_pos['landing_rate'] + else: + _desc_rate = chasemapper_config['pred_desc_rate'] + + if _current_pos['alt'] > chasemapper_config['pred_burst']: + _burst_alt = _current_pos['alt'] + 100 + else: + _burst_alt = chasemapper_config['pred_burst'] + + logging.info("Running Predictor for: %s." % _payload) + _pred_path = predictor.predict( + launch_lat=_current_pos['lat'], + launch_lon=_current_pos['lon'], + launch_alt=_current_pos['alt'], + ascent_rate=_current_pos['ascent_rate'], + descent_rate=_desc_rate, + burst_alt=_burst_alt, + launch_time=_current_pos['time'], + descent_mode=_current_pos['is_descending']) + + if len(_pred_path) > 1: + # Valid Prediction! + _pred_path.insert(0,_current_pos_list) + # Convert from predictor output format to a polyline. + _pred_output = [] + for _point in _pred_path: + _pred_output.append([_point[1], _point[2], _point[3]]) + + current_payloads[_payload]['pred_path'] = _pred_output + current_payloads[_payload]['pred_landing'] = _pred_output[-1] + + if _current_pos['is_descending']: + current_payloads[_payload]['burst'] = [] + else: + # Determine the burst position. + _cur_alt = 0.0 + _cur_idx = 0 + for i in range(len(_pred_output)): + if _pred_output[i][2]>_cur_alt: + _cur_alt = _pred_output[i][2] + _cur_idx = i + + current_payloads[_payload]['burst'] = _pred_output[_cur_idx] + + + logging.info("Prediction Updated, %d data points." % len(_pred_path)) + else: + logging.error("Prediction Failed.") + + # if _run_abort_prediction and (_current_pos['alt'] < burst_alt) and (_current_pos['is_descending'] == False): + # print("Running Abort Prediction... ") + # _pred_path = _predictor.predict( + # launch_lat=_current_pos['lat'], + # launch_lon=_current_pos['lon'], + # launch_alt=_current_pos['alt'], + # ascent_rate=_current_pos['ascent_rate'], + # descent_rate=_desc_rate, + # burst_alt=_current_pos['alt']+200, + # launch_time=_current_pos['time']) + + # if len(_pred_path) > 1: + # _pred_path.insert(0,_current_pos_list) + # _abort_prediction = _pred_path + # _abort_prediction_valid = True + # print("Abort Prediction Updated, %d points." % len(_pred_path)) + # else: + # print("Prediction Failed.") + # else: + # _abort_prediction_valid = False + + # # If have been asked to run an abort prediction, but we are descent, set the is_valid + # # flag to false, so the abort prediction is not plotted. + # if _run_abort_prediction and _current_pos['is_descending']: + # _abort_prediction_valid == False + + _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': [], + 'abort_landing': [] + } + flask_emit_event('predictor_update', _client_data) + + +def initPredictor(): + global predictor, predictor_thread, chasemapper_config + try: + from cusfpredict.predict import Predictor + from cusfpredict.utils import gfs_model_age + + # Check if we have any GFS data + _model_age = gfs_model_age(chasemapper_config['gfs_path']) + if _model_age == "Unknown": + logging.error("No GFS data in directory.") + chasemapper_config['pred_model'] = "No GFS Data." + flask_emit_event('predictor_model_update',{'model':"No GFS data."}) + else: + chasemapper_config['pred_model'] = _model_age + flask_emit_event('predictor_model_update',{'model':_model_age}) + predictor = Predictor(bin_path=chasemapper_config['pred_binary'], gfs_path=chasemapper_config['gfs_path']) + + # Start up the predictor thread. + predictor_thread = Thread(target=predictorThread) + predictor_thread.start() + + except Exception as e: + traceback.print_exc() + logging.error("Loading predictor failed: " + str(e)) + flask_emit_event('predictor_model_update',{'model':"Failed - Check Log."}) + chasemapper_config['pred_model'] = "Failed - Check Log." + print("Loading Predictor failed.") + predictor = None + + +@socketio.on('download_model', namespace='/chasemapper') +def download_new_model(data): + """ Trigger a download of a new weather model """ + pass + # TODO + +# Incoming telemetry handlers + +def ozi_listener_callback(data): + """ Handle a OziMux input message """ + # OziMux message contains: + # {'lat': -34.87915, 'comment': 'Telemetry Data', 'alt': 26493.0, 'lon': 139.11883, 'time': datetime.datetime(2018, 7, 16, 10, 55, 49, tzinfo=tzutc())} + logging.info("OziMux Data:" + str(data)) + output = {} + output['lat'] = data['lat'] + output['lon'] = data['lon'] + output['alt'] = data['alt'] + output['callsign'] = "Payload" + output['time_dt'] = data['time'] + + handle_new_payload_position(output) def udp_listener_summary_callback(data): ''' Handle a Payload Summary Message from UDPListener ''' global current_payloads, current_payload_tracks # Extract the fields we need. - print("SUMMARY:" + str(data)) + logging.info("Payload Summary Data: " + str(data)) # Convert to something generic we can pass onwards. output = {} @@ -177,12 +354,10 @@ def udp_listener_summary_callback(data): handle_new_payload_position(output) - - def udp_listener_car_callback(data): ''' Handle car position data ''' global car_track - print("CAR:" + str(data)) + logging.debug("Car Position:" + str(data)) _lat = data['latitude'] _lon = data['longitude'] _alt = data['altitude'] @@ -206,16 +381,16 @@ def udp_listener_car_callback(data): flask_emit_event('telemetry_event', {'callsign': 'CAR', 'position':[_lat,_lon,_alt], 'vel_v':0.0, 'heading': _heading}) +# Add other listeners here... + 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=True, help="Take payload input data via Payload Summary Broadcasts.") + 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.") @@ -224,11 +399,30 @@ if __name__ == "__main__": 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("-v", "--verbose", action="store_true", default=False, help="Verbose output.") args = parser.parse_args() + # Configure logging + if args.verbose: + _log_level = logging.DEBUG + else: + _log_level = logging.INFO + + logging.basicConfig(format='%(asctime)s %(levelname)s:%(message)s', stream=sys.stdout, level=_log_level) + # Make flask & socketio only output errors, not every damn GET request. + logging.getLogger("requests").setLevel(logging.CRITICAL) + logging.getLogger("urllib3").setLevel(logging.CRITICAL) + logging.getLogger('werkzeug').setLevel(logging.ERROR) + logging.getLogger('socketio').setLevel(logging.ERROR) + logging.getLogger('engineio').setLevel(logging.ERROR) + + if args.ozimux: + logging.info("Using OziMux data source.") + _listener = OziListener(telemetry_callback=ozi_listener_callback) + # Start up UDP Broadcast Listener (which we use for car positions even if not for the payload) if args.summary: - print("Using Payload Summary Messages.") + logging.info("Using Payload Summary data source.") _broadcast_listener = UDPListener(summary_callback=udp_listener_summary_callback, gps_callback=udp_listener_car_callback) else: @@ -237,9 +431,17 @@ if __name__ == "__main__": _broadcast_listener.start() + if args.predict: + 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. - _broadcast_listener.close() + try: + predictor_thread_running = False + _broadcast_listener.close() + _listener.close() + except: + pass diff --git a/static/css/chasemapper.css b/static/css/chasemapper.css index bb5bb68..7fe6266 100644 --- a/static/css/chasemapper.css +++ b/static/css/chasemapper.css @@ -57,6 +57,12 @@ html, body, #map { font-size:5em; } +.dataAgeHeader { + color:black; + font-weight: bold; + text-decoration: underline; + font-size:1em; +} .dataAgeOK { color:black; font-weight: bold; @@ -66,4 +72,16 @@ html, body, #map { color:red; font-weight: bold; font-size:1.5em; +} + +#followPayloadButton { + width:45px; + height:45px; + font-size:20px; +} + +#followCarButton { + width:45px; + height:45px; + font-size:20px; } \ No newline at end of file diff --git a/static/css/easy-button.css b/static/css/easy-button.css new file mode 100644 index 0000000..18ce9ac --- /dev/null +++ b/static/css/easy-button.css @@ -0,0 +1,56 @@ +.leaflet-bar button, +.leaflet-bar button:hover { + background-color: #fff; + border: none; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; +} + +.leaflet-bar button { + background-position: 50% 50%; + background-repeat: no-repeat; + overflow: hidden; + display: block; +} + +.leaflet-bar button:hover { + background-color: #f4f4f4; +} + +.leaflet-bar button:first-of-type { + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +.leaflet-bar button:last-of-type { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; +} + +.leaflet-bar.disabled, +.leaflet-bar button.disabled { + cursor: default; + pointer-events: none; + opacity: .4; +} + +.easy-button-button .button-state{ + display: block; + width: 100%; + height: 100%; + position: relative; +} + + +.leaflet-touch .leaflet-bar button { + width: 30px; + height: 30px; + line-height: 30px; +} diff --git a/static/js/config.js b/static/js/config.js index 684f3df..2023280 100644 --- a/static/js/config.js +++ b/static/js/config.js @@ -1,2 +1,10 @@ // Configuration Setting Handler. // Mark Jessop 2018-07 + + +// Global map settings +var prediction_opacity = 0.6; +var parachute_min_alt = 300; // Show the balloon as a 'landed' payload below this altitude. + +var car_bad_age = 5.0; +var payload_bad_age = 30.0; diff --git a/static/js/easy-button.js b/static/js/easy-button.js new file mode 100644 index 0000000..fbd300f --- /dev/null +++ b/static/js/easy-button.js @@ -0,0 +1,371 @@ +(function(){ + +// This is for grouping buttons into a bar +// takes an array of `L.easyButton`s and +// then the usual `.addTo(map)` +L.Control.EasyBar = L.Control.extend({ + + options: { + position: 'topleft', // part of leaflet's defaults + id: null, // an id to tag the Bar with + leafletClasses: true // use leaflet classes? + }, + + + initialize: function(buttons, options){ + + if(options){ + L.Util.setOptions( this, options ); + } + + this._buildContainer(); + this._buttons = []; + + for(var i = 0; i < buttons.length; i++){ + buttons[i]._bar = this; + buttons[i]._container = buttons[i].button; + this._buttons.push(buttons[i]); + this.container.appendChild(buttons[i].button); + } + + }, + + + _buildContainer: function(){ + this._container = this.container = L.DomUtil.create('div', ''); + this.options.leafletClasses && L.DomUtil.addClass(this.container, 'leaflet-bar easy-button-container leaflet-control'); + this.options.id && (this.container.id = this.options.id); + }, + + + enable: function(){ + L.DomUtil.addClass(this.container, 'enabled'); + L.DomUtil.removeClass(this.container, 'disabled'); + this.container.setAttribute('aria-hidden', 'false'); + return this; + }, + + + disable: function(){ + L.DomUtil.addClass(this.container, 'disabled'); + L.DomUtil.removeClass(this.container, 'enabled'); + this.container.setAttribute('aria-hidden', 'true'); + return this; + }, + + + onAdd: function () { + return this.container; + }, + + addTo: function (map) { + this._map = map; + + for(var i = 0; i < this._buttons.length; i++){ + this._buttons[i]._map = map; + } + + var container = this._container = this.onAdd(map), + pos = this.getPosition(), + corner = map._controlCorners[pos]; + + L.DomUtil.addClass(container, 'leaflet-control'); + + if (pos.indexOf('bottom') !== -1) { + corner.insertBefore(container, corner.firstChild); + } else { + corner.appendChild(container); + } + + return this; + } + +}); + +L.easyBar = function(){ + var args = [L.Control.EasyBar]; + for(var i = 0; i < arguments.length; i++){ + args.push( arguments[i] ); + } + return new (Function.prototype.bind.apply(L.Control.EasyBar, args)); +}; + +// L.EasyButton is the actual buttons +// can be called without being grouped into a bar +L.Control.EasyButton = L.Control.extend({ + + options: { + position: 'topleft', // part of leaflet's defaults + + id: null, // an id to tag the button with + + type: 'replace', // [(replace|animate)] + // replace swaps out elements + // animate changes classes with all elements inserted + + states: [], // state names look like this + // { + // stateName: 'untracked', + // onClick: function(){ handle_nav_manually(); }; + // title: 'click to make inactive', + // icon: 'fa-circle', // wrapped with + // } + + leafletClasses: true, // use leaflet styles for the button + tagName: 'button', + }, + + + + initialize: function(icon, onClick, title, id){ + + // clear the states manually + this.options.states = []; + + // add id to options + if(id != null){ + this.options.id = id; + } + + // storage between state functions + this.storage = {}; + + // is the last item an object? + if( typeof arguments[arguments.length-1] === 'object' ){ + + // if so, it should be the options + L.Util.setOptions( this, arguments[arguments.length-1] ); + } + + // if there aren't any states in options + // use the early params + if( this.options.states.length === 0 && + typeof icon === 'string' && + typeof onClick === 'function'){ + + // turn the options object into a state + this.options.states.push({ + icon: icon, + onClick: onClick, + title: typeof title === 'string' ? title : '' + }); + } + + // curate and move user's states into + // the _states for internal use + this._states = []; + + for(var i = 0; i < this.options.states.length; i++){ + this._states.push( new State(this.options.states[i], this) ); + } + + this._buildButton(); + + this._activateState(this._states[0]); + + }, + + _buildButton: function(){ + + this.button = L.DomUtil.create(this.options.tagName, ''); + + if (this.options.tagName === 'button') { + this.button.setAttribute('type', 'button'); + } + + if (this.options.id ){ + this.button.id = this.options.id; + } + + if (this.options.leafletClasses){ + L.DomUtil.addClass(this.button, 'easy-button-button leaflet-bar-part leaflet-interactive'); + } + + // don't let double clicks and mousedown get to the map + L.DomEvent.addListener(this.button, 'dblclick', L.DomEvent.stop); + L.DomEvent.addListener(this.button, 'mousedown', L.DomEvent.stop); + L.DomEvent.addListener(this.button, 'mouseup', L.DomEvent.stop); + + // take care of normal clicks + L.DomEvent.addListener(this.button,'click', function(e){ + L.DomEvent.stop(e); + this._currentState.onClick(this, this._map ? this._map : null ); + this._map && this._map.getContainer().focus(); + }, this); + + // prep the contents of the control + if(this.options.type == 'replace'){ + this.button.appendChild(this._currentState.icon); + } else { + for(var i=0;i"']/) ){ + + // if so, the user should have put in html + // so move forward as such + tmpIcon = ambiguousIconString; + + // then it wasn't html, so + // it's a class list, figure out what kind + } else { + ambiguousIconString = ambiguousIconString.replace(/(^\s*|\s*$)/g,''); + tmpIcon = L.DomUtil.create('span', ''); + + if( ambiguousIconString.indexOf('fa-') === 0 ){ + L.DomUtil.addClass(tmpIcon, 'fa ' + ambiguousIconString) + } else if ( ambiguousIconString.indexOf('glyphicon-') === 0 ) { + L.DomUtil.addClass(tmpIcon, 'glyphicon ' + ambiguousIconString) + } else { + L.DomUtil.addClass(tmpIcon, /*rollwithit*/ ambiguousIconString) + } + + // make this a string so that it's easy to set innerHTML below + tmpIcon = tmpIcon.outerHTML; + } + + return tmpIcon; +} + +})(); diff --git a/static/js/utils.js b/static/js/utils.js index 7c3eaf9..a1091ea 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -54,9 +54,55 @@ var carIcon = L.icon({ iconAnchor: [27,12] // Revisit this }); -// Other Global map settings -var prediction_opacity = 0.6; -var parachute_min_alt = 300; // Show the balloon as a 'landed' payload below this altitude. -var car_bad_age = 5.0; -var payload_bad_age = 30.0; \ No newline at end of file +// calculates look angles between two points +// format of a and b should be {lon: 0, lat: 0, alt: 0} +// returns {elevention: 0, azimut: 0, bearing: "", range: 0} +// +// based on earthmath.py +// Copyright 2012 (C) Daniel Richman; GNU GPL 3 + +var DEG_TO_RAD = Math.PI / 180.0; +var EARTH_RADIUS = 6371000.0; + +function calculate_lookangles(a, b) { + // degrees to radii + a.lat = a.lat * DEG_TO_RAD; + a.lon = a.lon * DEG_TO_RAD; + b.lat = b.lat * DEG_TO_RAD; + b.lon = b.lon * DEG_TO_RAD; + + var d_lon = b.lon - a.lon; + var sa = Math.cos(b.lat) * Math.sin(d_lon); + var sb = (Math.cos(a.lat) * Math.sin(b.lat)) - (Math.sin(a.lat) * Math.cos(b.lat) * Math.cos(d_lon)); + var bearing = Math.atan2(sa, sb); + var aa = Math.sqrt(Math.pow(sa, 2) + Math.pow(sb, 2)); + var ab = (Math.sin(a.lat) * Math.sin(b.lat)) + (Math.cos(a.lat) * Math.cos(b.lat) * Math.cos(d_lon)); + var angle_at_centre = Math.atan2(aa, ab); + var great_circle_distance = angle_at_centre * EARTH_RADIUS; + + ta = EARTH_RADIUS + a.alt; + tb = EARTH_RADIUS + b.alt; + ea = (Math.cos(angle_at_centre) * tb) - ta; + eb = Math.sin(angle_at_centre) * tb; + var elevation = Math.atan2(ea, eb) / DEG_TO_RAD; + + // Use Math.coMath.sine rule to find unknown side. + var distance = Math.sqrt(Math.pow(ta, 2) + Math.pow(tb, 2) - 2 * tb * ta * Math.cos(angle_at_centre)); + + // Give a bearing in range 0 <= b < 2pi + bearing += (bearing < 0) ? 2 * Math.PI : 0; + bearing /= DEG_TO_RAD; + + var value = Math.round(bearing % 90); + value = ((bearing > 90 && bearing < 180) || (bearing > 270 && bearing < 360)) ? 90 - value : value; + + var str_bearing = "" + ((bearing < 90 || bearing > 270) ? 'N' : 'S')+ " " + value + '° ' + ((bearing < 180) ? 'E' : 'W'); + + return { + 'elevation': elevation, + 'azimuth': bearing, + 'range': distance, + 'bearing': str_bearing + }; +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index fcf9511..b99d004 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,6 +7,7 @@ + @@ -20,10 +21,12 @@ + + @@ -43,16 +46,18 @@ // Default prediction settings (actual values will be used once the flight is underway) pred_desc_rate: 6.0, pred_burst: 28000, + pred_model: 'Disabled', show_abort: true, // Show a prediction of an 'abort' paths (i.e. if the balloon bursts *now*) }; // Object which will contain balloon markers and traces. - // properties for each key in this object (key = sonde ID) + // properties for each key in this object (key = callsign) // latest_data - latest sonde telemetry object from SondeDecoded // marker: Leaflet marker object. // path: Leaflet polyline object. // pred_marker: Leaflet marker for predicted landing position. // pred_path: Leaflet polyline object for predicted path. + // burst_marker: Leaflet marker for burst prediction. // abort_marker: Leaflet marker for abort landing prediction. // abort_path: Leaflet marker for abort prediction track. var balloon_positions = {}; @@ -93,6 +98,10 @@ async: false, // Yes, this is deprecated... success: function(data) { chase_config = data; + // Update a few fields based on this data. + $("#predictorModel").html("Current Model: " + chase_config.pred_model); + $('#burstAlt').val(chase_config.pred_burst.toFixed(0)); + $('#descentRate').val(chase_config.pred_desc_rate.toFixed(1)); } }); @@ -163,7 +172,7 @@ // Data age display - shows how old the various datasets are. L.control.custom({ position: 'topleft', - content : "
", + content : "
Data Age
", classes : 'btn-group-vertical btn-group-sm', id: 'age_display', style : @@ -190,6 +199,20 @@ }) .addTo(map); + // Follow buttons - these just set the radio buttons on the settings pane. + L.easyButton('fa-paper-plane', function(btn, map){ + $('input:radio[name=autoFollow]').val(['payload']); + }, 'Follow Payload', 'followPayloadButton', { + position: 'topright' + } + ).addTo(map); + + L.easyButton('fa-car', function(btn, map){ + $('input:radio[name=autoFollow]').val(['car']); + }, 'Follow Chase Car', 'followCarButton', { + position: 'topright' + } + ).addTo(map); function mapMovedEvent(e){ @@ -262,6 +285,15 @@ balloon_positions[callsign].pred_marker = null; } + // Burst position marker + // Only add if there is data to show + if (data.burst.length == 3){ + balloon_positions[callsign].burst_marker = L.marker(data.burst,{title:callsign + " Burst", icon: burstIcon}) + .bindTooltip(callsign + " Burst",{permanent:false,direction:'right'}) + .addTo(map); + } else{ + balloon_positions[callsign].burst_marker = null; + } // Abort path balloon_positions[callsign].abort_path = L.polyline(data.abort_path,{title:callsign + " Abort Prediction", color:'red', opacity:prediction_opacity}); @@ -306,6 +338,46 @@ }); + function updateSummaryDisplay(){ + // Update the 'Payload Summary' display. + var _summary_update = {}; + // See if there is any payload data. + if (balloon_positions.hasOwnProperty(balloon_currently_following) == true){ + // There is balloon data! + var _latest_telem = balloon_positions[balloon_currently_following].latest_data; + + _summary_update.alt = _latest_telem.position[2].toFixed(0) + "m"; + var _speed = _latest_telem.speed*3.6; + _summary_update.speed = _speed.toFixed(0) + " kph"; + _summary_update.vel_v = _latest_telem.vel_v.toFixed(1) + " m/s"; + + + if (chase_car_position.latest_data.length == 3){ + // We have a chase car position! Calculate relative position. + var _bal = {lat:_latest_telem.position[0], lon:_latest_telem.position[1], alt:_latest_telem.position[2]}; + var _car = {lat:chase_car_position.latest_data[0], lon:chase_car_position.latest_data[1], alt:chase_car_position.latest_data[2]}; + + var _look_angles = calculate_lookangles(_car, _bal); + + _summary_update.elevation = _look_angles.elevation.toFixed(0) + "°"; + _summary_update.azimuth = _look_angles.azimuth.toFixed(0) + "°"; + _summary_update.range = (_look_angles.range/1000).toFixed(1) + "km"; + }else{ + // No Chase car position data - insert dummy values + _summary_update.azimuth = "---°"; + _summary_update.elevation = "--°"; + _summary_update.range = "----m"; + } + + }else{ + // No balloon data! + _summary_update = {alt:'-----m', speed:'---kph', vel_v:'-.-m/s', azimuth:'---°', elevation:'--°', range:'----m'} + } + // Update table + $("#summary_table").tabulator("setData", [_summary_update]); + } + + // Telemetry event handler. // We will get one of these with every new balloon position socket.on('telemetry_event', function(data) { @@ -394,25 +466,6 @@ if (balloon_currently_following = data.callsign){ $('#time_to_landing').text(data.time_to_landing); - // Generate data to update summary display with. - var _summary_update = {}; - _summary_update.alt = data.position[2].toFixed(0) + "m"; - var _speed = data.speed*3.6; - _summary_update.speed = _speed.toFixed(0) + " kph"; - _summary_update.vel_v = data.vel_v.toFixed(1) + " m/s"; - _summary_update.azimuth = "---°"; - _summary_update.elevation = "--°"; - _summary_update.range = "----m"; - - // Calculate az/el/range from Car position to balloon. - if (chase_car_position.latest_data.length == 3){ - // Calculate relative position. - } else{ - // Leave the values as they are. - } - // Update the summary table - $("#summary_table").tabulator("setData", [_summary_update]); - payload_data_age = 0.0; } } @@ -427,9 +480,79 @@ // Don't pan to anything. } + // Updat the summary display. + updateSummaryDisplay(); + }); + // Predictor Functions + socket.on('predictor_model_update', function(data){ + var _model_data = data.model; + $("#predictorModel").html("Current Model: " + _model_data); + }); + socket.on('predictor_update', function(data){ + // We expect the fields: callsign, pred_path, pred_landing, and abort_path and abort_landing, if abort predictions are enabled. + var _callsign = data.callsign; + var _pred_path = data.pred_path; + var _pred_landing = data.pred_landing; + + // 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'}) + .addTo(map); + }else{ + 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}) + .bindTooltip(_burst_txt,{permanent:false,direction:'right'}) + .addTo(map); + }else{ + balloon_positions[callsign].burst_marker.setLatLng(data.burst); + } + }else{ + // No burst data, or we are in descent. + if (balloon_positions[_callsign].burst_marker != null){ + // Remove the burst icon from the map. + balloon_positions[_callsign].burst_marker.remove(); + } + } + // Update the predicted path. + balloon_positions[_callsign].pred_path.setLatLngs(data.pred_path); + + if (data.hasOwnProperty("abort_landing") == true){ + // Only update the abort data if there is actually abort data to show. + if(data.abort_landing.length == 3){ + 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'}); + if(chase_config.show_abort == true){ + balloon_positions[callsign].abort_marker.addTo(map); + } + }else{ + balloon_positions[callsign].abort_marker.setLatLng(data.abort_landing); + } + + balloon_positions[_callsign].abort_path.setLatLngs(data.abort_path); + } + }else{ + // Clear out the abort and abort marker data. + balloon_positions[_callsign].abort_path.setLatLngs([]); + + if (balloon_positions[_callsign].abort_marker != null){ + balloon_positions[callsign].abort_marker.remove(); + } + } + }); + + $("#downloadModel").click(function(){ + socket.emit('download_model', {data: 'plzkthx'}); + }); // Tell the server we are connected and ready for data. socket.on('connect', function() { @@ -441,7 +564,6 @@ var age_update_rate = 500; window.setInterval(function () { // Update the data age displays. - payload_data_age += age_update_rate/1000.0; car_data_age += age_update_rate/1000.0; @@ -470,10 +592,6 @@ $("#car_age").addClass('dataAgeOK'); } } - - - - }, age_update_rate); @@ -520,6 +638,12 @@

Predictor

+
+ Current Model: Predictor Disabled +
+
+ Download Model +
Enable Predictions