class ChecklistWindow(QWidget):
    def __init__(self, aircraft_id):
        super(ChecklistWindow, self).__init__()
        # Set properties of the window
        self.setWindowTitle("BTO and BPO Checklist")
        self.resize(500, 700)
        self.move(200,100)
        self.checklist_state = 0

        # Relative path for the default BPO and BTO checklist
        BPO_checklist_file = os.path.join(rospkg.RosPack().get_path('yonah_rqt'), 'src/yonah_rqt', 'BPO_checklist.csv')
        BTO_checklist_file = os.path.join(rospkg.RosPack().get_path('yonah_rqt'), 'src/yonah_rqt', 'BTO_checklist.csv')
        
        # Check whether checklist is present, if not print a error message to terminal
        try:
            # If checklist is present, parse it and pass it to its respective variable
            self.BPO_checklist = self.excel_parser(BPO_checklist_file)
            self.BTO_checklist = self.excel_parser(BTO_checklist_file)
        except:
            rospy.logerr("Checklist files are missing or named wrongly. Please follow the original directory and naming")
            exit()
    
        # Create the layout
        self.main_layout = QVBoxLayout()
        self.buttons_layout = QHBoxLayout()
        self.tree_widget_layout = QHBoxLayout()
        
        # Create the widgets
        self.create_widget()
        self.has_message_opened = 0

        # Add the widgets into the layouts
        self.main_layout.addLayout(self.tree_widget_layout)
        self.main_layout.addLayout(self.buttons_layout)
        self.setLayout(self.main_layout)

    # Create the main layout of widget
    def create_widget(self):
        # Create tree structure
        self.create_tree()

        # Declare buttons and connect each of them to a function
        self.load_button = QPushButton('Load')
        self.ok_button = QPushButton('OK')
        self.cancel_button = QPushButton('Cancel')
        self.load_button.pressed.connect(self.load_clicked)
        self.ok_button.pressed.connect(self.ok_clicked)
        self.cancel_button.pressed.connect(self.cancel_clicked)
        
        # Add buttons into the layout
        self.buttons_layout.addWidget(self.load_button)
        self.buttons_layout.addWidget(self.cancel_button)
        self.buttons_layout.addWidget(self.ok_button)
        self.tree_widget_layout.addWidget(self.tree_widget)

    # Create the tree layout of widget inside of the main layout
    def create_tree(self):
        # Set up the main tree widget
        self.tree_widget = QTreeWidget()
        self.tree_widget.setColumnCount(2)
        self.tree_widget.setColumnWidth(0, 250)
        self.tree_widget.setHeaderLabels(['Parts', 'Status'])
        self.item = QTreeWidgetItem()
        
        # Create the BPO section
        self.BPO_header = QTreeWidgetItem(self.tree_widget)
        self.BPO_header.setFlags(self.BPO_header.flags() | Qt.ItemIsTristate | Qt.ItemIsUserCheckable)
        self.BPO_header.setText(0, 'BPO Checklist')
        self.BPO_header.setExpanded(True)
        self.create_item(self.BPO_header, self.BPO_checklist) # Adds the list of items into the section

        # Create the BTO section
        self.BTO_header = QTreeWidgetItem(self.tree_widget)
        self.BTO_header.setFlags(self.BTO_header.flags() | Qt.ItemIsTristate | Qt.ItemIsUserCheckable)
        self.BTO_header.setText(0, 'BTO Checklist')
        self.BTO_header.setExpanded(True)
        self.create_item(self.BTO_header, self.BTO_checklist) # Adds the list of items into the section

    # Populate the tree layout with items
    def create_item(self, parent, list):
        section_header = [] # List of the section headers
        for i in range (len(list)):
            if (list[i][1] == '' and list[i][0] != ''):
                section_header.append(list[i][0])
        k = 0

        # Iterate through the different sections
        for j in range (len(section_header)):
            # Child refers to the sections (mechanical, avionics, etc)
            child = QTreeWidgetItem(parent)
            child.setFlags(child.flags() | Qt.ItemIsTristate | Qt.ItemIsUserCheckable)
            child.setText(0, section_header[j])

            while k < len(list):
                if list[k][0] in section_header:
                    # When the while loop encounters the first title, continue the loop
                    if (list[k][0] == section_header[0]):
                        k += 1
                        continue
                    # When the while loop encounters the next titles, break the loop so that the value of j increases
                    k += 1 
                    break
                # when the list contains empty cells, skip the cell and continue
                elif list[k][0] == '':
                    k += 1
                    continue
                # Add the list items to the treewidgetitem
                # Grandchild refers to the items under each section (wing nuts, tail nuts, etc)
                grandchild = QTreeWidgetItem(child)
                grandchild.setText(0, list[k][0])
                grandchild.setText(1, list[k][1])
                grandchild.setCheckState(0, Qt.Unchecked) # Set all checkbox to its unchecked state
                k += 1

    # Read the excel sheet and parse it as an array
    def excel_parser(self, file_name):
        with open(file_name, 'r') as file:
            checklist = []
            reader = csv.reader(file)
            for row in reader:
                checklist.append(row)
            return checklist            
    
    # Determines what happens when load button is clicked
    def load_clicked(self):
        # Use QFileDialog to open the system's file browser
        filenames = QFileDialog.getOpenFileNames(
            self, self.tr('Load from Files'), '.', self.tr('csv files {.csv} (*.csv)'))
        # Iterate through the file names selected
        for filename in filenames[0]:
            # If the file names has the word BPO or BTO in it, remove current widget, add the loaded one
            if (filename.find('BPO') != -1):
                self.BPO_checklist = self.excel_parser(filename)
                self.remove_widget()
                self.create_widget()
            elif (filename.find('BTO') != -1):
                self.BTO_checklist = self.excel_parser(filename)
                self.remove_widget()
                self.create_widget()
            else:
                rospy.logerr('rqt: Checklist name must contain BPO or BTO')
                self.close()
    
    # Close all the main_layout
    def remove_widget(self):
        self.main_layout.removeWidget(self.tree_widget)
        self.tree_widget.deleteLater()
        self.buttons_layout.removeWidget(self.ok_button)
        self.buttons_layout.removeWidget(self.cancel_button)
        self.buttons_layout.removeWidget(self.load_button)
        self.ok_button.deleteLater()
        self.cancel_button.deleteLater()
        self.load_button.deleteLater()
    
     # Declare what will happen when ok button is clicked
    def ok_clicked(self):
        if self.BPO_header.checkState(0) != 2 or self.BTO_header.checkState(0) != 2: # User clicks ok without checking all
            self.dialog_window("Some items in the checklist are still unchecked", "Do you still want to continue?", True)
        else:
            self.checklist_state = 1
            self.close()

    # Declare what will happen when cancel button is clicked
    def cancel_clicked(self):
        if self.BPO_header.checkState(0) != 0 or self.BTO_header.checkState(0) != 0: # User clicks cancel with some boxes checked
            self.dialog_window('Some of your items are checked. Cancelling will uncheck all your items', 'Do you still want to continue?', False)
        else:
            self.BTO_header.setCheckState(0, Qt.Unchecked)
            self.BPO_header.setCheckState(0, Qt.Unchecked)
            self.close()
    
    # Create a pop up window when user pre-emptively cancelled or clicked ok without completing the checklist
    def dialog_window(self, message, detail, check):
        self.message = QMessageBox()
        self.has_message_opened = 1
        self.message.setIcon(QMessageBox.Warning)
        self.message.setText(message)
        self.message.setInformativeText(detail)
        self.message.setWindowTitle("Items are unchecked")
        self.message.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
        self.message.show()
        if check == True: # Check == True means it is from ok_clicked
            self.message.buttonClicked.connect(self.message_action)
        else:
            self.message.buttonClicked.connect(self.message_action_uncheck)
    
    # Determines what happens after dialog_window pops up from ok_button
    def message_action(self, i):
        if i.text() == '&Yes':
            self.checklist_state = 1
            self.close()
        else:
            self.message.close()

    # Determines what happens after dialog_window pops up from cancel_button
    def message_action_uncheck(self, i):
        self.response = i.text()
        if self.response == '&Yes':
            self.checklist_state = 1
            self.BTO_header.setCheckState(0, Qt.Unchecked)
            self.BPO_header.setCheckState(0, Qt.Unchecked)
            self.close()
        else:
            self.message.close()

    # Shutdown function
    def shutdown(self):
        self.close()
        if self.has_message_opened == 1:
            self.message.close()
Beispiel #2
0
class DataPlot(QWidget):
    """A widget for displaying a plot of data

    The DataPlot widget displays a plot, on one of several plotting backends,
    depending on which backend(s) are available at runtime. It currently
    supports PyQtGraph, MatPlot and QwtPlot backends.

    The DataPlot widget manages the plot backend internally, and can save
    and restore the internal state using `save_settings` and `restore_settings`
    functions.

    Currently, the user MUST call `restore_settings` before using the widget,
    to cause the creation of the enclosed plotting widget.
    """
    # plot types in order of priority
    plot_types = [
        {
            'title': 'PyQtGraph',
            'widget_class': PyQtGraphDataPlot,
            'description':
            'Based on PyQtGraph\n- installer: http://luke.campagnola.me/code/pyqtgraph\n',
            'enabled': PyQtGraphDataPlot is not None,
        },
        {
            'title':
            'MatPlot',
            'widget_class':
            MatDataPlot,
            'description':
            'Based on MatPlotLib\n- needs most CPU\n- needs matplotlib >= 1.1.0\n- if using '
            'PySide: PySide > 1.1.0\n',
            'enabled':
            MatDataPlot is not None,
        },
        {
            'title':
            'QwtPlot',
            'widget_class':
            QwtDataPlot,
            'description':
            'Based on QwtPlot\n- does not use timestamps\n- uses least CPU\n- needs Python '
            'Qwt bindings\n',
            'enabled':
            QwtDataPlot is not None,
        },
    ]

    # pre-defined colors:
    RED = (255, 0, 0)
    GREEN = (0, 255, 0)
    BLUE = (0, 0, 255)

    SCALE_ALL = 1
    SCALE_VISIBLE = 2
    SCALE_EXTEND = 4

    _colors = [
        Qt.blue, Qt.red, Qt.cyan, Qt.magenta, Qt.green, Qt.darkYellow,
        Qt.black, Qt.darkCyan, Qt.darkRed, Qt.gray
    ]

    limits_changed = Signal()
    _redraw = Signal()
    _add_curve = Signal(str, str, 'QColor', bool, bool)

    def __init__(self, parent=None):
        """Create a new, empty DataPlot

        This will raise a RuntimeError if none of the supported plotting
        backends can be found
        """
        super(DataPlot, self).__init__(parent)
        self.x_width = 5.0

        self._plot_index = 0
        self._color_index = 0
        self._markers_on = False
        self._autoscroll = True

        self._autoscale_x = False
        self._autoscale_y = DataPlot.SCALE_ALL

        # the backend widget that we're trying to hide/abstract
        self._data_plot_widget = None
        self._curves = {}
        self._vline = None
        self._redraw.connect(self._do_redraw)

        self._layout = QHBoxLayout()
        self.setLayout(self._layout)

        enabled_plot_types = [pt for pt in self.plot_types if pt['enabled']]
        if not enabled_plot_types:
            if qVersion().startswith('4.'):
                version_info = '1.1.0'
            else:
                # minimum matplotlib version for Qt 5
                version_info = '1.4.0'
            if QT_BINDING == 'pyside':
                version_info += ' and PySide %s' % \
                    ('> 1.1.0' if qVersion().startswith('4.') else '>= 2.0.0')
            raise RuntimeError(
                'No usable plot type found. Install at least one of: PyQtGraph, MatPlotLib '
                '(at least %s) or Python-Qwt5.' % version_info)

        self._switch_data_plot_widget(self._plot_index)

        self.show()

    def set_x_width(self, width):
        self.x_width = width

    def _switch_data_plot_widget(self, plot_index, markers_on=False):
        """Internal method for activating a plotting backend by index"""
        # check if selected plot type is available
        if not self.plot_types[plot_index]['enabled']:
            # find other available plot type
            for index, plot_type in enumerate(self.plot_types):
                if plot_type['enabled']:
                    plot_index = index
                    break

        self._plot_index = plot_index
        self._markers_on = markers_on
        selected_plot = self.plot_types[plot_index]
        print("Selected plot: {}".format(selected_plot['title']))

        if self._data_plot_widget:
            x_limits = self.get_xlim()
            y_limits = self.get_ylim()

            self._layout.removeWidget(self._data_plot_widget)
            self._data_plot_widget.close()
            self._data_plot_widget = None
        else:
            x_limits = [0.0, 10.0]
            y_limits = [-0.001, 0.001]

        self._data_plot_widget = selected_plot['widget_class'](self)
        self._data_plot_widget.limits_changed.connect(self.limits_changed)
        self._add_curve.connect(self._data_plot_widget.add_curve)
        self._layout.addWidget(self._data_plot_widget)

        # restore old data
        for curve_id in self._curves:
            curve = self._curves[curve_id]
            self._data_plot_widget.add_curve(curve_id, curve['name'],
                                             curve['color'], markers_on)

        if self._vline:
            self.vline(*self._vline)

        self.set_xlim(x_limits)
        self.set_ylim(y_limits)
        self.redraw()

    def _switch_plot_markers(self, markers_on):
        self._markers_on = markers_on
        self._data_plot_widget._color_index = 0

        for curve_id in self._curves:
            self._data_plot_widget.remove_curve(curve_id)
            curve = self._curves[curve_id]
            self._data_plot_widget.add_curve(curve_id, curve['name'],
                                             curve['color'], markers_on,
                                             curve['dashed'])

        self.redraw()

    # interface out to the managing GUI component: get title, save, restore,
    # etc
    def getTitle(self):
        """get the title of the current plotting backend"""
        return self.plot_types[self._plot_index]['title']

    def save_settings(self, plugin_settings, instance_settings):
        """Save the settings associated with this widget

        Currently, this is just the plot type, but may include more useful
        data in the future"""
        instance_settings.set_value('plot_type', self._plot_index)
        xlim = self.get_xlim()
        ylim = self.get_ylim()
        # convert limits to normal arrays of floats; some backends return numpy
        # arrays
        xlim = [float(x) for x in xlim]
        ylim = [float(y) for y in ylim]
        instance_settings.set_value('x_limits', pack(xlim))
        instance_settings.set_value('y_limits', pack(ylim))

    def restore_settings(self, plugin_settings, instance_settings):
        """Restore the settings for this widget

        Currently, this just restores the plot type."""
        self._switch_data_plot_widget(
            int(instance_settings.value('plot_type', 0)))
        xlim = unpack(instance_settings.value('x_limits', []))
        ylim = unpack(instance_settings.value('y_limits', []))
        if xlim:
            # convert limits to an array of floats; they're often lists of
            # strings
            try:
                xlim = [float(x) for x in xlim]
                self.set_xlim(xlim)
            except:
                qWarning("Failed to restore X limits")
        if ylim:
            try:
                ylim = [float(y) for y in ylim]
                self.set_ylim(ylim)
            except:
                qWarning("Failed to restore Y limits")

    def doSettingsDialog(self):
        """Present the user with a dialog for choosing the plot backend

        This displays a SimpleSettingsDialog asking the user to choose a
        plot type, gets the result, and updates the plot type as necessary

        This method is blocking"""

        marker_settings = [{
            'title':
            'Show Plot Markers',
            'description':
            'Warning: Displaying markers in rqt_plot may cause\n \t high cpu load, '
            'especially using PyQtGraph\n',
            'enabled':
            True,
        }]
        if self._markers_on:
            selected_checkboxes = [0]
        else:
            selected_checkboxes = []

        dialog = SimpleSettingsDialog(title='Plot Options')
        dialog.add_exclusive_option_group(title='Plot Type',
                                          options=self.plot_types,
                                          selected_index=self._plot_index)
        dialog.add_checkbox_group(title='Plot Markers',
                                  options=marker_settings,
                                  selected_indexes=selected_checkboxes)
        [plot_type, checkboxes] = dialog.get_settings()
        if plot_type is not None and \
                plot_type['selected_index'] is not None and \
                self._plot_index != plot_type['selected_index']:
            self._switch_data_plot_widget(plot_type['selected_index'], 0
                                          in checkboxes['selected_indexes'])
        else:
            if checkboxes is not None and self._markers_on != (
                    0 in checkboxes['selected_indexes']):
                self._switch_plot_markers(0 in checkboxes['selected_indexes'])

    # interface out to the managing DATA component: load data, update data,
    # etc
    def autoscroll(self, enabled=True):
        """Enable or disable autoscrolling of the plot"""
        self._autoscroll = enabled

    def redraw(self):
        self._redraw.emit()

    def _do_redraw(self):
        """Redraw the underlying plot

        This causes the underlying plot to be redrawn. This is usually used
        after adding or updating the plot data"""
        if self._data_plot_widget:
            self._merged_autoscale()
            for curve_id in self._curves:
                curve = self._curves[curve_id]
                try:
                    self._data_plot_widget.set_values(curve_id, curve['x'],
                                                      curve['y'])
                except KeyError:
                    # skip curve which has been removed in the mean time
                    pass
            self._data_plot_widget.redraw()

    def _get_curve(self, curve_id):
        if curve_id in self._curves:
            return self._curves[curve_id]
        else:
            raise DataPlotException("No curve named %s in this DataPlot" %
                                    (curve_id))

    def add_curve(self,
                  curve_id,
                  curve_name,
                  data_x,
                  data_y,
                  curve_color=None,
                  dashed=False):
        """Add a new, named curve to this plot

        Add a curve named `curve_name` to the plot, with initial data series
        `data_x` and `data_y`.

        Future references to this curve should use the provided `curve_id`

        Note that the plot is not redraw automatically; call `redraw()` to make
        any changes visible to the user.
        """
        if curve_color is None:
            curve_color = QColor(self._colors[self._color_index %
                                              len(self._colors)])
        self._color_index += 1

        self._curves[curve_id] = {
            'x': numpy.array(data_x),
            'y': numpy.array(data_y),
            'name': curve_name,
            'color': curve_color,
            'dashed': dashed
        }
        if self._data_plot_widget:
            self._add_curve.emit(curve_id, curve_name, curve_color,
                                 self._markers_on, dashed)

    def remove_curve(self, curve_id):
        """Remove the specified curve from this plot"""
        # TODO: do on UI thread with signals
        if curve_id in self._curves:
            del self._curves[curve_id]
        if self._data_plot_widget:
            self._data_plot_widget.remove_curve(curve_id)

    def update_values(self, curve_id, values_x, values_y, sort_data=True):
        """Append new data to an existing curve

        `values_x` and `values_y` will be appended to the existing data for
        `curve_id`

        Note that the plot is not redraw automatically; call `redraw()` to make
        any changes visible to the user.

        If `sort_data` is set to False, values won't be sorted by `values_x`
        order.
        """
        curve = self._get_curve(curve_id)
        curve['x'] = numpy.append(curve['x'], values_x)
        curve['y'] = numpy.append(curve['y'], values_y)

        if sort_data:
            # sort resulting data, so we can slice it later
            sort_order = curve['x'].argsort()
            curve['x'] = curve['x'][sort_order]
            curve['y'] = curve['y'][sort_order]

    def clear_values(self, curve_id=None):
        """Clear the values for the specified curve, or all curves

        This will erase the data series associaed with `curve_id`, or all
        curves if `curve_id` is not present or is None

        Note that the plot is not redraw automatically; call `redraw()` to make
        any changes visible to the user.
        """
        # clear internal curve representation
        if curve_id:
            curve = self._get_curve(curve_id)
            curve['x'] = numpy.array([])
            curve['y'] = numpy.array([])
        else:
            for curve_id in self._curves:
                self._curves[curve_id]['x'] = numpy.array([])
                self._curves[curve_id]['y'] = numpy.array([])

    def vline(self, x, color=RED):
        """Draw a vertical line on the plot

        Draw a line a position X, with the given color

        @param x: position of the vertical line to draw
        @param color: optional parameter specifying the color, as tuple of
                      RGB values from 0 to 255
        """
        self._vline = (x, color)
        if self._data_plot_widget:
            self._data_plot_widget.vline(x, color)

    # autoscaling methods
    def set_autoscale(self, x=None, y=None):
        """Change autoscaling of plot axes

        if a parameter is not passed, the autoscaling setting for that axis is
        not changed

        @param x: enable or disable autoscaling for X
        @param y: set autoscaling mode for Y
        """
        if x is not None:
            self._autoscale_x = x
        if y is not None:
            self._autoscale_y = y

    # autoscaling:  adjusting the plot bounds fit the data
    # autoscrollig: move the plot X window to show the most recent data
    #
    # what order do we do these adjustments in?
    #  * assuming the various stages are enabled:
    #  * autoscale X to bring all data into view
    #   * else, autoscale X to determine which data we're looking at
    #  * autoscale Y to fit the data we're viewing
    #
    # * autoscaling of Y might have several modes:
    #  * scale Y to fit the entire dataset
    #  * scale Y to fit the current view
    #  * increase the Y scale to fit the current view
    #
    # TODO: incrmenetal autoscaling: only update the autoscaling bounds
    #       when new data is added
    def _merged_autoscale(self):
        x_limit = [numpy.inf, -numpy.inf]
        if self._autoscale_x:
            for curve_id in self._curves:
                curve = self._curves[curve_id]
                if len(curve['x']) > 0:
                    x_limit[0] = min(x_limit[0], curve['x'].min())
                    x_limit[1] = max(x_limit[1], curve['x'].max())
        elif self._autoscroll:
            # get current width of plot
            x_limit = self.get_xlim()
            # x_width = x_limit[1] - x_limit[0]
            x_width = self.x_width

            # reset the upper x_limit so that we ignore the previous position
            x_limit[1] = -numpy.inf

            # get largest X value
            for curve_id in self._curves:
                curve = self._curves[curve_id]
                if len(curve['x']) > 0:
                    x_limit[1] = max(x_limit[1], curve['x'].max())

            # set lower limit based on width
            x_limit[0] = x_limit[1] - x_width
        else:
            # don't modify limit, or get it from plot
            x_limit = self.get_xlim()

        # set sane limits if our limits are infinite
        if numpy.isinf(x_limit[0]):
            x_limit[0] = 0.0
        if numpy.isinf(x_limit[1]):
            x_limit[1] = 1.0

        y_limit = [numpy.inf, -numpy.inf]
        if self._autoscale_y:
            # if we're extending the y limits, initialize them with the
            # current limits
            if self._autoscale_y & DataPlot.SCALE_EXTEND:
                y_limit = self.get_ylim()
            for curve_id in self._curves:
                curve = self._curves[curve_id]
                start_index = 0
                end_index = len(curve['x'])

                # if we're scaling based on the visible window, find the
                # start and end indicies of our window
                if self._autoscale_y & DataPlot.SCALE_VISIBLE:
                    # indexof x_limit[0] in curves['x']
                    start_index = curve['x'].searchsorted(x_limit[0])
                    # indexof x_limit[1] in curves['x']
                    end_index = curve['x'].searchsorted(x_limit[1])

                # region here is cheap because it is a numpy view and not a
                # copy of the underlying data
                region = curve['y'][start_index:end_index]
                if len(region) > 0:
                    y_limit[0] = min(y_limit[0], region.min())
                    y_limit[1] = max(y_limit[1], region.max())

                # TODO: compute padding around new min and max values
                #       ONLY consider data for new values; not
                #       existing limits, or we'll add padding on top of old
                #       padding in SCALE_EXTEND mode
                #
                # pad the min/max
                # TODO: invert this padding in get_ylim
                # ymin = limits[0]
                # ymax = limits[1]
                # delta = ymax - ymin if ymax != ymin else 0.1
                # ymin -= .05 * delta
                # ymax += .05 * delta
        else:
            y_limit = self.get_ylim()

        # set sane limits if our limits are infinite
        if numpy.isinf(y_limit[0]):
            y_limit[0] = 0.0
        if numpy.isinf(y_limit[1]):
            y_limit[1] = 1.0

        self.set_xlim(x_limit)
        self.set_ylim(y_limit)

    def get_xlim(self):
        """get X limits"""
        if self._data_plot_widget:
            return self._data_plot_widget.get_xlim()
        else:
            qWarning("No plot widget; returning default X limits")
            return [0.0, 1.0]

    def set_xlim(self, limits):
        """set X limits"""
        if self._data_plot_widget:
            self._data_plot_widget.set_xlim(limits)
        else:
            qWarning("No plot widget; can't set X limits")

    def get_ylim(self):
        """get Y limits"""
        if self._data_plot_widget:
            return self._data_plot_widget.get_ylim()
        else:
            qWarning("No plot widget; returning default Y limits")
            return [0.0, 10.0]

    def set_ylim(self, limits):
        """set Y limits"""
        if self._data_plot_widget:
            self._data_plot_widget.set_ylim(limits)
        else:
            qWarning("No plot widget; can't set Y limits")
Beispiel #3
0
class DataPlot(QWidget):
    """A widget for displaying a plot of data

    The DataPlot widget displays a plot, on one of several plotting backends,
    depending on which backend(s) are available at runtime. It currently 
    supports PyQtGraph, MatPlot and QwtPlot backends.

    The DataPlot widget manages the plot backend internally, and can save
    and restore the internal state using `save_settings` and `restore_settings`
    functions.

    Currently, the user MUST call `restore_settings` before using the widget,
    to cause the creation of the enclosed plotting widget.
    """
    # plot types in order of priority
    plot_types = [
        {
            'title': 'PyQtGraph',
            'widget_class': PyQtGraphDataPlot,
            'description': 'Based on PyQtGraph\n- installer: http://luke.campagnola.me/code/pyqtgraph\n',
            'enabled': PyQtGraphDataPlot is not None,
        },
        {
            'title': 'MatPlot',
            'widget_class': MatDataPlot,
            'description': 'Based on MatPlotLib\n- needs most CPU\n- needs matplotlib >= 1.1.0\n- if using PySide: PySide > 1.1.0\n',
            'enabled': MatDataPlot is not None,
        },
        {
            'title': 'QwtPlot',
            'widget_class': QwtDataPlot,
            'description': 'Based on QwtPlot\n- does not use timestamps\n- uses least CPU\n- needs Python Qwt bindings\n',
            'enabled': QwtDataPlot is not None,
        },
    ]

    # pre-defined colors:
    RED=(255, 0, 0)
    GREEN=(0, 255, 0)
    BLUE=(0, 0, 255)

    SCALE_ALL=1
    SCALE_VISIBLE=2
    SCALE_EXTEND=4

    _colors = [Qt.blue, Qt.red, Qt.cyan, Qt.magenta, Qt.green, Qt.darkYellow, Qt.black, Qt.darkCyan, Qt.darkRed, Qt.gray]

    limits_changed = Signal()
    _redraw = Signal()
    _add_curve = Signal(str, str, 'QColor', bool)

    def __init__(self, parent=None):
        """Create a new, empty DataPlot

        This will raise a RuntimeError if none of the supported plotting
        backends can be found
        """
        super(DataPlot, self).__init__(parent)
        self._plot_index = 0
        self._color_index = 0
        self._markers_on = False
        self._autoscroll = True

        self._autoscale_x = True
        self._autoscale_y = DataPlot.SCALE_ALL

        # the backend widget that we're trying to hide/abstract
        self._data_plot_widget = None
        self._curves = {}
        self._vline = None
        self._redraw.connect(self._do_redraw)

        self._layout = QHBoxLayout()
        self.setLayout(self._layout)

        enabled_plot_types = [pt for pt in self.plot_types if pt['enabled']]
        if not enabled_plot_types:
            if qVersion().startswith('4.'):
                version_info = '1.1.0'
            else:
                # minimum matplotlib version for Qt 5
                version_info = '1.4.0'
            if QT_BINDING == 'pyside':
                version_info += ' and PySide %s' % \
                    ('> 1.1.0' if qVersion().startswith('4.') else '>= 2.0.0')
            raise RuntimeError('No usable plot type found. Install at least one of: PyQtGraph, MatPlotLib (at least %s) or Python-Qwt5.' % version_info)

        self._switch_data_plot_widget(self._plot_index)

        self.show()

    def _switch_data_plot_widget(self, plot_index, markers_on=False):
        """Internal method for activating a plotting backend by index"""
        # check if selected plot type is available
        if not self.plot_types[plot_index]['enabled']:
            # find other available plot type
            for index, plot_type in enumerate(self.plot_types):
                if plot_type['enabled']:
                    plot_index = index
                    break

        self._plot_index = plot_index
        self._markers_on = markers_on
        selected_plot = self.plot_types[plot_index]

        if self._data_plot_widget:
            x_limits = self.get_xlim()
            y_limits = self.get_ylim()

            self._layout.removeWidget(self._data_plot_widget)
            self._data_plot_widget.close()
            self._data_plot_widget = None
        else:
            x_limits = [0.0, 10.0]
            y_limits = [-0.001, 0.001]

        self._data_plot_widget = selected_plot['widget_class'](self)
        self._data_plot_widget.limits_changed.connect(self.limits_changed)
        self._add_curve.connect(self._data_plot_widget.add_curve)
        self._layout.addWidget(self._data_plot_widget)

        # restore old data
        for curve_id in self._curves:
            curve = self._curves[curve_id]
            self._data_plot_widget.add_curve(curve_id, curve['name'], curve['color'], markers_on)

        if self._vline:
            self.vline(*self._vline)

        self.set_xlim(x_limits)
        self.set_ylim(y_limits)
        self.redraw()

    def _switch_plot_markers(self, markers_on):
        self._markers_on = markers_on
        self._data_plot_widget._color_index = 0

        for curve_id in self._curves:
            self._data_plot_widget.remove_curve(curve_id)
            curve = self._curves[curve_id]
            self._data_plot_widget.add_curve(curve_id, curve['name'], curve['color'], markers_on)

        self.redraw()

    # interface out to the managing GUI component: get title, save, restore, 
    # etc
    def getTitle(self):
        """get the title of the current plotting backend"""
        return self.plot_types[self._plot_index]['title']

    def save_settings(self, plugin_settings, instance_settings):
        """Save the settings associated with this widget

        Currently, this is just the plot type, but may include more useful
        data in the future"""
        instance_settings.set_value('plot_type', self._plot_index)
        xlim = self.get_xlim()
        ylim = self.get_ylim()
        # convert limits to normal arrays of floats; some backends return numpy
        # arrays
        xlim = [float(x) for x in xlim]
        ylim = [float(y) for y in ylim]
        instance_settings.set_value('x_limits', pack(xlim))
        instance_settings.set_value('y_limits', pack(ylim))

    def restore_settings(self, plugin_settings, instance_settings):
        """Restore the settings for this widget

        Currently, this just restores the plot type."""
        self._switch_data_plot_widget(int(instance_settings.value('plot_type', 0)))
        xlim = unpack(instance_settings.value('x_limits', []))
        ylim = unpack(instance_settings.value('y_limits', []))
        if xlim:
            # convert limits to an array of floats; they're often lists of
            # strings
            try:
                xlim = [float(x) for x in xlim]
                self.set_xlim(xlim)
            except:
                qWarning("Failed to restore X limits")
        if ylim:
            try:
                ylim = [float(y) for y in ylim]
                self.set_ylim(ylim)
            except:
                qWarning("Failed to restore Y limits")


    def doSettingsDialog(self):
        """Present the user with a dialog for choosing the plot backend

        This displays a SimpleSettingsDialog asking the user to choose a
        plot type, gets the result, and updates the plot type as necessary
        
        This method is blocking"""

        marker_settings = [
            {
                'title': 'Show Plot Markers',
                'description': 'Warning: Displaying markers in rqt_plot may cause\n \t high cpu load, especially using PyQtGraph\n',
                'enabled': True,
            }]
        if self._markers_on:
            selected_checkboxes = [0]
        else:
            selected_checkboxes = []

        dialog = SimpleSettingsDialog(title='Plot Options')
        dialog.add_exclusive_option_group(title='Plot Type', options=self.plot_types, selected_index=self._plot_index)
        dialog.add_checkbox_group(title='Plot Markers', options=marker_settings, selected_indexes=selected_checkboxes)
        [plot_type, checkboxes] = dialog.get_settings()
        if plot_type is not None and plot_type['selected_index'] is not None and self._plot_index != plot_type['selected_index']:
            self._switch_data_plot_widget(plot_type['selected_index'], 0 in checkboxes['selected_indexes'])
        else:
            if checkboxes is not None and self._markers_on != (0 in checkboxes['selected_indexes']):
                self._switch_plot_markers(0 in checkboxes['selected_indexes'])

    # interface out to the managing DATA component: load data, update data,
    # etc
    def autoscroll(self, enabled=True):
        """Enable or disable autoscrolling of the plot"""
        self._autoscroll = enabled

    def redraw(self):
        self._redraw.emit()

    def _do_redraw(self):
        """Redraw the underlying plot

        This causes the underlying plot to be redrawn. This is usually used
        after adding or updating the plot data"""
        if self._data_plot_widget:
            self._merged_autoscale()
            for curve_id in self._curves:
                curve = self._curves[curve_id]
                self._data_plot_widget.set_values(curve_id, curve['x'], curve['y'])
            self._data_plot_widget.redraw()

    def _get_curve(self, curve_id):
        if curve_id in self._curves:
            return self._curves[curve_id]
        else:
            raise DataPlotException("No curve named %s in this DataPlot" %
                    ( curve_id) )

    def add_curve(self, curve_id, curve_name, data_x, data_y):
        """Add a new, named curve to this plot

        Add a curve named `curve_name` to the plot, with initial data series
        `data_x` and `data_y`.
        
        Future references to this curve should use the provided `curve_id`

        Note that the plot is not redraw automatically; call `redraw()` to make
        any changes visible to the user.
        """
        curve_color = QColor(self._colors[self._color_index % len(self._colors)])
        self._color_index += 1

        self._curves[curve_id] = { 'x': numpy.array(data_x),
                                   'y': numpy.array(data_y),
                                   'name': curve_name,
                                   'color': curve_color}
        if self._data_plot_widget:
            self._add_curve.emit(curve_id, curve_name, curve_color, self._markers_on)

    def remove_curve(self, curve_id):
        """Remove the specified curve from this plot"""
        # TODO: do on UI thread with signals
        if curve_id in self._curves:
            del self._curves[curve_id]
        if self._data_plot_widget:
            self._data_plot_widget.remove_curve(curve_id)

    def update_values(self, curve_id, values_x, values_y):
        """Append new data to an existing curve
        
        `values_x` and `values_y` will be appended to the existing data for
        `curve_id`

        Note that the plot is not redraw automatically; call `redraw()` to make
        any changes visible to the user.
        """
        curve = self._get_curve(curve_id)
        curve['x'] = numpy.append(curve['x'], values_x)
        curve['y'] = numpy.append(curve['y'], values_y)
        # sort resulting data, so we can slice it later
        sort_order = curve['x'].argsort()
        curve['x'] = curve['x'][sort_order]
        curve['y'] = curve['y'][sort_order]

    def clear_values(self, curve_id=None):
        """Clear the values for the specified curve, or all curves

        This will erase the data series associaed with `curve_id`, or all
        curves if `curve_id` is not present or is None

        Note that the plot is not redraw automatically; call `redraw()` to make
        any changes visible to the user.
        """
        # clear internal curve representation
        if curve_id:
            curve = self._get_curve(curve_id)
            curve['x'] = numpy.array([])
            curve['y'] = numpy.array([])
        else:
            for curve_id in self._curves:
                self._curves[curve_id]['x'] = numpy.array([])
                self._curves[curve_id]['y'] = numpy.array([])


    def vline(self, x, color=RED):
        """Draw a vertical line on the plot

        Draw a line a position X, with the given color
        
        @param x: position of the vertical line to draw
        @param color: optional parameter specifying the color, as tuple of
                      RGB values from 0 to 255
        """
        self._vline = (x, color)
        if self._data_plot_widget:
            self._data_plot_widget.vline(x, color)

    # autoscaling methods
    def set_autoscale(self, x=None, y=None):
        """Change autoscaling of plot axes

        if a parameter is not passed, the autoscaling setting for that axis is
        not changed

        @param x: enable or disable autoscaling for X
        @param y: set autoscaling mode for Y
        """
        if x is not None:
            self._autoscale_x = x
        if y is not None:
            self._autoscale_y = y

    # autoscaling:  adjusting the plot bounds fit the data
    # autoscrollig: move the plot X window to show the most recent data
    #
    # what order do we do these adjustments in?
    #  * assuming the various stages are enabled:
    #  * autoscale X to bring all data into view
    #   * else, autoscale X to determine which data we're looking at
    #  * autoscale Y to fit the data we're viewing
    #
    # * autoscaling of Y might have several modes:
    #  * scale Y to fit the entire dataset
    #  * scale Y to fit the current view
    #  * increase the Y scale to fit the current view
    #
    # TODO: incrmenetal autoscaling: only update the autoscaling bounds
    #       when new data is added
    def _merged_autoscale(self):
        x_limit = [numpy.inf, -numpy.inf]
        if self._autoscale_x:
            for curve_id in self._curves:
                curve = self._curves[curve_id]
                if len(curve['x']) > 0:
                    x_limit[0] = min(x_limit[0], curve['x'].min())
                    x_limit[1] = max(x_limit[1], curve['x'].max())
        elif self._autoscroll:
            # get current width of plot
            x_limit = self.get_xlim()
            x_width = x_limit[1] - x_limit[0]

            # reset the upper x_limit so that we ignore the previous position
            x_limit[1] = -numpy.inf
            
            # get largest X value
            for curve_id in self._curves:
                curve = self._curves[curve_id]
                if len(curve['x']) > 0:
                    x_limit[1] = max(x_limit[1], curve['x'].max())

            # set lower limit based on width
            x_limit[0] = x_limit[1] - x_width
        else:
            # don't modify limit, or get it from plot
            x_limit = self.get_xlim()

        # set sane limits if our limits are infinite
        if numpy.isinf(x_limit[0]):
            x_limit[0] = 0.0
        if numpy.isinf(x_limit[1]):
            x_limit[1] = 1.0


        y_limit = [numpy.inf, -numpy.inf]
        if self._autoscale_y:
            # if we're extending the y limits, initialize them with the
            # current limits
            if self._autoscale_y & DataPlot.SCALE_EXTEND:
                y_limit = self.get_ylim()
            for curve_id in self._curves:
                curve = self._curves[curve_id]
                start_index = 0
                end_index = len(curve['x'])

                # if we're scaling based on the visible window, find the
                # start and end indicies of our window
                if self._autoscale_y & DataPlot.SCALE_VISIBLE:
                    # indexof x_limit[0] in curves['x']
                    start_index = curve['x'].searchsorted(x_limit[0])
                    # indexof x_limit[1] in curves['x']
                    end_index = curve['x'].searchsorted(x_limit[1])

                # region here is cheap because it is a numpy view and not a
                # copy of the underlying data
                region = curve['y'][start_index:end_index]
                if len(region) > 0:
                    y_limit[0] = min(y_limit[0], region.min())
                    y_limit[1] = max(y_limit[1], region.max())

                # TODO: compute padding around new min and max values
                #       ONLY consider data for new values; not
                #       existing limits, or we'll add padding on top of old
                #       padding in SCALE_EXTEND mode
                # 
                # pad the min/max
                # TODO: invert this padding in get_ylim
                #ymin = limits[0]
                #ymax = limits[1]
                #delta = ymax - ymin if ymax != ymin else 0.1
                #ymin -= .05 * delta
                #ymax += .05 * delta
        else:
            y_limit = self.get_ylim()

        # set sane limits if our limits are infinite
        if numpy.isinf(y_limit[0]):
            y_limit[0] = 0.0
        if numpy.isinf(y_limit[1]):
            y_limit[1] = 1.0

        self.set_xlim(x_limit)
        self.set_ylim(y_limit)

    def get_xlim(self):
        """get X limits"""
        if self._data_plot_widget:
            return self._data_plot_widget.get_xlim()
        else:
            qWarning("No plot widget; returning default X limits")
            return [0.0, 1.0]

    def set_xlim(self, limits):
        """set X limits"""
        if self._data_plot_widget:
            self._data_plot_widget.set_xlim(limits)
        else:
            qWarning("No plot widget; can't set X limits")

    def get_ylim(self):
        """get Y limits"""
        if self._data_plot_widget:
            return self._data_plot_widget.get_ylim()
        else:
            qWarning("No plot widget; returning default Y limits")
            return [0.0, 10.0]

    def set_ylim(self, limits):
        """set Y limits"""
        if self._data_plot_widget:
            self._data_plot_widget.set_ylim(limits)
        else:
            qWarning("No plot widget; can't set Y limits")