__init__.py
Go to the documentation of this file.
00001 #!/usr/bin/env python
00002 
00003 # Copyright (c) 2014, Austin Hendrix
00004 # Copyright (c) 2011, Dorian Scholz, TU Darmstadt
00005 # All rights reserved.
00006 #
00007 # Redistribution and use in source and binary forms, with or without
00008 # modification, are permitted provided that the following conditions
00009 # are met:
00010 #
00011 #   * Redistributions of source code must retain the above copyright
00012 #     notice, this list of conditions and the following disclaimer.
00013 #   * Redistributions in binary form must reproduce the above
00014 #     copyright notice, this list of conditions and the following
00015 #     disclaimer in the documentation and/or other materials provided
00016 #     with the distribution.
00017 #   * Neither the name of the TU Darmstadt nor the names of its
00018 #     contributors may be used to endorse or promote products derived
00019 #     from this software without specific prior written permission.
00020 #
00021 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
00022 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
00023 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
00024 # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
00025 # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
00026 # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
00027 # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
00028 # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
00029 # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
00030 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
00031 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
00032 # POSSIBILITY OF SUCH DAMAGE.
00033 
00034 import numpy
00035 
00036 from qt_gui_py_common.simple_settings_dialog import SimpleSettingsDialog
00037 from python_qt_binding.QtCore import Qt, qWarning, Signal
00038 from python_qt_binding.QtGui import QColor, QWidget, QHBoxLayout
00039 from rqt_py_common.ini_helper import pack, unpack
00040 
00041 try:
00042     from pyqtgraph_data_plot import PyQtGraphDataPlot
00043 except ImportError:
00044     PyQtGraphDataPlot = None
00045 
00046 try:
00047     from mat_data_plot import MatDataPlot
00048 except ImportError:
00049     MatDataPlot = None
00050 
00051 try:
00052     from qwt_data_plot import QwtDataPlot
00053 except ImportError:
00054     QwtDataPlot = None
00055 
00056 # separate class for DataPlot exceptions, just so that users can differentiate
00057 # errors from the DataPlot widget from exceptions generated by the underlying
00058 # libraries
00059 class DataPlotException(Exception):
00060     pass
00061 
00062 class DataPlot(QWidget):
00063     """A widget for displaying a plot of data
00064 
00065     The DataPlot widget displays a plot, on one of several plotting backends,
00066     depending on which backend(s) are available at runtime. It currently 
00067     supports PyQtGraph, MatPlot and QwtPlot backends.
00068 
00069     The DataPlot widget manages the plot backend internally, and can save
00070     and restore the internal state using `save_settings` and `restore_settings`
00071     functions.
00072 
00073     Currently, the user MUST call `restore_settings` before using the widget,
00074     to cause the creation of the enclosed plotting widget.
00075     """
00076     # plot types in order of priority
00077     plot_types = [
00078         {
00079             'title': 'PyQtGraph',
00080             'widget_class': PyQtGraphDataPlot,
00081             'description': 'Based on PyQtGraph\n- installer: http://luke.campagnola.me/code/pyqtgraph\n',
00082             'enabled': PyQtGraphDataPlot is not None,
00083         },
00084         {
00085             'title': 'MatPlot',
00086             'widget_class': MatDataPlot,
00087             'description': 'Based on MatPlotLib\n- needs most CPU\n- needs matplotlib >= 1.1.0\n- if using PySide: PySide > 1.1.0\n',
00088             'enabled': MatDataPlot is not None,
00089         },
00090         {
00091             'title': 'QwtPlot',
00092             'widget_class': QwtDataPlot,
00093             'description': 'Based on QwtPlot\n- does not use timestamps\n- uses least CPU\n- needs Python Qwt bindings\n',
00094             'enabled': QwtDataPlot is not None,
00095         },
00096     ]
00097 
00098     # pre-defined colors:
00099     RED=(255, 0, 0)
00100     GREEN=(0, 255, 0)
00101     BLUE=(0, 0, 255)
00102 
00103     SCALE_ALL=1
00104     SCALE_VISIBLE=2
00105     SCALE_EXTEND=4
00106 
00107     _colors = [Qt.blue, Qt.red, Qt.cyan, Qt.magenta, Qt.green, Qt.darkYellow, Qt.black, Qt.darkCyan, Qt.darkRed, Qt.gray]
00108 
00109     limits_changed = Signal()
00110     _redraw = Signal()
00111     _add_curve = Signal(str, str, 'QColor', bool)
00112 
00113     def __init__(self, parent=None):
00114         """Create a new, empty DataPlot
00115 
00116         This will raise a RuntimeError if none of the supported plotting
00117         backends can be found
00118         """
00119         super(DataPlot, self).__init__(parent)
00120         self._plot_index = 0
00121         self._color_index = 0
00122         self._markers_on = False
00123         self._autoscroll = True
00124 
00125         self._autoscale_x = True
00126         self._autoscale_y = DataPlot.SCALE_ALL
00127 
00128         # the backend widget that we're trying to hide/abstract
00129         self._data_plot_widget = None
00130         self._curves = {}
00131         self._vline = None
00132         self._redraw.connect(self._do_redraw)
00133 
00134         self._layout = QHBoxLayout()
00135         self.setLayout(self._layout)
00136 
00137         enabled_plot_types = [pt for pt in self.plot_types if pt['enabled']]
00138         if not enabled_plot_types:
00139             version_info = ' and PySide > 1.1.0' if QT_BINDING == 'pyside' else ''
00140             raise RuntimeError('No usable plot type found. Install at least one of: PyQtGraph, MatPlotLib (at least 1.1.0%s) or Python-Qwt5.' % version_info)
00141 
00142         self._switch_data_plot_widget(self._plot_index)
00143 
00144         self.show()
00145 
00146     def _switch_data_plot_widget(self, plot_index, markers_on=False):
00147         """Internal method for activating a plotting backend by index"""
00148         # check if selected plot type is available
00149         if not self.plot_types[plot_index]['enabled']:
00150             # find other available plot type
00151             for index, plot_type in enumerate(self.plot_types):
00152                 if plot_type['enabled']:
00153                     plot_index = index
00154                     break
00155 
00156         self._plot_index = plot_index
00157         self._markers_on = markers_on
00158         selected_plot = self.plot_types[plot_index]
00159 
00160         if self._data_plot_widget:
00161             x_limits = self.get_xlim()
00162             y_limits = self.get_ylim()
00163 
00164             self._layout.removeWidget(self._data_plot_widget)
00165             self._data_plot_widget.close()
00166             self._data_plot_widget = None
00167         else:
00168             x_limits = [0.0, 10.0]
00169             y_limits = [-0.001, 0.001]
00170 
00171         self._data_plot_widget = selected_plot['widget_class'](self)
00172         self._data_plot_widget.limits_changed.connect(self.limits_changed)
00173         self._add_curve.connect(self._data_plot_widget.add_curve)
00174         self._layout.addWidget(self._data_plot_widget)
00175 
00176         # restore old data
00177         for curve_id in self._curves:
00178             curve = self._curves[curve_id]
00179             self._data_plot_widget.add_curve(curve_id, curve['name'], curve['color'], markers_on)
00180 
00181         if self._vline:
00182             self.vline(*self._vline)
00183 
00184         self.set_xlim(x_limits)
00185         self.set_ylim(y_limits)
00186         self.redraw()
00187 
00188     def _switch_plot_markers(self, markers_on):
00189         self._markers_on = markers_on
00190         self._data_plot_widget._color_index = 0
00191 
00192         for curve_id in self._curves:
00193             self._data_plot_widget.remove_curve(curve_id)
00194             curve = self._curves[curve_id]
00195             self._data_plot_widget.add_curve(curve_id, curve['name'], curve['color'], markers_on)
00196 
00197         self.redraw()
00198 
00199     # interface out to the managing GUI component: get title, save, restore, 
00200     # etc
00201     def getTitle(self):
00202         """get the title of the current plotting backend"""
00203         return self.plot_types[self._plot_index]['title']
00204 
00205     def save_settings(self, plugin_settings, instance_settings):
00206         """Save the settings associated with this widget
00207 
00208         Currently, this is just the plot type, but may include more useful
00209         data in the future"""
00210         instance_settings.set_value('plot_type', self._plot_index)
00211         xlim = self.get_xlim()
00212         ylim = self.get_ylim()
00213         # convert limits to normal arrays of floats; some backends return numpy
00214         # arrays
00215         xlim = [float(x) for x in xlim]
00216         ylim = [float(y) for y in ylim]
00217         instance_settings.set_value('x_limits', pack(xlim))
00218         instance_settings.set_value('y_limits', pack(ylim))
00219 
00220     def restore_settings(self, plugin_settings, instance_settings):
00221         """Restore the settings for this widget
00222 
00223         Currently, this just restores the plot type."""
00224         self._switch_data_plot_widget(int(instance_settings.value('plot_type', 0)))
00225         xlim = unpack(instance_settings.value('x_limits', []))
00226         ylim = unpack(instance_settings.value('y_limits', []))
00227         if xlim:
00228             # convert limits to an array of floats; they're often lists of
00229             # strings
00230             try:
00231                 xlim = [float(x) for x in xlim]
00232                 self.set_xlim(xlim)
00233             except:
00234                 qWarning("Failed to restore X limits")
00235         if ylim:
00236             try:
00237                 ylim = [float(y) for y in ylim]
00238                 self.set_ylim(ylim)
00239             except:
00240                 qWarning("Failed to restore Y limits")
00241 
00242 
00243     def doSettingsDialog(self):
00244         """Present the user with a dialog for choosing the plot backend
00245 
00246         This displays a SimpleSettingsDialog asking the user to choose a
00247         plot type, gets the result, and updates the plot type as necessary
00248         
00249         This method is blocking"""
00250 
00251         marker_settings = [
00252             {
00253                 'title': 'Show Plot Markers',
00254                 'description': 'Warning: Displaying markers in rqt_plot may cause\n \t high cpu load, especially using PyQtGraph\n',
00255                 'enabled': True,
00256             }]
00257         if self._markers_on:
00258             selected_checkboxes = [0]
00259         else:
00260             selected_checkboxes = []
00261 
00262         dialog = SimpleSettingsDialog(title='Plot Options')
00263         dialog.add_exclusive_option_group(title='Plot Type', options=self.plot_types, selected_index=self._plot_index)
00264         dialog.add_checkbox_group(title='Plot Markers', options=marker_settings, selected_indexes=selected_checkboxes)
00265         [plot_type, checkboxes] = dialog.get_settings()
00266         if plot_type is not None and plot_type['selected_index'] is not None and self._plot_index != plot_type['selected_index']:
00267             self._switch_data_plot_widget(plot_type['selected_index'], 0 in checkboxes['selected_indexes'])
00268         else:
00269             if checkboxes is not None and self._markers_on != (0 in checkboxes['selected_indexes']):
00270                 self._switch_plot_markers(0 in checkboxes['selected_indexes'])
00271 
00272     # interface out to the managing DATA component: load data, update data,
00273     # etc
00274     def autoscroll(self, enabled=True):
00275         """Enable or disable autoscrolling of the plot"""
00276         self._autoscroll = enabled
00277 
00278     def redraw(self):
00279         self._redraw.emit()
00280 
00281     def _do_redraw(self):
00282         """Redraw the underlying plot
00283 
00284         This causes the underlying plot to be redrawn. This is usually used
00285         after adding or updating the plot data"""
00286         if self._data_plot_widget:
00287             self._merged_autoscale()
00288             for curve_id in self._curves:
00289                 curve = self._curves[curve_id]
00290                 self._data_plot_widget.set_values(curve_id, curve['x'], curve['y'])
00291             self._data_plot_widget.redraw()
00292 
00293     def _get_curve(self, curve_id):
00294         if curve_id in self._curves:
00295             return self._curves[curve_id]
00296         else:
00297             raise DataPlotException("No curve named %s in this DataPlot" %
00298                     ( curve_id) )
00299 
00300     def add_curve(self, curve_id, curve_name, data_x, data_y):
00301         """Add a new, named curve to this plot
00302 
00303         Add a curve named `curve_name` to the plot, with initial data series
00304         `data_x` and `data_y`.
00305         
00306         Future references to this curve should use the provided `curve_id`
00307 
00308         Note that the plot is not redraw automatically; call `redraw()` to make
00309         any changes visible to the user.
00310         """
00311         curve_color = QColor(self._colors[self._color_index % len(self._colors)])
00312         self._color_index += 1
00313 
00314         self._curves[curve_id] = { 'x': numpy.array(data_x),
00315                                    'y': numpy.array(data_y),
00316                                    'name': curve_name,
00317                                    'color': curve_color}
00318         if self._data_plot_widget:
00319             self._add_curve.emit(curve_id, curve_name, curve_color, self._markers_on)
00320 
00321     def remove_curve(self, curve_id):
00322         """Remove the specified curve from this plot"""
00323         # TODO: do on UI thread with signals
00324         if curve_id in self._curves:
00325             del self._curves[curve_id]
00326         if self._data_plot_widget:
00327             self._data_plot_widget.remove_curve(curve_id)
00328 
00329     def update_values(self, curve_id, values_x, values_y):
00330         """Append new data to an existing curve
00331         
00332         `values_x` and `values_y` will be appended to the existing data for
00333         `curve_id`
00334 
00335         Note that the plot is not redraw automatically; call `redraw()` to make
00336         any changes visible to the user.
00337         """
00338         curve = self._get_curve(curve_id)
00339         curve['x'] = numpy.append(curve['x'], values_x)
00340         curve['y'] = numpy.append(curve['y'], values_y)
00341         # sort resulting data, so we can slice it later
00342         sort_order = curve['x'].argsort()
00343         curve['x'] = curve['x'][sort_order]
00344         curve['y'] = curve['y'][sort_order]
00345 
00346     def clear_values(self, curve_id=None):
00347         """Clear the values for the specified curve, or all curves
00348 
00349         This will erase the data series associaed with `curve_id`, or all
00350         curves if `curve_id` is not present or is None
00351 
00352         Note that the plot is not redraw automatically; call `redraw()` to make
00353         any changes visible to the user.
00354         """
00355         # clear internal curve representation
00356         if curve_id:
00357             curve = self._get_curve(curve_id)
00358             curve['x'] = numpy.array([])
00359             curve['y'] = numpy.array([])
00360         else:
00361             for curve_id in self._curves:
00362                 self._curves[curve_id]['x'] = numpy.array([])
00363                 self._curves[curve_id]['y'] = numpy.array([])
00364 
00365 
00366     def vline(self, x, color=RED):
00367         """Draw a vertical line on the plot
00368 
00369         Draw a line a position X, with the given color
00370         
00371         @param x: position of the vertical line to draw
00372         @param color: optional parameter specifying the color, as tuple of
00373                       RGB values from 0 to 255
00374         """
00375         self._vline = (x, color)
00376         if self._data_plot_widget:
00377             self._data_plot_widget.vline(x, color)
00378 
00379     # autoscaling methods
00380     def set_autoscale(self, x=None, y=None):
00381         """Change autoscaling of plot axes
00382 
00383         if a parameter is not passed, the autoscaling setting for that axis is
00384         not changed
00385 
00386         @param x: enable or disable autoscaling for X
00387         @param y: set autoscaling mode for Y
00388         """
00389         if x is not None:
00390             self._autoscale_x = x
00391         if y is not None:
00392             self._autoscale_y = y
00393 
00394     # autoscaling:  adjusting the plot bounds fit the data
00395     # autoscrollig: move the plot X window to show the most recent data
00396     #
00397     # what order do we do these adjustments in?
00398     #  * assuming the various stages are enabled:
00399     #  * autoscale X to bring all data into view
00400     #   * else, autoscale X to determine which data we're looking at
00401     #  * autoscale Y to fit the data we're viewing
00402     #
00403     # * autoscaling of Y might have several modes:
00404     #  * scale Y to fit the entire dataset
00405     #  * scale Y to fit the current view
00406     #  * increase the Y scale to fit the current view
00407     #
00408     # TODO: incrmenetal autoscaling: only update the autoscaling bounds
00409     #       when new data is added
00410     def _merged_autoscale(self):
00411         x_limit = [numpy.inf, -numpy.inf]
00412         if self._autoscale_x:
00413             for curve_id in self._curves:
00414                 curve = self._curves[curve_id]
00415                 if len(curve['x']) > 0:
00416                     x_limit[0] = min(x_limit[0], curve['x'].min())
00417                     x_limit[1] = max(x_limit[1], curve['x'].max())
00418         elif self._autoscroll:
00419             # get current width of plot
00420             x_limit = self.get_xlim()
00421             x_width = x_limit[1] - x_limit[0]
00422 
00423             # reset the upper x_limit so that we ignore the previous position
00424             x_limit[1] = -numpy.inf
00425             
00426             # get largest X value
00427             for curve_id in self._curves:
00428                 curve = self._curves[curve_id]
00429                 if len(curve['x']) > 0:
00430                     x_limit[1] = max(x_limit[1], curve['x'].max())
00431 
00432             # set lower limit based on width
00433             x_limit[0] = x_limit[1] - x_width
00434         else:
00435             # don't modify limit, or get it from plot
00436             x_limit = self.get_xlim()
00437 
00438         # set sane limits if our limits are infinite
00439         if numpy.isinf(x_limit[0]):
00440             x_limit[0] = 0.0
00441         if numpy.isinf(x_limit[1]):
00442             x_limit[1] = 1.0
00443 
00444 
00445         y_limit = [numpy.inf, -numpy.inf]
00446         if self._autoscale_y:
00447             # if we're extending the y limits, initialize them with the
00448             # current limits
00449             if self._autoscale_y & DataPlot.SCALE_EXTEND:
00450                 y_limit = self.get_ylim()
00451             for curve_id in self._curves:
00452                 curve = self._curves[curve_id]
00453                 start_index = 0
00454                 end_index = len(curve['x'])
00455 
00456                 # if we're scaling based on the visible window, find the
00457                 # start and end indicies of our window
00458                 if self._autoscale_y & DataPlot.SCALE_VISIBLE:
00459                     # indexof x_limit[0] in curves['x']
00460                     start_index = curve['x'].searchsorted(x_limit[0])
00461                     # indexof x_limit[1] in curves['x']
00462                     end_index = curve['x'].searchsorted(x_limit[1])
00463 
00464                 # region here is cheap because it is a numpy view and not a
00465                 # copy of the underlying data
00466                 region = curve['y'][start_index:end_index]
00467                 if len(region) > 0:
00468                     y_limit[0] = min(y_limit[0], region.min())
00469                     y_limit[1] = max(y_limit[1], region.max())
00470 
00471                 # TODO: compute padding around new min and max values
00472                 #       ONLY consider data for new values; not
00473                 #       existing limits, or we'll add padding on top of old
00474                 #       padding in SCALE_EXTEND mode
00475                 # 
00476                 # pad the min/max
00477                 # TODO: invert this padding in get_ylim
00478                 #ymin = limits[0]
00479                 #ymax = limits[1]
00480                 #delta = ymax - ymin if ymax != ymin else 0.1
00481                 #ymin -= .05 * delta
00482                 #ymax += .05 * delta
00483         else:
00484             y_limit = self.get_ylim()
00485 
00486         # set sane limits if our limits are infinite
00487         if numpy.isinf(y_limit[0]):
00488             y_limit[0] = 0.0
00489         if numpy.isinf(y_limit[1]):
00490             y_limit[1] = 1.0
00491 
00492         self.set_xlim(x_limit)
00493         self.set_ylim(y_limit)
00494 
00495     def get_xlim(self):
00496         """get X limits"""
00497         if self._data_plot_widget:
00498             return self._data_plot_widget.get_xlim()
00499         else:
00500             qWarning("No plot widget; returning default X limits")
00501             return [0.0, 1.0]
00502 
00503     def set_xlim(self, limits):
00504         """set X limits"""
00505         if self._data_plot_widget:
00506             self._data_plot_widget.set_xlim(limits)
00507         else:
00508             qWarning("No plot widget; can't set X limits")
00509 
00510     def get_ylim(self):
00511         """get Y limits"""
00512         if self._data_plot_widget:
00513             return self._data_plot_widget.get_ylim()
00514         else:
00515             qWarning("No plot widget; returning default Y limits")
00516             return [0.0, 10.0]
00517 
00518     def set_ylim(self, limits):
00519         """set Y limits"""
00520         if self._data_plot_widget:
00521             self._data_plot_widget.set_ylim(limits)
00522         else:
00523             qWarning("No plot widget; can't set Y limits")
00524 
00525     # signal on y limit changed?


rqt_plot
Author(s): Dorian Scholz
autogenerated on Wed Sep 16 2015 06:58:13