Source code for pyrogue.pydm.widgets.time_plotter

from __future__ import annotations

#-----------------------------------------------------------------------------
# Company    : SLAC National Accelerator Laboratory
#-----------------------------------------------------------------------------
#  Description:
#       PyRogue PyDM Debug Tree Widget
#-----------------------------------------------------------------------------
# This file is part of the rogue software platform. It is subject to
# the license terms in the LICENSE.txt file found in the top-level directory
# of this distribution and at:
#    https://confluence.slac.stanford.edu/display/ppareg/LICENSE.html.
# No part of the rogue software platform, including this file, may be
# copied, modified, propagated, or distributed except according to the terms
# contained in the LICENSE.txt file.
#-----------------------------------------------------------------------------
import pyrogue
from pyrogue.pydm.data_plugins.rogue_plugin import nodeFromAddress
from pyrogue.pydm.widgets.debug_tree import makeVariableViewWidget

from pydm import Display
from pydm.widgets.frame import PyDMFrame
from pydm.widgets import PyDMLabel, PyDMPushButton
import pydm.widgets.timeplot as pwt

from qtpy import QtCore
from qtpy.QtCore import Property, Slot, QEvent
from qtpy.QtGui import QFontMetrics
from qtpy.QtWidgets import (
    QVBoxLayout, QHBoxLayout, QTreeWidgetItem, QTreeWidget, QLabel,
    QGroupBox, QLineEdit, QPushButton, QScrollArea, QFrame, QWidget)

from pyqtgraph import ViewBox

from PyQt5.QtWidgets import QSplitter
from PyQt5.QtCore import Qt

import random


class DebugDev(QTreeWidgetItem):
    """Tree item representing a device in the time-plot selection tree.

    Parameters
    ----------
    main : TimePlotter
        Owning time-plotter widget.
    path : str
        Full Rogue node path for this device.
    top : SelectionTree
        Owning selection-tree widget.
    parent : QTreeWidget | QTreeWidgetItem
        Parent tree widget/item.
    dev : pyrogue.Device
        Backing Rogue device object.
    noExpand : bool
        If ``True``, delay child expansion until user expands the row.
    """

    def __init__(
        self,
        main: "TimePlotter",
        *,
        path: str,
        top: "SelectionTree",
        parent: QTreeWidget | QTreeWidgetItem,
        dev: pyrogue.Device,
        noExpand: bool,
    ) -> None:
        QTreeWidgetItem.__init__(self,parent)
        self._main=main
        self._top      = top
        self._parent   = parent
        self._dev      = dev
        self._dummy    = None
        self._path     = path
        self._groups   = {}

        if isinstance(parent,DebugDev):
            self._depth = parent._depth+1
        else:
            self._depth = 1

        w = PyDMLabel(parent=None, init_channel=self._path + '/name')
        w.showUnits             = False
        w.precisionFromPV       = False
        w.alarmSensitiveContent = False
        w.alarmSensitiveBorder  = False

        self._top._tree.setItemWidget(self,0,w)
        self.setToolTip(0,self._dev.description)

        if self._top._node == dev:
            self._parent.addTopLevelItem(self)
            self.setExpanded(True)
            self._setup(False)

        elif (not noExpand) and self._dev.expand:
            self._dummy = None
            self.setExpanded(True)
            self._setup(False)
        else:
            self._dummy = QTreeWidgetItem(self) # One dummy item to add expand control
            self.setExpanded(False)

    def _setup(self, noExpand: bool) -> None:

        # Get dictionary of variables followed by commands
        lst = self._dev.variablesByGroup(incGroups=self._top._incGroups,
                                         excGroups=self._top._excGroups)


#         lst.update(self._dev.commandsByGroup(incGroups=self._top._incGroups,
#                                              excGroups=self._top._excGroups))

        # First create variables/commands
        for key,val in lst.items():

            if val.guiGroup is not None:
                if val.guiGroup not in self._groups:
                    self._groups[val.guiGroup] = DebugGroup(path=self._path, main=self._main, top=self._top, parent=self, name=val.guiGroup)

                self._groups[val.guiGroup].addNode(val)

            else:
                DebugHolder(main=self._main,path=self._path + '.' + val.name, top=self._top, parent=self, variable=val)

        # Then create devices
        for key,val in self._dev.devicesByGroup(incGroups=self._top._incGroups, excGroups=self._top._excGroups).items():

            if val.guiGroup is not None:
                if val.guiGroup not in self._groups:
                    self._groups[val.guiGroup] = DebugGroup(path=self._path, main=self._main, top=self._top, parent=self, name=val.guiGroup)

                self._groups[val.guiGroup].addNode(val)

            else:
                DebugDev(main = self._main,path=self._path + '.' + val.name, top=self._top, parent=self, dev=val, noExpand=noExpand)

    def _expand(self) -> None:
        if self._dummy is None:
            return

        self.removeChild(self._dummy)
        self._dummy = None
        self._setup(True)


class DebugGroup(QTreeWidgetItem):
    """Tree item representing a GUI group in the selection tree.

    Parameters
    ----------
    main : TimePlotter
        Owning time-plotter widget.
    path : str
        Parent Rogue node path for this group.
    top : SelectionTree
        Owning selection-tree widget.
    parent : QTreeWidgetItem
        Parent tree item.
    name : str
        GUI group name.
    """

    def __init__(self, main: "TimePlotter", *, path: str, top: "SelectionTree", parent: QTreeWidgetItem, name: str) -> None:
        QTreeWidgetItem.__init__(self,parent)
        self._main = main
        self._top      = top
        self._parent   = parent
        self._name     = name
        self._dummy    = None
        self._path     = path
        self._list     = []
        self._depth    = parent._depth+1

        self._lab = QLabel(parent=None, text=self._name)

        self._top._tree.setItemWidget(self,0,self._lab)
        self._dummy = QTreeWidgetItem(self) # One dummy item to add expand control
        self.setExpanded(False)

    def _setup(self) -> None:

        # Create variables
        for n in self._list:

            if n.isDevice:
                DebugDev(main=self._main,
                         path=self._path + '.' + n.name,
                         top=self._top,
                         parent=self,
                         dev=n,
                         noExpand=True)

            elif n.isVariable or n.isCommand:
                DebugHolder(main=self._main,
                            path=self._path + '.' + n.name,
                            top=self._top,
                            parent=self,
                            variable=n)

    def _expand(self) -> None:
        if self._dummy is None:
            return

        self.removeChild(self._dummy)
        self._dummy = None
        self._setup()

    def addNode(self, node: pyrogue.Node) -> None:
        self._list.append(node)


class DebugHolder(QTreeWidgetItem):
    """Tree item representing a variable entry in the selection tree.

    Parameters
    ----------
    main : TimePlotter
        Owning time-plotter widget.
    path : str
        Full Rogue node path for this variable/command.
    top : SelectionTree
        Owning selection-tree widget.
    parent : QTreeWidgetItem
        Parent tree item.
    variable : pyrogue.BaseVariable | pyrogue.BaseCommand
        Backing Rogue variable/command object.
    """

    def __init__(
        self,
        main: "TimePlotter",
        *,
        path: str,
        top: "SelectionTree",
        parent: QTreeWidgetItem,
        variable: pyrogue.BaseVariable | pyrogue.BaseCommand,
    ) -> None:
        QTreeWidgetItem.__init__(self,parent)
        self._main   = main
        self._top    = top
        self._parent = parent
        self._var    = variable
        self._path   = path
        self._depth  = parent._depth+1

        # print(f'DebugHolder: {self._var=}, {self._var.name=}, {self._var.path=}, {self._var.pollInterval=}')

        w = PyDMLabel(parent=None, init_channel=self._path + '/name')
        w.showUnits             = False
        w.precisionFromPV       = False
        w.alarmSensitiveContent = False
        w.alarmSensitiveBorder  = True

        fm = QFontMetrics(w.font())
        width = int(fm.width(self._path.split('.')[-1]) * 1.1)

        rightEdge = width + (self._top._tree.indentation() * self._depth)

        if rightEdge > self._top._colWidths[0]:
            self._top._colWidths[0] = rightEdge

        self._top._tree.setItemWidget(self,0,w)
        self.setToolTip(0,self._var.description)

        w = makeVariableViewWidget(self)
        self._top._tree.setItemWidget(self, 1, w)

        pe = QLineEdit(parent=None)

        def _makeSetPoll(le):
            def _setPoll():
                print(self._var)
                print(le.text())
                self._var.setPollInterval(float(le.text()))
            return _setPoll

        pe.returnPressed.connect(_makeSetPoll(pe))
        pe.setText(str(self._var.pollInterval))

        self._top._tree.setItemWidget(self, 3, pe)
        #self.setText(3,str(self._var.pollInterval))
        # self.setText(4,str(self._var._value))


        def funcgen(str):
            def ret():
                #calls the button's toggle method, and also changes the state of DefaultTop so that the correct color will be used in the plot
                if w._state is True:
                    self._main.do_remove(str)
                else:
                    self._main.do_add(str)
                w.toggle()
            return ret

        f = funcgen(self._path)
        w = ToggleButton(main=self._main, state=False, path=self._path)
        w.setText('Add')
        w.clicked.connect(f)


        self._top._tree.setItemWidget(self,2,w)
        width = fm.width('0xAAAAAAAA    ')

        if width > self._top._colWidths[1]:
            self._top._colWidths[1] = width



class SelectionTree(PyDMFrame):
    """Rogue node selection tree used by :class:`TimePlotter`.

    Parameters
    ----------
    main : TimePlotter
        Parent plotter widget coordinating add/remove actions.
    parent : QWidget | None, optional
        Parent Qt widget.
    init_channel : str | None, optional
        Initial Rogue channel address.
    incGroups : list[str] | None, optional
        Include filter for Rogue groups.
    excGroups : list[str] | None, optional
        Exclude filter for Rogue groups.
    """

    def __init__(
        self,
        main: "TimePlotter",
        parent: QWidget | None = None,
        init_channel: str | None = None,
        incGroups: list[str] | None = None,
        excGroups: list[str] | None = ['Hidden'],
    ) -> None:
        PyDMFrame.__init__(self, parent, init_channel)

        self._main = main
        self._node = None
        self._path = None

        self._incGroups = incGroups
        self._excGroups = excGroups
        self._tree      = None

        self._colWidths = [250,50,150,50,50]

    def connection_changed(self, connected: bool) -> None:

        build = (self._node is None) and (self._connected != connected and connected is True)
        super(SelectionTree, self).connection_changed(connected)

        if not build:
            return

        self._node = nodeFromAddress(self.channel)
        self._path = self.channel

        vb = QVBoxLayout()
        self.setLayout(vb)

        self._tree = QTreeWidget()
        vb.addWidget(self._tree)

        self._tree.setColumnCount(4)
        self._tree.setHeaderLabels(['Node','Value','Plot','Poll Interval'])

        self._tree.itemExpanded.connect(self._expandCb)

        hb = QHBoxLayout()
        vb.addLayout(hb)

        if self._node.isinstance(pyrogue.Root):
            hb.addWidget(PyDMPushButton(label='Read All',
                                        pressValue=True,
                                        init_channel=self._path + '.ReadAll'))
        else:
            hb.addWidget(PyDMPushButton(label='Read Recursive',
                                        pressValue=True,
                                        init_channel=self._path + '.ReadDevice'))


        self.setUpdatesEnabled(False)
        DebugDev(main = self._main, path = self._path, top=self, parent=self._tree, dev=self._node, noExpand=False)
        self.setUpdatesEnabled(True)

    @Slot(QTreeWidgetItem)
    def _expandCb(self, item: QTreeWidgetItem) -> None:
        self.setUpdatesEnabled(False)
        item._expand()

        self._tree.setColumnWidth(0,self._colWidths[0])
        self._tree.setColumnWidth(1,self._colWidths[1])
        # self._tree.resizeColumnToContents(1)
        # self._tree.resizeColumnToContents(2)
        self._tree.setColumnWidth(2,self._colWidths[2])
        self._tree.setColumnWidth(3,self._colWidths[3])
        self._tree.setColumnWidth(4,self._colWidths[4])
        # self._tree.setColumnWidth(4,self._colWidths[4])
        # self._tree.resizeColumnToContents(5)

        self.setUpdatesEnabled(True)

    @Property(str)
    def incGroups(self) -> str:
        if self._incGroups is None or len(self._incGroups) == 0:
            return ''
        else:
            return ','.join(self._incGroups)

    @incGroups.setter
    def incGroups(self, value: str) -> None:
        if value == '':
            self._incGroups = None
        else:
            self._incGroups = value.split(',')

    @Property(str)
    def excGroups(self) -> str:
        if self._excGroups is None or len(self._excGroups) == 0:
            return ''
        else:
            return ','.join(self._excGroups)

    @excGroups.setter
    def excGroups(self, value: str) -> None:
        if value == '':
            self._excGroups = None
        else:
            self._excGroups = value.split(',')

    def eventFilter(self, obj: object, event: QEvent) -> bool:
        if event.type() == QEvent.Wheel:
            return True
        else:
            return False

class ToggleButton(QPushButton):
    """Add/remove button with state-aware styling for selected plot channels.

    Parameters
    ----------
    main : TimePlotter
        Owning time-plotter widget.
    state : bool
        Initial toggle state.
    path : str
        Rogue node path represented by this button.
    """

    def __init__(self, main: "TimePlotter", state: bool, path: str) -> None:
        super().__init__()
        self._main = main
        self._state = state
        self._path = path
        self.styleSheet = 'QPushButton {background-color: %s;\
                                          color: black;\
                                          border: none;\
                                          font: bold}'

        self.setStyleSheet(self.styleSheet%self._main._addColor)

    def toggle(self) -> None:
        if self._state is True:
            self.setText('Add')
            self._state = False
            self.setStyleSheet(self.styleSheet%self._main._addColor)
        else:
            self.setText('Remove')
            self._state = True
            self.setStyleSheet(self.styleSheet%self._main._colorSelector.current_color())


    def setStyle(self, color: str) -> None:
        self.setStyleSheet(self.styleSheet%color)

[docs] class TimePlotter(PyDMFrame): """Widget combining variable selection tree and live time plots. Parameters ---------- parent : QWidget | None, optional Parent Qt widget. init_channel : str | None, optional Initial Rogue channel address. """ def __init__(self, parent: QWidget | None = None, init_channel: str | None = None) -> None: super().__init__(parent, init_channel) self._node = None self._addColor = '#dddddd' self._colorSelector = ColorSelector()
[docs] def connection_changed(self, connected: bool) -> None: build = (self._node is None) and (self._connected != connected and connected is True) super(TimePlotter, self).connection_changed(connected) if not build: return self._node = nodeFromAddress(self.channel) self.setup_ui()
[docs] def setup_ui(self) -> None: vb = QVBoxLayout() self.setLayout(vb) main_layout = QHBoxLayout() main_box = QGroupBox(parent=self) main_box.setLayout(main_layout) buttons_layout = QHBoxLayout() buttons_box = QGroupBox(parent=self) buttons_box.setLayout(buttons_layout) self.width_edit = QLineEdit() apply_btn = QPushButton() apply_btn.setText("Set width") apply_btn.clicked.connect(self.do_setwidth) auto_axis_btn = QPushButton() auto_axis_btn.setText("Auto height") auto_axis_btn.clicked.connect(self.do_autoheight) # Create the legend layout self.legend_layout = QVBoxLayout() self.legend_layout.setContentsMargins(10, 10, 10, 10) # Create a Frame to host the items in the legend self.frm_legend = QFrame(parent=self) self.frm_legend.setLayout(self.legend_layout) # Create a ScrollArea self.scroll_area = QScrollArea(parent=self) self.scroll_area.setMinimumHeight(200) self.scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) self.scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.scroll_area.setWidgetResizable(True) self.scroll_area.setAlignment(QtCore.Qt.AlignTop) # Add the Frame to the scroll area self.scroll_area.setWidget(self.frm_legend) buttons_layout.addWidget(self.width_edit) buttons_layout.addWidget(apply_btn) buttons_layout.addWidget(auto_axis_btn) plots_layout = QHBoxLayout() plots_box = QGroupBox(parent=self) plots_box.setLayout(plots_layout) self.plots = pwt.PyDMTimePlot(parent=None, background = '#f6f6f6', plot_by_timestamps = True) self.plots.setShowLegend(False) self.plots.setShowXGrid(True) self.plots.setShowYGrid(True) self.plots.setTitle('Plots') self.plots.setTimeSpan(100) plots_layout.addWidget(self.plots) plots_layout.addWidget(buttons_box) selection_layout = QVBoxLayout() self.selection_tree = SelectionTree(main=self,parent=None,init_channel=self.channel) self.selection_tree.setMinimumWidth(718) selection_layout.addWidget(self.selection_tree) selection_layout.addWidget(self.scroll_area) selection_box = QGroupBox() selection_box.setLayout(selection_layout) selection_splitter = QSplitter(Qt.Vertical) selection_splitter.addWidget(self.selection_tree) selection_splitter.addWidget(self.scroll_area) graphs_layout = QVBoxLayout() graphs_layout.addWidget(self.plots) graphs_layout.addWidget(buttons_box) graphs_box = QGroupBox() graphs_box.setLayout(graphs_layout) main_splitter = QSplitter(Qt.Horizontal) main_splitter.addWidget(selection_splitter) main_splitter.addWidget(graphs_box) main_layout.addWidget(main_splitter) vb.addWidget(main_box)
[docs] def do_add(self, path: str) -> None: self.plots.addYChannel(y_channel=path, color = self._colorSelector.take_color(path), lineWidth = 5) self.plots.setShowLegend(False) disp = LegendRow(parent = self,path=path,main = self) disp.setMaximumHeight(50) self.legend_layout.addWidget(disp)
[docs] def do_remove(self, path: str) -> None: curve = self.plots.findCurve(path) self.plots.removeYChannel(curve) for widget in self.frm_legend.findChildren(QWidget): if isinstance(widget,LegendRow) and widget._path == path: widget.setParent(None) widget.deleteLater()
[docs] def do_setwidth(self) -> None: text = self.width_edit.text() try: val = float(text) self.plots.setTimeSpan(val) except Exception: pass
[docs] def do_autoheight(self) -> None: # self.plots.setAutoRangeY(True) # self.plots.autoRangeY = True self.plots.plotItem.enableAutoRange(ViewBox.YAxis, enable=True)
[docs] def minimumSizeHint(self) -> QtCore.QSize: return QtCore.QSize(1500, 900)
[docs] def ui_filepath(self) -> None: return None
class LegendRow(Display): """Legend row widget for one plotted channel. Parameters ---------- parent : QWidget | None, optional Parent Qt widget. args : list[str] | None, optional Display argument list forwarded to :class:`pydm.Display`. macros : dict[str, str] | None, optional Macro substitutions forwarded to :class:`pydm.Display`. path : str | None, optional Rogue path of the plotted variable represented by this legend row. main : TimePlotter | None, optional Owning time-plotter widget. """ def __init__( self, parent: QWidget | None = None, args: list[str] | None = None, macros: dict[str, str] | None = None, path: str | None = None, main: TimePlotter | None = None, ) -> None: super(LegendRow, self).__init__(parent=parent, args=args, macros=None) self._path = path self.sizeX = 40 self.sizeY = 40 self._main = main self.setMaximumHeight(50) self.setup_ui() def setup_ui(self) -> None: #setup main layout main_layout = QHBoxLayout() main_box = QGroupBox(parent=self) main_box.setLayout(main_layout) #Add widgets to layout main_layout.addWidget(QLabel(self._path)) button = ToggleButton(main=self._main, state = True, path = self._path) button.setStyle(self._main._colorSelector.current_color()) button.setMaximumWidth(150) button.setMaximumHeight(50) button.setText('Remove') def legendbuttonfuncgen(path): def f(): self._main.do_remove(path) for widget in self._main.selection_tree.findChildren(QWidget): if isinstance(widget,ToggleButton) and widget._path == path: widget.toggle() return f button.clicked.connect(legendbuttonfuncgen(self._path)) main_layout.addWidget(button) self.setLayout(main_layout) def ui_filepath(self) -> None: # No UI file is being used return None class ColorSelector(): """Allocates reusable colors for plot channels.""" def __init__(self) -> None: self._colorList = ['#F94144', '#F3722C', '#F8961E', '#F9844A', '#F9C74F', '#90BE6D', '#43AA8B', '#4D908E', '#577590', '#277DA1','#f70a0a','#f75d0a','#f7a00a','#f7f30a','#98f70a','#1af70a','#0af7c0','#0accf7','#0a75f7','#0a1ef7','#710af7','#e70af7','#f70a71','#8a2d06','#838a06''#188a06','#188a06','#188a06'] self._currentDict = {} self._currentColor = None def take_color(self, channel: str) -> str: if len(self._colorList) == 0: self._colorList.append(self.generate_new_color()) random.shuffle(self._colorList) color = self._colorList.pop() self._currentDict[channel] = color self._currentColor = color return color def give_back_color(self, channel: str) -> None: color = self.currentDict.pop(channel) self._colorList.append(color) random.shuffle(self._colorList) def current_color(self) -> str | None: return self._currentColor def generate_new_color(self) -> str: return '#' + hex(random.randint(0x100000,0xffffff))[2:] #<----------Change this