data_plot/__init__.py
Go to the documentation of this file.
1 #!/usr/bin/env python
2 
3 # Copyright (c) 2014, Austin Hendrix
4 # Copyright (c) 2011, Dorian Scholz, TU Darmstadt
5 # All rights reserved.
6 #
7 # Redistribution and use in source and binary forms, with or without
8 # modification, are permitted provided that the following conditions
9 # are met:
10 #
11 # * Redistributions of source code must retain the above copyright
12 # notice, this list of conditions and the following disclaimer.
13 # * Redistributions in binary form must reproduce the above
14 # copyright notice, this list of conditions and the following
15 # disclaimer in the documentation and/or other materials provided
16 # with the distribution.
17 # * Neither the name of the TU Darmstadt nor the names of its
18 # contributors may be used to endorse or promote products derived
19 # from this software without specific prior written permission.
20 #
21 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
22 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
23 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
24 # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
25 # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
26 # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
27 # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
28 # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
29 # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
30 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
31 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
32 # POSSIBILITY OF SUCH DAMAGE.
33 
34 import numpy
35 
36 from qt_gui_py_common.simple_settings_dialog import SimpleSettingsDialog
37 from python_qt_binding import QT_BINDING
38 from python_qt_binding.QtCore import Qt, qVersion, qWarning, Signal
39 from python_qt_binding.QtGui import QColor
40 from python_qt_binding.QtWidgets import QWidget, QHBoxLayout
41 from rqt_py_common.ini_helper import pack, unpack
42 
43 try:
44  from .pyqtgraph_data_plot import PyQtGraphDataPlot
45 except ImportError as e:
46  PyQtGraphDataPlot = None
47 
48 try:
49  from .mat_data_plot import MatDataPlot
50 except ImportError as e:
51  MatDataPlot = None
52 
53 try:
54  from .qwt_data_plot import QwtDataPlot
55 except ImportError as e:
56  QwtDataPlot = None
57 
58 # separate class for DataPlot exceptions, just so that users can differentiate
59 # errors from the DataPlot widget from exceptions generated by the underlying
60 # libraries
61 
62 
63 class DataPlotException(Exception):
64  pass
65 
66 
67 class DataPlot(QWidget):
68 
69  """A widget for displaying a plot of data
70 
71  The DataPlot widget displays a plot, on one of several plotting backends,
72  depending on which backend(s) are available at runtime. It currently
73  supports PyQtGraph, MatPlot and QwtPlot backends.
74 
75  The DataPlot widget manages the plot backend internally, and can save
76  and restore the internal state using `save_settings` and `restore_settings`
77  functions.
78 
79  Currently, the user MUST call `restore_settings` before using the widget,
80  to cause the creation of the enclosed plotting widget.
81  """
82  # plot types in order of priority
83  plot_types = [
84  {
85  'title': 'PyQtGraph',
86  'widget_class': PyQtGraphDataPlot,
87  'description':
88  'Based on PyQtGraph\n- installer: http://luke.campagnola.me/code/pyqtgraph\n',
89  'enabled': PyQtGraphDataPlot is not None,
90  },
91  {
92  'title': 'MatPlot',
93  'widget_class': MatDataPlot,
94  'description':
95  'Based on MatPlotLib\n- needs most CPU\n- needs matplotlib >= 1.1.0\n- if using '
96  'PySide: PySide > 1.1.0\n',
97  'enabled': MatDataPlot is not None,
98  },
99  {
100  'title': 'QwtPlot',
101  'widget_class': QwtDataPlot,
102  'description':
103  'Based on QwtPlot\n- does not use timestamps\n- uses least CPU\n- needs Python '
104  'Qwt bindings\n',
105  'enabled': QwtDataPlot is not None,
106  },
107  ]
108 
109  # pre-defined colors:
110  RED = (255, 0, 0)
111  GREEN = (0, 255, 0)
112  BLUE = (0, 0, 255)
113 
114  SCALE_ALL = 1
115  SCALE_VISIBLE = 2
116  SCALE_EXTEND = 4
117 
118  _colors = [Qt.blue, Qt.red, Qt.cyan, Qt.magenta, Qt.green,
119  Qt.darkYellow, Qt.black, Qt.darkCyan, Qt.darkRed, Qt.gray]
120 
121  limits_changed = Signal()
122  _redraw = Signal()
123  _add_curve = Signal(str, str, 'QColor', bool)
124 
125  def __init__(self, parent=None):
126  """Create a new, empty DataPlot
127 
128  This will raise a RuntimeError if none of the supported plotting
129  backends can be found
130  """
131  super(DataPlot, self).__init__(parent)
132  self._plot_index = 0
133  self._color_index = 0
134  self._markers_on = False
135  self._autoscroll = True
136 
137  self._autoscale_x = True
138  self._autoscale_y = DataPlot.SCALE_ALL
139 
140  # the backend widget that we're trying to hide/abstract
141  self._data_plot_widget = None
142  self._curves = {}
143  self._vline = None
144  self._redraw.connect(self._do_redraw)
145 
146  self._layout = QHBoxLayout()
147  self.setLayout(self._layout)
148 
149  enabled_plot_types = [pt for pt in self.plot_types if pt['enabled']]
150  if not enabled_plot_types:
151  if qVersion().startswith('4.'):
152  version_info = '1.1.0'
153  else:
154  # minimum matplotlib version for Qt 5
155  version_info = '1.4.0'
156  if QT_BINDING == 'pyside':
157  version_info += ' and PySide %s' % \
158  ('> 1.1.0' if qVersion().startswith('4.') else '>= 2.0.0')
159  raise RuntimeError(
160  'No usable plot type found. Install at least one of: PyQtGraph, MatPlotLib '
161  '(at least %s) or Python-Qwt5.' % version_info)
162 
164 
165  self.show()
166 
167  def _switch_data_plot_widget(self, plot_index, markers_on=False):
168  """Internal method for activating a plotting backend by index"""
169  # check if selected plot type is available
170  if not self.plot_types[plot_index]['enabled']:
171  # find other available plot type
172  for index, plot_type in enumerate(self.plot_types):
173  if plot_type['enabled']:
174  plot_index = index
175  break
176 
177  self._plot_index = plot_index
178  self._markers_on = markers_on
179  selected_plot = self.plot_types[plot_index]
180 
181  if self._data_plot_widget:
182  x_limits = self.get_xlim()
183  y_limits = self.get_ylim()
184 
185  self._layout.removeWidget(self._data_plot_widget)
186  self._data_plot_widget.close()
187  self._data_plot_widget = None
188  else:
189  x_limits = [0.0, 10.0]
190  y_limits = [-0.001, 0.001]
191 
192  self._data_plot_widget = selected_plot['widget_class'](self)
193  self._data_plot_widget.limits_changed.connect(self.limits_changed)
194  self._add_curve.connect(self._data_plot_widget.add_curve)
195  self._layout.addWidget(self._data_plot_widget)
196 
197  # restore old data
198  for curve_id in self._curves:
199  curve = self._curves[curve_id]
200  self._data_plot_widget.add_curve(curve_id, curve['name'], curve['color'], markers_on)
201 
202  if self._vline:
203  self.vline(*self._vline)
204 
205  self.set_xlim(x_limits)
206  self.set_ylim(y_limits)
207  self.redraw()
208 
209  def _switch_plot_markers(self, markers_on):
210  self._markers_on = markers_on
211  self._data_plot_widget._color_index = 0
212 
213  for curve_id in self._curves:
214  self._data_plot_widget.remove_curve(curve_id)
215  curve = self._curves[curve_id]
216  self._data_plot_widget.add_curve(curve_id, curve['name'], curve['color'], markers_on)
217 
218  self.redraw()
219 
220  # interface out to the managing GUI component: get title, save, restore,
221  # etc
222  def getTitle(self):
223  """get the title of the current plotting backend"""
224  return self.plot_types[self._plot_index]['title']
225 
226  def save_settings(self, plugin_settings, instance_settings):
227  """Save the settings associated with this widget
228 
229  Currently, this is just the plot type, but may include more useful
230  data in the future"""
231  instance_settings.set_value('plot_type', self._plot_index)
232  xlim = self.get_xlim()
233  ylim = self.get_ylim()
234  # convert limits to normal arrays of floats; some backends return numpy
235  # arrays
236  xlim = [float(x) for x in xlim]
237  ylim = [float(y) for y in ylim]
238  instance_settings.set_value('x_limits', pack(xlim))
239  instance_settings.set_value('y_limits', pack(ylim))
240 
241  def restore_settings(self, plugin_settings, instance_settings):
242  """Restore the settings for this widget
243 
244  Currently, this just restores the plot type."""
245  self._switch_data_plot_widget(int(instance_settings.value('plot_type', 0)))
246  xlim = unpack(instance_settings.value('x_limits', []))
247  ylim = unpack(instance_settings.value('y_limits', []))
248  if xlim:
249  # convert limits to an array of floats; they're often lists of
250  # strings
251  try:
252  xlim = [float(x) for x in xlim]
253  self.set_xlim(xlim)
254  except:
255  qWarning("Failed to restore X limits")
256  if ylim:
257  try:
258  ylim = [float(y) for y in ylim]
259  self.set_ylim(ylim)
260  except:
261  qWarning("Failed to restore Y limits")
262 
263  def doSettingsDialog(self):
264  """Present the user with a dialog for choosing the plot backend
265 
266  This displays a SimpleSettingsDialog asking the user to choose a
267  plot type, gets the result, and updates the plot type as necessary
268 
269  This method is blocking"""
270 
271  marker_settings = [
272  {
273  'title': 'Show Plot Markers',
274  'description':
275  'Warning: Displaying markers in rqt_plot may cause\n \t high cpu load, '
276  'especially using PyQtGraph\n',
277  'enabled': True,
278  }]
279  if self._markers_on:
280  selected_checkboxes = [0]
281  else:
282  selected_checkboxes = []
283 
284  dialog = SimpleSettingsDialog(title='Plot Options')
285  dialog.add_exclusive_option_group(
286  title='Plot Type', options=self.plot_types, selected_index=self._plot_index)
287  dialog.add_checkbox_group(
288  title='Plot Markers', options=marker_settings, selected_indexes=selected_checkboxes)
289  [plot_type, checkboxes] = dialog.get_settings()
290  if plot_type is not None and \
291  plot_type['selected_index'] is not None and \
292  self._plot_index != plot_type['selected_index']:
294  plot_type['selected_index'], 0 in checkboxes['selected_indexes'])
295  else:
296  if checkboxes is not None and self._markers_on != (0 in checkboxes['selected_indexes']):
297  self._switch_plot_markers(0 in checkboxes['selected_indexes'])
298 
299  # interface out to the managing DATA component: load data, update data,
300  # etc
301  def autoscroll(self, enabled=True):
302  """Enable or disable autoscrolling of the plot"""
303  self._autoscroll = enabled
304 
305  def redraw(self):
306  self._redraw.emit()
307 
308  def _do_redraw(self):
309  """Redraw the underlying plot
310 
311  This causes the underlying plot to be redrawn. This is usually used
312  after adding or updating the plot data"""
313  if self._data_plot_widget:
314  self._merged_autoscale()
315  for curve_id in self._curves:
316  curve = self._curves[curve_id]
317  self._data_plot_widget.set_values(curve_id, curve['x'], curve['y'])
318  self._data_plot_widget.redraw()
319 
320  def _get_curve(self, curve_id):
321  if curve_id in self._curves:
322  return self._curves[curve_id]
323  else:
324  raise DataPlotException("No curve named %s in this DataPlot" %
325  (curve_id))
326 
327  def add_curve(self, curve_id, curve_name, data_x, data_y):
328  """Add a new, named curve to this plot
329 
330  Add a curve named `curve_name` to the plot, with initial data series
331  `data_x` and `data_y`.
332 
333  Future references to this curve should use the provided `curve_id`
334 
335  Note that the plot is not redraw automatically; call `redraw()` to make
336  any changes visible to the user.
337  """
338  curve_color = QColor(self._colors[self._color_index % len(self._colors)])
339  self._color_index += 1
340 
341  self._curves[curve_id] = {'x': numpy.array(data_x),
342  'y': numpy.array(data_y),
343  'name': curve_name,
344  'color': curve_color}
345  if self._data_plot_widget:
346  self._add_curve.emit(curve_id, curve_name, curve_color, self._markers_on)
347 
348  def remove_curve(self, curve_id):
349  """Remove the specified curve from this plot"""
350  # TODO: do on UI thread with signals
351  if curve_id in self._curves:
352  del self._curves[curve_id]
353  if self._data_plot_widget:
354  self._data_plot_widget.remove_curve(curve_id)
355 
356  def update_values(self, curve_id, values_x, values_y, sort_data=True):
357  """Append new data to an existing curve
358 
359  `values_x` and `values_y` will be appended to the existing data for
360  `curve_id`
361 
362  Note that the plot is not redraw automatically; call `redraw()` to make
363  any changes visible to the user.
364 
365  If `sort_data` is set to False, values won't be sorted by `values_x`
366  order.
367  """
368  curve = self._get_curve(curve_id)
369  curve['x'] = numpy.append(curve['x'], values_x)
370  curve['y'] = numpy.append(curve['y'], values_y)
371 
372  if sort_data:
373  # sort resulting data, so we can slice it later
374  sort_order = curve['x'].argsort()
375  curve['x'] = curve['x'][sort_order]
376  curve['y'] = curve['y'][sort_order]
377 
378  def clear_values(self, curve_id=None):
379  """Clear the values for the specified curve, or all curves
380 
381  This will erase the data series associaed with `curve_id`, or all
382  curves if `curve_id` is not present or is None
383 
384  Note that the plot is not redraw automatically; call `redraw()` to make
385  any changes visible to the user.
386  """
387  # clear internal curve representation
388  if curve_id:
389  curve = self._get_curve(curve_id)
390  curve['x'] = numpy.array([])
391  curve['y'] = numpy.array([])
392  else:
393  for curve_id in self._curves:
394  self._curves[curve_id]['x'] = numpy.array([])
395  self._curves[curve_id]['y'] = numpy.array([])
396 
397  def vline(self, x, color=RED):
398  """Draw a vertical line on the plot
399 
400  Draw a line a position X, with the given color
401 
402  @param x: position of the vertical line to draw
403  @param color: optional parameter specifying the color, as tuple of
404  RGB values from 0 to 255
405  """
406  self._vline = (x, color)
407  if self._data_plot_widget:
408  self._data_plot_widget.vline(x, color)
409 
410  # autoscaling methods
411  def set_autoscale(self, x=None, y=None):
412  """Change autoscaling of plot axes
413 
414  if a parameter is not passed, the autoscaling setting for that axis is
415  not changed
416 
417  @param x: enable or disable autoscaling for X
418  @param y: set autoscaling mode for Y
419  """
420  if x is not None:
421  self._autoscale_x = x
422  if y is not None:
423  self._autoscale_y = y
424 
425  # autoscaling: adjusting the plot bounds fit the data
426  # autoscrollig: move the plot X window to show the most recent data
427  #
428  # what order do we do these adjustments in?
429  # * assuming the various stages are enabled:
430  # * autoscale X to bring all data into view
431  # * else, autoscale X to determine which data we're looking at
432  # * autoscale Y to fit the data we're viewing
433  #
434  # * autoscaling of Y might have several modes:
435  # * scale Y to fit the entire dataset
436  # * scale Y to fit the current view
437  # * increase the Y scale to fit the current view
438  #
439  # TODO: incrmenetal autoscaling: only update the autoscaling bounds
440  # when new data is added
441  def _merged_autoscale(self):
442  x_limit = [numpy.inf, -numpy.inf]
443  if self._autoscale_x:
444  for curve_id in self._curves:
445  curve = self._curves[curve_id]
446  if len(curve['x']) > 0:
447  x_limit[0] = min(x_limit[0], curve['x'].min())
448  x_limit[1] = max(x_limit[1], curve['x'].max())
449  elif self._autoscroll:
450  # get current width of plot
451  x_limit = self.get_xlim()
452  x_width = x_limit[1] - x_limit[0]
453 
454  # reset the upper x_limit so that we ignore the previous position
455  x_limit[1] = -numpy.inf
456 
457  # get largest X value
458  for curve_id in self._curves:
459  curve = self._curves[curve_id]
460  if len(curve['x']) > 0:
461  x_limit[1] = max(x_limit[1], curve['x'].max())
462 
463  # set lower limit based on width
464  x_limit[0] = x_limit[1] - x_width
465  else:
466  # don't modify limit, or get it from plot
467  x_limit = self.get_xlim()
468 
469  # set sane limits if our limits are infinite
470  if numpy.isinf(x_limit[0]):
471  x_limit[0] = 0.0
472  if numpy.isinf(x_limit[1]):
473  x_limit[1] = 1.0
474 
475  y_limit = [numpy.inf, -numpy.inf]
476  if self._autoscale_y:
477  # if we're extending the y limits, initialize them with the
478  # current limits
479  if self._autoscale_y & DataPlot.SCALE_EXTEND:
480  y_limit = self.get_ylim()
481  for curve_id in self._curves:
482  curve = self._curves[curve_id]
483  start_index = 0
484  end_index = len(curve['x'])
485 
486  # if we're scaling based on the visible window, find the
487  # start and end indicies of our window
488  if self._autoscale_y & DataPlot.SCALE_VISIBLE:
489  # indexof x_limit[0] in curves['x']
490  start_index = curve['x'].searchsorted(x_limit[0])
491  # indexof x_limit[1] in curves['x']
492  end_index = curve['x'].searchsorted(x_limit[1])
493 
494  # region here is cheap because it is a numpy view and not a
495  # copy of the underlying data
496  region = curve['y'][start_index:end_index]
497  if len(region) > 0:
498  y_limit[0] = min(y_limit[0], region.min())
499  y_limit[1] = max(y_limit[1], region.max())
500 
501  # TODO: compute padding around new min and max values
502  # ONLY consider data for new values; not
503  # existing limits, or we'll add padding on top of old
504  # padding in SCALE_EXTEND mode
505  #
506  # pad the min/max
507  # TODO: invert this padding in get_ylim
508  # ymin = limits[0]
509  # ymax = limits[1]
510  # delta = ymax - ymin if ymax != ymin else 0.1
511  # ymin -= .05 * delta
512  # ymax += .05 * delta
513  else:
514  y_limit = self.get_ylim()
515 
516  # set sane limits if our limits are infinite
517  if numpy.isinf(y_limit[0]):
518  y_limit[0] = 0.0
519  if numpy.isinf(y_limit[1]):
520  y_limit[1] = 1.0
521 
522  self.set_xlim(x_limit)
523  self.set_ylim(y_limit)
524 
525  def get_xlim(self):
526  """get X limits"""
527  if self._data_plot_widget:
528  return self._data_plot_widget.get_xlim()
529  else:
530  qWarning("No plot widget; returning default X limits")
531  return [0.0, 1.0]
532 
533  def set_xlim(self, limits):
534  """set X limits"""
535  if self._data_plot_widget:
536  self._data_plot_widget.set_xlim(limits)
537  else:
538  qWarning("No plot widget; can't set X limits")
539 
540  def get_ylim(self):
541  """get Y limits"""
542  if self._data_plot_widget:
543  return self._data_plot_widget.get_ylim()
544  else:
545  qWarning("No plot widget; returning default Y limits")
546  return [0.0, 10.0]
547 
548  def set_ylim(self, limits):
549  """set Y limits"""
550  if self._data_plot_widget:
551  self._data_plot_widget.set_ylim(limits)
552  else:
553  qWarning("No plot widget; can't set Y limits")
554 
555  # signal on y limit changed?
def save_settings(self, plugin_settings, instance_settings)
def __init__(self, parent=None)
def set_autoscale(self, x=None, y=None)
def restore_settings(self, plugin_settings, instance_settings)
def vline(self, x, color=RED)
def add_curve(self, curve_id, curve_name, data_x, data_y)
def _switch_plot_markers(self, markers_on)
def _get_curve(self, curve_id)
def remove_curve(self, curve_id)
def clear_values(self, curve_id=None)
def update_values(self, curve_id, values_x, values_y, sort_data=True)
def _switch_data_plot_widget(self, plot_index, markers_on=False)
def autoscroll(self, enabled=True)


rqt_plot
Author(s): Dorian Scholz
autogenerated on Sun Mar 17 2019 02:21:35