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


rqt_plot
Author(s): Dorian Scholz
autogenerated on Mon May 1 2017 02:41:23