Basic prediction support.

bearings
Mark Jessop 2018-07-16 22:56:48 +09:30
rodzic 2b75824188
commit b2d5f46859
7 zmienionych plików z 869 dodań i 44 usunięć

Wyświetl plik

@ -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

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -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;

Wyświetl plik

@ -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 <a>
// }
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<this._states.length;i++){
this.button.appendChild(this._states[i].icon);
}
}
},
_currentState: {
// placeholder content
stateName: 'unnamed',
icon: (function(){ return document.createElement('span'); })()
},
_states: null, // populated on init
state: function(newState){
// activate by name
if(typeof newState == 'string'){
this._activateStateNamed(newState);
// activate by index
} else if (typeof newState == 'number'){
this._activateState(this._states[newState]);
}
return this;
},
_activateStateNamed: function(stateName){
for(var i = 0; i < this._states.length; i++){
if( this._states[i].stateName == stateName ){
this._activateState( this._states[i] );
}
}
},
_activateState: function(newState){
if( newState === this._currentState ){
// don't touch the dom if it'll just be the same after
return;
} else {
// swap out elements... if you're into that kind of thing
if( this.options.type == 'replace' ){
this.button.appendChild(newState.icon);
this.button.removeChild(this._currentState.icon);
}
if( newState.title ){
this.button.title = newState.title;
} else {
this.button.removeAttribute('title');
}
// update classes for animations
for(var i=0;i<this._states.length;i++){
L.DomUtil.removeClass(this._states[i].icon, this._currentState.stateName + '-active');
L.DomUtil.addClass(this._states[i].icon, newState.stateName + '-active');
}
// update classes for animations
L.DomUtil.removeClass(this.button, this._currentState.stateName + '-active');
L.DomUtil.addClass(this.button, newState.stateName + '-active');
// update the record
this._currentState = newState;
}
},
enable: function(){
L.DomUtil.addClass(this.button, 'enabled');
L.DomUtil.removeClass(this.button, 'disabled');
this.button.setAttribute('aria-hidden', 'false');
return this;
},
disable: function(){
L.DomUtil.addClass(this.button, 'disabled');
L.DomUtil.removeClass(this.button, 'enabled');
this.button.setAttribute('aria-hidden', 'true');
return this;
},
onAdd: function(map){
var bar = L.easyBar([this], {
position: this.options.position,
leafletClasses: this.options.leafletClasses
});
this._anonymousBar = bar;
this._container = bar.container;
return this._anonymousBar.container;
},
removeFrom: function (map) {
if (this._map === map)
this.remove();
return this;
},
});
L.easyButton = function(/* args will pass automatically */){
var args = Array.prototype.concat.apply([L.Control.EasyButton],arguments);
return new (Function.prototype.bind.apply(L.Control.EasyButton, args));
};
/*************************
*
* util functions
*
*************************/
// constructor for states so only curated
// states end up getting called
function State(template, easyButton){
this.title = template.title;
this.stateName = template.stateName ? template.stateName : 'unnamed-state';
// build the wrapper
this.icon = L.DomUtil.create('span', '');
L.DomUtil.addClass(this.icon, 'button-state state-' + this.stateName.replace(/(^\s*|\s*$)/g,''));
this.icon.innerHTML = buildIcon(template.icon);
this.onClick = L.Util.bind(template.onClick?template.onClick:function(){}, easyButton);
}
function buildIcon(ambiguousIconString) {
var tmpIcon;
// does this look like html? (i.e. not a class)
if( ambiguousIconString.match(/[&;=<>"']/) ){
// 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;
}
})();

Wyświetl plik

@ -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;
// 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
};
}

Wyświetl plik

@ -7,6 +7,7 @@
<link href="{{ url_for('static', filename='css/leaflet.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/leaflet-sidebar.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/leaflet-control-topcenter.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/easy-button.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/tabulator_simple.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/font-awesome.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='css/chasemapper.css') }}" rel="stylesheet">
@ -20,10 +21,12 @@
<script src="{{ url_for('static', filename='js/leaflet-control-topcenter.js') }}"></script>
<script src="{{ url_for('static', filename='js/leaflet-sidebar.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/Leaflet.Control.Custom.js') }}"></script>
<script src="{{ url_for('static', filename='js/easy-button.js') }}"></script>
<script src="{{ url_for('static', filename='js/tabulator.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/utils.js') }}"></script>
<script src="{{ url_for('static', filename='js/tables.js') }}"></script>
<script src="{{ url_for('static', filename='js/config.js') }}"></script>
<!-- Leaflet plugins... -->
<script src="{{ url_for('static', filename='js/leaflet.rotatedMarker.js') }}"></script>
@ -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("<b>Current Model: </b>" + 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 : "<div id='payload_age' class='dataAgeOK'></div><div id='car_age' class='dataAgeOK'></div>",
content : "<div class='dataAgeHeader'>Data Age</div><div id='payload_age' class='dataAgeOK'></div><div id='car_age' class='dataAgeOK'></div>",
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("<b>Current Model: </b>" + _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 @@
</hr>
<h4>Predictor</h4>
<div class="paramRow" id="predictorModel">
<b>Current Model: </b> Predictor Disabled
</div>
<div class="paramRow">
<b>Download Model</b> <button type="button" class="paramSelector" id="downloadModel">Download</button>
</div>
<div class="paramRow">
<b>Enable Predictions</b> <input type="checkbox" class="paramSelector" id="predictorEnabled">
</div>