diff --git a/MAVProxy/modules/lib/graph_ui.py b/MAVProxy/modules/lib/graph_ui.py index 2906a12767..03bd7cef5f 100644 --- a/MAVProxy/modules/lib/graph_ui.py +++ b/MAVProxy/modules/lib/graph_ui.py @@ -78,3 +78,53 @@ def set_xlim(self, xlim): return False self.xlim = xlim return True + + +class Histogram_UI(object): + '''UI class for displaying histograms from log data''' + + def __init__(self, mestate): + self.mestate = mestate + self.xlim = None + self.xlim_pipe = multiproc.Pipe() + + def display_histogram(self, graphdef, bins=50, show_stats=True): + '''display a histogram''' + if 'mestate' in globals(): + self.mestate.console.write("Histogram: %s\n" % ' '.join(graphdef.expression.split())) + else: + self.mestate.child_pipe_send_console.send("Histogram: %s\n" % ' '.join(graphdef.expression.split())) + + mh = grapher.MavHistogram() + if self.mestate.settings.title is not None: + mh.set_title(self.mestate.settings.title) + else: + mh.set_title(graphdef.name) + mh.set_bins(bins) + mh.set_show_stats(show_stats) + mh.set_condition(self.mestate.settings.condition) + mh.set_legend(self.mestate.settings.legend) + mh.add_mav(copy.copy(self.mestate.mlog)) + for f in graphdef.expression.split(): + mh.add_field(f) + mh.process(self.mestate.flightmode_selections, self.mestate.mlog._flightmodes) + lenmavlist = len(mh.mav_list) + mh.mav_list = [] + child = multiproc.Process(target=mh.show, args=[lenmavlist], kwargs={'xlim_pipe': self.xlim_pipe}) + child.start() + self.xlim_pipe[1].close() + self.mestate.mlog.rewind() + + def check_xlim_change(self): + '''histogram never drives xlim changes in other graphs''' + return None + + def set_xlim(self, xlim): + '''forward a time-range update to the histogram child process''' + if self.xlim_pipe is not None and self.xlim != xlim: + try: + self.xlim_pipe[0].send(xlim) + except IOError: + return False + self.xlim = xlim + return True diff --git a/MAVProxy/modules/lib/grapher.py b/MAVProxy/modules/lib/grapher.py index aee918f4f2..63236b45fc 100755 --- a/MAVProxy/modules/lib/grapher.py +++ b/MAVProxy/modules/lib/grapher.py @@ -17,6 +17,7 @@ from math import * from pymavlink.mavextra import * import matplotlib.pyplot as plt +import matplotlib.ticker from pymavlink import mavutil import threading import numpy as np @@ -798,6 +799,129 @@ def show(self, lenmavlist, block=True, xlim_pipe=None, output=None): else: plt.savefig(output, bbox_inches='tight', dpi=200) + +class MavHistogram(MavGraph): + '''Histogram of a single log data field with live time-range filtering.''' + + def __init__(self, flightmode_colourmap=None): + super().__init__(flightmode_colourmap) + self.bins = 50 + self.show_stats = True + self._all_x = None + self._all_y = None + + def set_bins(self, bins): + '''set number of histogram bins''' + self.bins = bins + + def set_show_stats(self, show_stats): + '''set whether to overlay median and std dev lines''' + self.show_stats = show_stats + + def _draw_histogram(self, y_data): + '''redraw histogram axes with the supplied data''' + self.ax1.cla() + label = self.fields[0] if self.fields else '' + title = self.title if self.title else 'Histogram: ' + label + self.ax1.set_title(title) + self.ax1.hist(y_data, bins=self.bins, color=colors[0], alpha=0.7, label=label, + weights=np.ones(len(y_data)) / len(y_data) * 100.0) + self.ax1.set_xlabel(label) + self.ax1.set_ylabel('Frequency (%)') + self.ax1.yaxis.set_major_formatter(matplotlib.ticker.FormatStrFormatter('%.1f%%')) + self.ax1.xaxis.set_major_locator(matplotlib.ticker.MaxNLocator(nbins=15)) + self.ax1.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) + self.ax1.tick_params(axis='x', which='major', labelrotation=45) + + if self.show_stats and len(y_data) > 0: + median = np.median(y_data) + mean = np.mean(y_data) + std = np.std(y_data) + + stat_styles = [ + (median, 'black', '-', 2.0, 'Median %.3g' % median), + (mean - std, 'orange', '--', 1.5, u'\u00b11\u03c3 (%.3g)' % std), + (mean + std, 'orange', '--', 1.5, None), + (mean - 2*std, 'red', ':', 1.5, u'\u00b12\u03c3 (%.3g)' % (2*std)), + (mean + 2*std, 'red', ':', 1.5, None), + (mean - 3*std, 'purple', '-.', 1.2, u'\u00b13\u03c3 (%.3g)' % (3*std)), + (mean + 3*std, 'purple', '-.', 1.2, None), + ] + for (xval, col, ls, lw, lbl) in stat_styles: + self.ax1.axvline(x=xval, color=col, linestyle=ls, + linewidth=lw, label=lbl) + + self.ax1.legend(loc=self.legend) + self.fig.tight_layout() + self.fig.canvas.draw_idle() + + def _hist_xlim_timer(self): + '''called every 100 ms to check for time-range updates from other graphs''' + if self.closing or self.xlim_pipe is None: + return + try: + if not self.xlim_pipe[1].poll(): + return + xlim = self.xlim_pipe[1].recv() + except Exception: + return + if xlim == self.xlim or xlim is None: + return + self.xlim = xlim + mask = (self._all_x >= xlim[0]) & (self._all_x <= xlim[1]) + y_filtered = self._all_y[mask] + if len(y_filtered) == 0: + return + self._draw_histogram(y_filtered) + + def show(self, lenmavlist, block=True, xlim_pipe=None, output=None): + '''show histogram plot in a new figure''' + if xlim_pipe is not None: + xlim_pipe[0].close() + self.xlim_pipe = xlim_pipe + + if not self.x or len(self.x[0]) == 0: + print("No data for histogram") + return + + self._all_x = np.array(self.x[0]) + self._all_y = np.array(self.y[0]) + + label = self.fields[0] if self.fields else '' + title = self.title if self.title else 'Histogram: ' + label + + interactive = output is None + if interactive: + plt.ion() + + self.fig, ax = plt.subplots(figsize=(10, 6)) + self.ax1 = ax + + self._draw_histogram(self._all_y) + + self.fig.canvas.get_default_filename = lambda: ''.join( + 'histogram' if self.title is None else + (c if c.isalnum() else '_' for c in title)) + '.png' + if self.fig.canvas.manager is not None: + self.fig.canvas.manager.set_window_title(title) + self.fig.canvas.mpl_connect('close_event', self.close_event) + + if output is None: + if xlim_pipe is not None: + self.xlim_t = self.fig.canvas.new_timer(interval=100) + self.xlim_t.add_callback(self._hist_xlim_timer) + self.xlim_t.start() + plt.draw() + plt.show(block=block) + elif output.endswith('.html'): + import mpld3 + html = mpld3.fig_to_html(self.fig) + with open(output, 'w') as f_out: + f_out.write(html) + else: + plt.savefig(output, bbox_inches='tight', dpi=200) + + if __name__ == "__main__": from argparse import ArgumentParser parser = ArgumentParser(description=__doc__) diff --git a/MAVProxy/tools/MAVExplorer.py b/MAVProxy/tools/MAVExplorer.py index 01d7de682e..0f6a51bffb 100755 --- a/MAVProxy/tools/MAVExplorer.py +++ b/MAVProxy/tools/MAVExplorer.py @@ -112,6 +112,8 @@ def __init__(self): MPSetting('sync_xmap', bool, True, 'sync X-axis zoom for map'), MPSetting('legend', str, 'upper left', 'legend position'), MPSetting('legend2', str, 'upper right', 'legend2 position'), + MPSetting('hist_bins', int, 50, 'histogram bin count', tab='Graph'), + MPSetting('hist_show_stats', bool, True, 'show median/std dev on histogram', tab='Graph'), MPSetting('axis_mode', str, 'auto', 'y-axis layout mode', choice=['auto', 'dual', 'multi']), MPSetting('title', str, None, 'Graph title'), @@ -131,6 +133,7 @@ def __init__(self): "condition" : ["(VARIABLE)"], "graph" : ['(VARIABLE) (VARIABLE) (VARIABLE) (VARIABLE) (VARIABLE) (VARIABLE) (VARIABLE) (VARIABLE) (VARIABLE) (VARIABLE) (VARIABLE) (VARIABLE)'], "graphs" : ['(PREDEFINED_GRAPH)'], + "histogram" : ['(VARIABLE) (VARIABLE) (VARIABLE) (VARIABLE)', '--bins (VARIABLE) (VARIABLE)'], "dump" : ['(MESSAGETYPE)', '--verbose (MESSAGETYPE)'], "map" : ['(VARIABLE) (VARIABLE) (VARIABLE) (VARIABLE) (VARIABLE)'], "param" : ['download', 'check', 'help (PARAMETER)', 'save', 'savechanged', 'diff', 'show', 'check'], @@ -260,6 +263,7 @@ def setup_menus(): items=[MPMenuItem('MagFit', 'MagFit', '# magfit'), MPMenuItem('Stats', 'Stats', '# stats'), MPMenuItem('FFT', 'FFT', '# fft'), + MPMenuItem('Histogram', 'Histogram', '# histogram'), MPMenuItem('Location Analysis', 'Location', '# locationAnalysis')])) mestate.console.set_menu(TopMenu, menu_callback) @@ -579,6 +583,48 @@ def cmd_graph(args): #print("initial: ", xlimits.last_xlim) grui[-1].set_xlim(xlimits.last_xlim) +def cmd_histogram(args): + '''plot histogram of a single log data field''' + usage = "usage: histogram [--bins N] " + if len(args) < 1: + print(usage) + return + + bins = mestate.settings.hist_bins + fields = [] + i = 0 + while i < len(args): + if args[i] == '--bins': + if i + 1 < len(args): + try: + bins = int(args[i+1]) + i += 2 + continue + except ValueError: + print("Invalid bins value: %s" % args[i+1]) + return + fields.append(args[i]) + i += 1 + + if not fields: + print(usage) + return + if len(fields) > 1: + print("histogram only accepts one field at a time (got: %s)" % ', '.join(fields)) + return + + check_vehicle_type() + from MAVProxy.modules.lib.graph_ui import Histogram_UI + expression = fields[0] + graphdef = GraphDefinition(mestate.settings.title, expression, '', [expression], None) + hui = Histogram_UI(mestate) + hui.display_histogram(graphdef, bins=bins, show_stats=mestate.settings.hist_show_stats) + grui.append(hui) + global xlimits + if xlimits.last_xlim is not None and mestate.settings.sync_xzoom: + grui[-1].set_xlim(xlimits.last_xlim) + + def cmd_graphs(args): '''graphs command''' usage = "usage: graphs " @@ -1743,6 +1789,7 @@ def main_loop(): command_map = { 'graph' : (cmd_graph, 'display a graph'), 'graphs' : (cmd_graphs, 'display a predefined graph'), + 'histogram' : (cmd_histogram, 'plot histogram of log data fields'), 'set' : (cmd_set, 'control settings'), 'reload' : (cmd_reload, 'reload graphs'), 'save' : (cmd_save, 'save a graph'),