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


rqt_plot
Author(s): Dorian Scholz
autogenerated on Sun Mar 17 2019 02:29:34