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  try:
318  self._data_plot_widget.set_values(curve_id, curve['x'], curve['y'])
319  except KeyError:
320  # skip curve which has been removed in the mean time
321  pass
322  self._data_plot_widget.redraw()
323 
324  def _get_curve(self, curve_id):
325  if curve_id in self._curves:
326  return self._curves[curve_id]
327  else:
328  raise DataPlotException("No curve named %s in this DataPlot" %
329  (curve_id))
330 
331  def add_curve(self, curve_id, curve_name, data_x, data_y):
332  """Add a new, named curve to this plot
333 
334  Add a curve named `curve_name` to the plot, with initial data series
335  `data_x` and `data_y`.
336 
337  Future references to this curve should use the provided `curve_id`
338 
339  Note that the plot is not redraw automatically; call `redraw()` to make
340  any changes visible to the user.
341  """
342  curve_color = QColor(self._colors[self._color_index % len(self._colors)])
343  self._color_index += 1
344 
345  self._curves[curve_id] = {'x': numpy.array(data_x),
346  'y': numpy.array(data_y),
347  'name': curve_name,
348  'color': curve_color}
349  if self._data_plot_widget:
350  self._add_curve.emit(curve_id, curve_name, curve_color, self._markers_on)
351 
352  def remove_curve(self, curve_id):
353  """Remove the specified curve from this plot"""
354  # TODO: do on UI thread with signals
355  if curve_id in self._curves:
356  del self._curves[curve_id]
357  if self._data_plot_widget:
358  self._data_plot_widget.remove_curve(curve_id)
359 
360  def update_values(self, curve_id, values_x, values_y, sort_data=True):
361  """Append new data to an existing curve
362 
363  `values_x` and `values_y` will be appended to the existing data for
364  `curve_id`
365 
366  Note that the plot is not redraw automatically; call `redraw()` to make
367  any changes visible to the user.
368 
369  If `sort_data` is set to False, values won't be sorted by `values_x`
370  order.
371  """
372  curve = self._get_curve(curve_id)
373  curve['x'] = numpy.append(curve['x'], values_x)
374  curve['y'] = numpy.append(curve['y'], values_y)
375 
376  if sort_data:
377  # sort resulting data, so we can slice it later
378  sort_order = curve['x'].argsort()
379  curve['x'] = curve['x'][sort_order]
380  curve['y'] = curve['y'][sort_order]
381 
382  def clear_values(self, curve_id=None):
383  """Clear the values for the specified curve, or all curves
384 
385  This will erase the data series associaed with `curve_id`, or all
386  curves if `curve_id` is not present or is None
387 
388  Note that the plot is not redraw automatically; call `redraw()` to make
389  any changes visible to the user.
390  """
391  # clear internal curve representation
392  if curve_id:
393  curve = self._get_curve(curve_id)
394  curve['x'] = numpy.array([])
395  curve['y'] = numpy.array([])
396  else:
397  for curve_id in self._curves:
398  self._curves[curve_id]['x'] = numpy.array([])
399  self._curves[curve_id]['y'] = numpy.array([])
400 
401  def vline(self, x, color=RED):
402  """Draw a vertical line on the plot
403 
404  Draw a line a position X, with the given color
405 
406  @param x: position of the vertical line to draw
407  @param color: optional parameter specifying the color, as tuple of
408  RGB values from 0 to 255
409  """
410  self._vline = (x, color)
411  if self._data_plot_widget:
412  self._data_plot_widget.vline(x, color)
413 
414  # autoscaling methods
415  def set_autoscale(self, x=None, y=None):
416  """Change autoscaling of plot axes
417 
418  if a parameter is not passed, the autoscaling setting for that axis is
419  not changed
420 
421  @param x: enable or disable autoscaling for X
422  @param y: set autoscaling mode for Y
423  """
424  if x is not None:
425  self._autoscale_x = x
426  if y is not None:
427  self._autoscale_y = y
428 
429  # autoscaling: adjusting the plot bounds fit the data
430  # autoscrollig: move the plot X window to show the most recent data
431  #
432  # what order do we do these adjustments in?
433  # * assuming the various stages are enabled:
434  # * autoscale X to bring all data into view
435  # * else, autoscale X to determine which data we're looking at
436  # * autoscale Y to fit the data we're viewing
437  #
438  # * autoscaling of Y might have several modes:
439  # * scale Y to fit the entire dataset
440  # * scale Y to fit the current view
441  # * increase the Y scale to fit the current view
442  #
443  # TODO: incrmenetal autoscaling: only update the autoscaling bounds
444  # when new data is added
445  def _merged_autoscale(self):
446  x_limit = [numpy.inf, -numpy.inf]
447  if self._autoscale_x:
448  for curve_id in self._curves:
449  curve = self._curves[curve_id]
450  if len(curve['x']) > 0:
451  x_limit[0] = min(x_limit[0], curve['x'].min())
452  x_limit[1] = max(x_limit[1], curve['x'].max())
453  elif self._autoscroll:
454  # get current width of plot
455  x_limit = self.get_xlim()
456  x_width = x_limit[1] - x_limit[0]
457 
458  # reset the upper x_limit so that we ignore the previous position
459  x_limit[1] = -numpy.inf
460 
461  # get largest X value
462  for curve_id in self._curves:
463  curve = self._curves[curve_id]
464  if len(curve['x']) > 0:
465  x_limit[1] = max(x_limit[1], curve['x'].max())
466 
467  # set lower limit based on width
468  x_limit[0] = x_limit[1] - x_width
469  else:
470  # don't modify limit, or get it from plot
471  x_limit = self.get_xlim()
472 
473  # set sane limits if our limits are infinite
474  if numpy.isinf(x_limit[0]):
475  x_limit[0] = 0.0
476  if numpy.isinf(x_limit[1]):
477  x_limit[1] = 1.0
478 
479  y_limit = [numpy.inf, -numpy.inf]
480  if self._autoscale_y:
481  # if we're extending the y limits, initialize them with the
482  # current limits
483  if self._autoscale_y & DataPlot.SCALE_EXTEND:
484  y_limit = self.get_ylim()
485  for curve_id in self._curves:
486  curve = self._curves[curve_id]
487  start_index = 0
488  end_index = len(curve['x'])
489 
490  # if we're scaling based on the visible window, find the
491  # start and end indicies of our window
492  if self._autoscale_y & DataPlot.SCALE_VISIBLE:
493  # indexof x_limit[0] in curves['x']
494  start_index = curve['x'].searchsorted(x_limit[0])
495  # indexof x_limit[1] in curves['x']
496  end_index = curve['x'].searchsorted(x_limit[1])
497 
498  # region here is cheap because it is a numpy view and not a
499  # copy of the underlying data
500  region = curve['y'][start_index:end_index]
501  if len(region) > 0:
502  y_limit[0] = min(y_limit[0], region.min())
503  y_limit[1] = max(y_limit[1], region.max())
504 
505  # TODO: compute padding around new min and max values
506  # ONLY consider data for new values; not
507  # existing limits, or we'll add padding on top of old
508  # padding in SCALE_EXTEND mode
509  #
510  # pad the min/max
511  # TODO: invert this padding in get_ylim
512  # ymin = limits[0]
513  # ymax = limits[1]
514  # delta = ymax - ymin if ymax != ymin else 0.1
515  # ymin -= .05 * delta
516  # ymax += .05 * delta
517  else:
518  y_limit = self.get_ylim()
519 
520  # set sane limits if our limits are infinite
521  if numpy.isinf(y_limit[0]):
522  y_limit[0] = 0.0
523  if numpy.isinf(y_limit[1]):
524  y_limit[1] = 1.0
525 
526  self.set_xlim(x_limit)
527  self.set_ylim(y_limit)
528 
529  def get_xlim(self):
530  """get X limits"""
531  if self._data_plot_widget:
532  return self._data_plot_widget.get_xlim()
533  else:
534  qWarning("No plot widget; returning default X limits")
535  return [0.0, 1.0]
536 
537  def set_xlim(self, limits):
538  """set X limits"""
539  if self._data_plot_widget:
540  self._data_plot_widget.set_xlim(limits)
541  else:
542  qWarning("No plot widget; can't set X limits")
543 
544  def get_ylim(self):
545  """get Y limits"""
546  if self._data_plot_widget:
547  return self._data_plot_widget.get_ylim()
548  else:
549  qWarning("No plot widget; returning default Y limits")
550  return [0.0, 10.0]
551 
552  def set_ylim(self, limits):
553  """set Y limits"""
554  if self._data_plot_widget:
555  self._data_plot_widget.set_ylim(limits)
556  else:
557  qWarning("No plot widget; can't set Y limits")
558 
559  # 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, Dirk Thomas
autogenerated on Thu Feb 25 2021 03:12:45