From cb9912438d6ff66ebb1e88024234ba9fcd443ded Mon Sep 17 00:00:00 2001 From: tangkong Date: Wed, 31 Jul 2024 15:18:30 -0700 Subject: [PATCH 1/9] REF/ENH: move common model methods to a common view submodule, start work on LivePVTableModel and NestableTableModel --- superscore/tests/test_widgets.py | 2 +- superscore/widgets/page/entry.py | 2 +- superscore/widgets/page/search.py | 73 +------ superscore/widgets/{tree.py => views.py} | 267 +++++++++++++++++++++-- superscore/widgets/window.py | 2 +- 5 files changed, 258 insertions(+), 88 deletions(-) rename superscore/widgets/{tree.py => views.py} (59%) diff --git a/superscore/tests/test_widgets.py b/superscore/tests/test_widgets.py index 2357ba9..f3bd2f2 100644 --- a/superscore/tests/test_widgets.py +++ b/superscore/tests/test_widgets.py @@ -7,7 +7,7 @@ from superscore.model import Collection, Root from superscore.widgets.core import DataWidget -from superscore.widgets.tree import RootTree +from superscore.widgets.views import RootTree @pytest.mark.parametrize( diff --git a/superscore/widgets/page/entry.py b/superscore/widgets/page/entry.py index 709f7b6..6a6a53b 100644 --- a/superscore/widgets/page/entry.py +++ b/superscore/widgets/page/entry.py @@ -7,7 +7,7 @@ from superscore.model import Collection from superscore.widgets.core import DataWidget, Display, NameDescTagsWidget from superscore.widgets.manip_helpers import insert_widget -from superscore.widgets.tree import RootTree +from superscore.widgets.views import RootTree class CollectionPage(Display, DataWidget): diff --git a/superscore/widgets/page/search.py b/superscore/widgets/page/search.py index 7dbe99e..77633a3 100644 --- a/superscore/widgets/page/search.py +++ b/superscore/widgets/page/search.py @@ -11,6 +11,7 @@ from superscore.model import Collection, Entry, Readback, Setpoint, Snapshot from superscore.widgets import ICON_MAP from superscore.widgets.core import Display +from superscore.widgets.views import BaseTableEntryModel, ButtonDelegate logger = logging.getLogger(__name__) @@ -150,7 +151,7 @@ def subfilter_results(self) -> None: self.proxy_model.invalidateFilter() -class ResultModel(QtCore.QAbstractTableModel): +class ResultModel(BaseTableEntryModel): headers: List[str] = ['Name', 'Type', 'Description', 'Created', 'Open'] def __init__(self, *args, entries: List[Entry] = None, **kwargs) -> None: @@ -198,76 +199,6 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: # if nothing is found, return invalid QVariant return QtCore.QVariant() - def rowCount(self, index): - return len(self.entries) - - def columnCount(self, index): - return len(self.headers) - - def headerData( - self, - section: int, - orientation: QtCore.Qt.Orientation, - role: int - ) -> Any: - """ - Returns the header data for the model. - Currently only displays horizontal header data - """ - if role != QtCore.Qt.DisplayRole: - return - - if orientation == QtCore.Qt.Horizontal: - return self.headers[section] - - def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlag: - """ - Returns the item flags for the given ``index``. The returned - item flag controls what behaviors the item supports. - - Parameters - ---------- - index : QtCore.QModelIndex - the index referring to a cell of the TableView - - Returns - ------- - QtCore.Qt.ItemFlag - the ItemFlag corresponding to the cell - """ - if (index.column() == len(self.headers) - 1): - return QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEnabled - else: - return QtCore.Qt.ItemIsEnabled - - -class ButtonDelegate(QtWidgets.QStyledItemDelegate): - clicked = QtCore.Signal(QtCore.QModelIndex) - - def __init__(self, *args, button_text: str = '', **kwargs): - self.button_text = button_text - super().__init__(*args, **kwargs) - - def createEditor( - self, - parent: QtWidgets.QWidget, - option, - index: QtCore.QModelIndex - ) -> QtWidgets.QWidget: - button = QtWidgets.QPushButton(self.button_text, parent) - button.clicked.connect( - lambda _, index=index: self.clicked.emit(index) - ) - return button - - def updateEditorGeometry( - self, - editor: QtWidgets.QWidget, - option: QtWidgets.QStyleOptionViewItem, - index: QtCore.QModelIndex - ) -> None: - return editor.setGeometry(option.rect) - class ResultFilterProxyModel(QtCore.QSortFilterProxyModel): """ diff --git a/superscore/widgets/tree.py b/superscore/widgets/views.py similarity index 59% rename from superscore/widgets/tree.py rename to superscore/widgets/views.py index f547547..9992d3c 100644 --- a/superscore/widgets/tree.py +++ b/superscore/widgets/views.py @@ -5,16 +5,16 @@ from __future__ import annotations import logging -from typing import Any, ClassVar, Generator, List, Optional +from typing import Any, Callable, ClassVar, Generator, List, Optional, Union from uuid import UUID from weakref import WeakValueDictionary +import numpy as np import qtawesome as qta -from PyQt5.QtCore import Qt -from qtpy import QtCore +from qtpy import QtCore, QtGui, QtWidgets from superscore.client import Client -from superscore.model import Entry, Nestable, Root +from superscore.model import Collection, Entry, Nestable, Root, Snapshot from superscore.qt_helpers import QDataclassBridge from superscore.widgets import ICON_MAP @@ -255,7 +255,7 @@ def __init__( def headerData( self, section: int, - orientation: Qt.Orientation, + orientation: QtCore.Qt.Orientation, role: int ) -> Any: """ @@ -276,10 +276,10 @@ def headerData( Any requested header data """ - if role != Qt.DisplayRole: + if role != QtCore.Qt.DisplayRole: return - if orientation == Qt.Horizontal: + if orientation == QtCore.Qt.Horizontal: return self.headers[section] def index( @@ -416,20 +416,259 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: # special handling for status info if index.column() == 1: - if role == Qt.DisplayRole: + if role == QtCore.Qt.DisplayRole: return item.data(1) - if role == Qt.TextAlignmentRole: - return Qt.AlignLeft + if role == QtCore.Qt.TextAlignmentRole: + return QtCore.Qt.AlignLeft - if role == Qt.ToolTipRole: + if role == QtCore.Qt.ToolTipRole: return item.tooltip() - if role == Qt.DisplayRole: + if role == QtCore.Qt.DisplayRole: return item.data(index.column()) - if role == Qt.UserRole: + if role == QtCore.Qt.UserRole: return item - if role == Qt.DecorationRole and index.column() == 0: + if role == QtCore.Qt.DecorationRole and index.column() == 0: return item.icon() return None + + +class BaseTableEntryModel(QtCore.QAbstractTableModel): + """ + Common methods for table model that holds onto entries. + To subclass this: + - implement the `.data()` method and specify handling for your chosen columns + and Qt display roles + - define the header names + + Enables the editable flag for the last row for open-page-buttons + """ + entries: List[Entry] + headers: List[str] + + def __init__( + self, + *args, + entries: Optional[List[Entry]] = None, + **kwargs + ) -> None: + self.entries = entries or [] + super().__init__(*args, **kwargs) + + def rowCount(self, index): + return len(self.entries) + + def columnCount(self, index): + return len(self.headers) + + def headerData( + self, + section: int, + orientation: QtCore.Qt.Orientation, + role: int + ) -> Any: + """ + Returns the header data for the model. + Currently only displays horizontal header data + """ + if role != QtCore.Qt.DisplayRole: + return + + if orientation == QtCore.Qt.Horizontal: + return self.headers[section] + + def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlag: + """ + Returns the item flags for the given ``index``. The returned + item flag controls what behaviors the item supports. + + Parameters + ---------- + index : QtCore.QModelIndex + the index referring to a cell of the TableView + + Returns + ------- + QtCore.Qt.ItemFlag + the ItemFlag corresponding to the cell + """ + if (index.column() == len(self.headers) - 1): + return QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEnabled + else: + return QtCore.Qt.ItemIsEnabled + + def add_entry(self, entry: Entry) -> None: + if entry in self.entries or not isinstance(entry, Entry): + return + + self.entries.append[entry] + + +class LivePVTableModel(BaseTableEntryModel): + # Takes PV-entries (Parameter, Setpoint, Readback) + # shows live details (current PV status, severity) + # shows setpoints (can be blank) + # open details delegate + + # Hide un-needed rows + headers: List[str] = ['PV Name', 'Stored Value', 'Live Value', 'Timestamp', + 'Status', 'Severity', 'Open'] + + def __init__( + self, + *args, + entries: Optional[List[Entry]] = None, + client: Optional[Client] = None, + open_page_slot: Optional[Callable] = None, + **kwargs + ) -> None: + self.client = client + self.open_page_slot = open_page_slot + super().__init__(*args, entries=entries, **kwargs) + + def data(self, index: QtCore.QModelIndex, role: int) -> Any: + """ + Returns the data stored under the given role for the item + referred to by the index. + + Parameters + ---------- + index : QtCore.QModelIndex + An index referring to a cell of the TableView + role : int + The requested data role. + + Returns + ------- + Any + the requested data + """ + entry: Entry = self.entries[index.row()] + + # Special handling for open button delegate + if index.column() == (len(self.headers) - 1): + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + return 'click to open' + + if index.column() == 0: # name column + if role == QtCore.Qt.DecorationRole: + return ICON_MAP.get(type(entry), QtCore.QVariant()) + name_text = getattr(entry, 'pv_name') + return name_text + + if role not in (QtCore.Qt.DisplayRole, QtCore.Qt.BackgroundRole): + # table is read only + return QtCore.QVariant() + + if index.column() == 1: # Stored Value + return getattr(entry, 'data', '--') + elif index.column() == 2: # Live Value + # TODO: cache / control polling + live_value = self.client.cl.get(entry.pv_name) + is_close = self.is_close(live_value, getattr(entry, 'data', None)) + if role == QtCore.Qt.BackgroundRole and not is_close: + return QtGui.QColor('red') + return str(live_value) + elif index.column() == 3: # Timestamp + return entry.creation_time.strftime('%Y/%m/%d %H:%M') + elif index.column() == 4: # Status + return getattr(entry, 'status', '--') + elif index.column() == 5: # Severity + return getattr(entry, 'severity', '--') + elif index.column() == 6: # Severity + return "Open" + + # if nothing is found, return invalid QVariant + return QtCore.QVariant() + + def is_close(self, l_data, r_data) -> bool: + """returns true if ``l_data`` is close to ``r_data``""" + if l_data is None or r_data is None: + return False + return np.isclose(l_data, r_data) + + +class NestableTableModel(BaseTableEntryModel): + # Shows simplified details (created time, description, # pvs, # child colls) + # Open details delegate + headers: List[str] = ['Name', 'Description', 'Created', 'Open'] + + def __init__( + self, + *args, + entries: Optional[List[Union[Snapshot, Collection]]] = None, + open_page_slot: Optional[Callable] = None, + **kwargs + ) -> None: + self.open_page_slot = open_page_slot + super().__init__(*args, entries=entries, **kwargs) + + def data(self, index: QtCore.QModelIndex, role: int) -> Any: + """ + Returns the data stored under the given role for the item + referred to by the index. + + Parameters + ---------- + index : QtCore.QModelIndex + An index referring to a cell of the TableView + role : int + The requested data role. + + Returns + ------- + Any + the requested data + """ + entry: Entry = self.entries[index.row()] + + # Special handling for open button delegate + if index.column() == (len(self.headers) - 1): + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + return 'click to open' + + if role != QtCore.Qt.DisplayRole: + # table is read only + return QtCore.QVariant() + + if index.column() == 0: # name column + if role == QtCore.Qt.DecorationRole: + return ICON_MAP[type(entry)] + name_text = getattr(entry, 'title') + return name_text + elif index.column() == 1: # description + return getattr(entry, 'description') + elif index.column() == 2: # Created + return entry.creation_time.strftime('%Y/%m/%d %H:%M') + elif index.column() == 3: # Open Delegate + return "Open" + + +class ButtonDelegate(QtWidgets.QStyledItemDelegate): + clicked = QtCore.Signal(QtCore.QModelIndex) + + def __init__(self, *args, button_text: str = '', **kwargs): + self.button_text = button_text + super().__init__(*args, **kwargs) + + def createEditor( + self, + parent: QtWidgets.QWidget, + option, + index: QtCore.QModelIndex + ) -> QtWidgets.QWidget: + button = QtWidgets.QPushButton(self.button_text, parent) + button.clicked.connect( + lambda _, index=index: self.clicked.emit(index) + ) + return button + + def updateEditorGeometry( + self, + editor: QtWidgets.QWidget, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex + ) -> None: + return editor.setGeometry(option.rect) diff --git a/superscore/widgets/window.py b/superscore/widgets/window.py index 9a08243..2cd4704 100644 --- a/superscore/widgets/window.py +++ b/superscore/widgets/window.py @@ -15,7 +15,7 @@ from superscore.widgets.core import Display from superscore.widgets.page import PAGE_MAP from superscore.widgets.page.search import SearchPage -from superscore.widgets.tree import RootTree +from superscore.widgets.views import RootTree logger = logging.getLogger(__name__) From 32a9f9ef411b5b37e298ab118a757f14202d136f Mon Sep 17 00:00:00 2001 From: tangkong Date: Fri, 2 Aug 2024 09:14:59 -0700 Subject: [PATCH 2/9] ENH: add _PVPollThread to properly poll for values, adjust icon acquisition --- superscore/widgets/views.py | 155 ++++++++++++++++++++++++++++++++++-- 1 file changed, 149 insertions(+), 6 deletions(-) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index 9992d3c..84f1e25 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -5,7 +5,9 @@ from __future__ import annotations import logging -from typing import Any, Callable, ClassVar, Generator, List, Optional, Union +import time +from typing import (Any, Callable, ClassVar, Dict, Generator, List, Optional, + Union) from uuid import UUID from weakref import WeakValueDictionary @@ -193,7 +195,7 @@ def icon(self): icon_id = ICON_MAP.get(type(self._data), None) if icon_id is None: return - return qta.icon(ICON_MAP[type(self._data)]) + return qta.icon(icon_id) def build_tree(entry: Entry, parent: Optional[EntryItem] = None) -> EntryItem: @@ -505,6 +507,13 @@ def add_entry(self, entry: Entry) -> None: self.entries.append[entry] + def icon(self, entry: Entry) -> Optional[QtGui.QIcon]: + """return icon for this ``entry``""" + icon_id = ICON_MAP.get(type(entry), None) + if icon_id is None: + return + return qta.icon(icon_id) + class LivePVTableModel(BaseTableEntryModel): # Takes PV-entries (Parameter, Setpoint, Readback) @@ -527,6 +536,64 @@ def __init__( self.client = client self.open_page_slot = open_page_slot super().__init__(*args, entries=entries, **kwargs) + self._data_cache: Dict[str, Any] = {e.pv_name: None for e in self.entries} + self._workers: List[_PVPollThread] = [] + + self._polling = False + + self.start() + + def start(self) -> None: + """Start the polling thread""" + if self._polling: + return + + self._polling = True + self._poll_thread = _PVPollThread( + data=self._data_cache, + poll_rate=1.0, + client=self.client + ) + + self._data_cache = self._poll_thread.data # Shared reference + self._poll_thread.data_ready.connect(self._data_ready) + self._poll_thread.finished.connect(self._poll_thread_finished) + + self._poll_thread.start() + + @QtCore.Slot() + def _poll_thread_finished(self): + """Slot: poll thread finished and returned.""" + if self._poll_thread is None: + return + + self._poll_thread.data_ready.disconnect(self._data_ready) + self._poll_thread.finished.disconnect(self._poll_thread_finished) + self._polling = False + + @QtCore.Slot() + def _data_ready(self) -> None: + """ + Slot: initial indication from _DevicePollThread that the data dictionary is ready. + """ + self.beginResetModel() + + self.endResetModel() + if self._poll_thread is not None: + self._poll_thread.data_changed.connect(self._data_changed) + + @QtCore.Slot(str) + def _data_changed(self, pv_name: str) -> None: + """Slot: data changed for the given attribute in the thread.""" + try: + row = list(self._data_cache).index(pv_name) + except IndexError: + ... + else: + self.dataChanged.emit( + self.createIndex(row, 0), + self.createIndex(row, self.columnCount()), + ) def data(self, index: QtCore.QModelIndex, role: int) -> Any: """ @@ -554,19 +621,20 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: if index.column() == 0: # name column if role == QtCore.Qt.DecorationRole: - return ICON_MAP.get(type(entry), QtCore.QVariant()) + return self.icon(entry) + name_text = getattr(entry, 'pv_name') return name_text if role not in (QtCore.Qt.DisplayRole, QtCore.Qt.BackgroundRole): - # table is read only + # Other parts of the table are read only return QtCore.QVariant() if index.column() == 1: # Stored Value return getattr(entry, 'data', '--') elif index.column() == 2: # Live Value # TODO: cache / control polling - live_value = self.client.cl.get(entry.pv_name) + live_value = self.get_cache_data(entry.pv_name) is_close = self.is_close(live_value, getattr(entry, 'data', None)) if role == QtCore.Qt.BackgroundRole and not is_close: return QtGui.QColor('red') @@ -589,6 +657,81 @@ def is_close(self, l_data, r_data) -> bool: return False return np.isclose(l_data, r_data) + def get_cache_data(self, pv_name: str) -> str: + """ + Get data from cache if possible. If unavailable, dispatch to background + thread to fill. String-ifies data for display + """ + data = self._data_cache.get(pv_name, None) + if data is None: + # TODO: A neat spinny icon maybe? + return "fetching..." + else: + return str(data) + + +class _PVPollThread(QtCore.QThread): + """ + Polling thread for LivePVTableModel + + emits ``data_changed(pv: str)`` when a pv has new data + """ + data_ready: ClassVar[QtCore.Signal] = QtCore.Signal() + data_changed: ClassVar[QtCore.Signal] = QtCore.Signal(str) + running: bool + + # TODO: replace Any with unified superscore data type + data: Dict[str, Any] + poll_rate: float + + def __init__( + self, + poll_rate: float, + data: Dict[str, Any], + client: Client, + *, + parent: Optional[QtWidgets.QWidget] = None + ): + super().__init__(parent=parent) + self.data = data + self.poll_rate = poll_rate + self.client = client + self.running = False + self._attrs = set() + + def stop(self) -> None: + """Stop the polling thread.""" + self.running = False + + def _update_data(self, pv_name): + try: + val = self.client.cl.get(pv_name) + except Exception as e: + logger.warning(f'Unable to get data from {pv_name}: {e}') + return + self.data[pv_name] = val + + def run(self): + """The thread polling loop.""" + self.running = True + + self.data_ready.emit() + + while self.running: + t0 = time.monotonic() + for pv_name in self.data: + self._update_data(pv_name) + if not self.running: + break + time.sleep(0) + + if self.poll_rate <= 0.0: + # A zero or below means "single shot" updates. + break + + elapsed = time.monotonic() - t0 + time.sleep(max((0, self.poll_rate - elapsed))) + class NestableTableModel(BaseTableEntryModel): # Shows simplified details (created time, description, # pvs, # child colls) @@ -635,7 +778,7 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: if index.column() == 0: # name column if role == QtCore.Qt.DecorationRole: - return ICON_MAP[type(entry)] + return self.icon(entry) name_text = getattr(entry, 'title') return name_text elif index.column() == 1: # description From 15b448dc8c837c1b256bcbb05b53ed47e23daaff Mon Sep 17 00:00:00 2001 From: tangkong Date: Tue, 13 Aug 2024 09:26:35 -0700 Subject: [PATCH 3/9] DOC: fix type hints for ControlLayer.get --- superscore/control_layers/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/superscore/control_layers/core.py b/superscore/control_layers/core.py index b77c88a..1933514 100644 --- a/superscore/control_layers/core.py +++ b/superscore/control_layers/core.py @@ -83,12 +83,12 @@ def get(self, address: Union[str, Iterable[str]]) -> Union[EpicsData, Iterable[E Parameters ---------- - address : Union[str, list[str]] + address : Union[str, Iterable[str]] The PV(s) to get values for. Returns ------- - Union[EpicsData, list[EpicsData]] + Union[EpicsData, Iterable[EpicsData]] The requested data """ # Dispatches to _get_single and _get_list depending on type From 4259f587a4d59bc931ad08b79d40596e615cfeb9 Mon Sep 17 00:00:00 2001 From: tangkong Date: Tue, 27 Aug 2024 18:03:10 -0700 Subject: [PATCH 4/9] MNT: refactor headers to use enums, add _get_live_data_field helper and refactor LivePVTableModel.is_close --- superscore/widgets/views.py | 112 ++++++++++++++++++++++++++---------- 1 file changed, 82 insertions(+), 30 deletions(-) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index 84f1e25..403df3b 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -6,6 +6,7 @@ import logging import time +from enum import Enum, IntEnum, auto from typing import (Any, Callable, ClassVar, Dict, Generator, List, Optional, Union) from uuid import UUID @@ -16,13 +17,18 @@ from qtpy import QtCore, QtGui, QtWidgets from superscore.client import Client -from superscore.model import Collection, Entry, Nestable, Root, Snapshot +from superscore.control_layers import EpicsData +from superscore.model import (Collection, Entry, Nestable, Parameter, Readback, + Root, Setpoint, Snapshot) from superscore.qt_helpers import QDataclassBridge from superscore.widgets import ICON_MAP logger = logging.getLogger(__name__) +PVEntry = Union[Parameter, Setpoint, Readback] + + class EntryItem: """Node representing one Entry""" _bridge_cache: ClassVar[ @@ -515,40 +521,55 @@ def icon(self, entry: Entry) -> Optional[QtGui.QIcon]: return qta.icon(icon_id) +class LivePVHeaderEnum(IntEnum): + """ + Enum for more readable header names. Underscores will be replaced with spaces + """ + PV_Name = 0 + Stored_Value = auto() + Live_Value = auto() + Timestamp = auto() + Stored_Status = auto() + Live_Status = auto() + Stored_Severity = auto() + Live_Severity = auto() + Open = auto() + + class LivePVTableModel(BaseTableEntryModel): - # Takes PV-entries (Parameter, Setpoint, Readback) + # Takes PV-entries # shows live details (current PV status, severity) # shows setpoints (can be blank) + # TO-DO: # open details delegate - # Hide un-needed rows - headers: List[str] = ['PV Name', 'Stored Value', 'Live Value', 'Timestamp', - 'Status', 'Severity', 'Open'] + headers: List[str] def __init__( self, *args, - entries: Optional[List[Entry]] = None, + entries: Optional[List[PVEntry]] = None, client: Optional[Client] = None, open_page_slot: Optional[Callable] = None, **kwargs ) -> None: + self.headers = [h.name.replace('_', ' ') for h in LivePVHeaderEnum] + self.HEADS = LivePVHeaderEnum self.client = client self.open_page_slot = open_page_slot super().__init__(*args, entries=entries, **kwargs) - self._data_cache: Dict[str, Any] = {e.pv_name: None for e in self.entries} - self._workers: List[_PVPollThread] = [] + self._data_cache: Dict[str, EpicsData] = {e.pv_name: None for e in self.entries} # noqa: F821 + self._poll_thread: Optional[_PVPollThread] = None self._polling = False - self.start() + self.start_polling() - def start(self) -> None: + def start_polling(self) -> None: """Start the polling thread""" if self._polling: return - self._polling = True self._poll_thread = _PVPollThread( data=self._data_cache, poll_rate=1.0, @@ -560,6 +581,14 @@ def start(self) -> None: self._poll_thread.finished.connect(self._poll_thread_finished) self._poll_thread.start() + self._polling = True + + def stop_polling(self) -> None: + if not self._polling: + return + + self._poll_thread.stop() + self._polling = False @QtCore.Slot() def _poll_thread_finished(self): @@ -612,52 +641,75 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: Any the requested data """ - entry: Entry = self.entries[index.row()] + entry: PVEntry = self.entries[index.row()] # Special handling for open button delegate if index.column() == (len(self.headers) - 1): if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): return 'click to open' - if index.column() == 0: # name column + if index.column() == self.HEADS.PV_Name: if role == QtCore.Qt.DecorationRole: return self.icon(entry) - - name_text = getattr(entry, 'pv_name') - return name_text + elif role == QtCore.Qt.DisplayRole: + name_text = getattr(entry, 'pv_name') + return name_text if role not in (QtCore.Qt.DisplayRole, QtCore.Qt.BackgroundRole): # Other parts of the table are read only return QtCore.QVariant() - if index.column() == 1: # Stored Value + if index.column() == self.HEADS.Stored_Value: return getattr(entry, 'data', '--') - elif index.column() == 2: # Live Value + elif index.column() == self.HEADS.Live_Value: # TODO: cache / control polling - live_value = self.get_cache_data(entry.pv_name) + live_value = self._get_live_data_field(entry, 'data') is_close = self.is_close(live_value, getattr(entry, 'data', None)) if role == QtCore.Qt.BackgroundRole and not is_close: return QtGui.QColor('red') return str(live_value) - elif index.column() == 3: # Timestamp + elif index.column() == self.HEADS.Timestamp: return entry.creation_time.strftime('%Y/%m/%d %H:%M') - elif index.column() == 4: # Status - return getattr(entry, 'status', '--') - elif index.column() == 5: # Severity - return getattr(entry, 'severity', '--') - elif index.column() == 6: # Severity + elif index.column() == self.HEADS.Stored_Status: + status = getattr(entry, 'status', '--') + return getattr(status, 'name', status) + elif index.column() == self.HEADS.Live_Status: + return self._get_live_data_field(entry, 'status') + elif index.column() == self.HEADS.Stored_Severity: + severity = getattr(entry, 'severity', '--') + return getattr(severity, 'name', severity) + elif index.column() == self.HEADS.Live_Severity: + return self._get_live_data_field(entry, 'severity') + elif index.column() == self.HEADS.Open: return "Open" # if nothing is found, return invalid QVariant return QtCore.QVariant() + def _get_live_data_field(self, entry: PVEntry, field: str) -> str: + """helper to get field from data cache""" + live_data = self.get_cache_data(entry.pv_name) + if not isinstance(live_data, EpicsData): + # Data is probably fetching, return as is + return live_data + + data_field = getattr(live_data, field) + if isinstance(data_field, Enum): + return str(getattr(data_field, 'name', data_field)) + else: + return data_field + def is_close(self, l_data, r_data) -> bool: """returns true if ``l_data`` is close to ``r_data``""" - if l_data is None or r_data is None: - return False - return np.isclose(l_data, r_data) + is_close = False + try: + is_close = np.isclose(l_data, r_data) + except TypeError: + pass + + return is_close - def get_cache_data(self, pv_name: str) -> str: + def get_cache_data(self, pv_name: str) -> EpicsData: """ Get data from cache if possible. If unavailable, dispatch to background thread to fill. String-ifies data for display @@ -667,7 +719,7 @@ def get_cache_data(self, pv_name: str) -> str: # TODO: A neat spinny icon maybe? return "fetching..." else: - return str(data) + return data class _PVPollThread(QtCore.QThread): From ed2e61e44e39a8a8f6683867ed7ba6f8ea042150 Mon Sep 17 00:00:00 2001 From: tangkong Date: Wed, 28 Aug 2024 13:41:59 -0700 Subject: [PATCH 5/9] DOC/MNT: docstring pass, make poll rate an init arg, add index_from_item helper method --- superscore/widgets/views.py | 110 ++++++++++++++++++++++++++++-------- 1 file changed, 87 insertions(+), 23 deletions(-) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index 403df3b..70df826 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -450,8 +450,15 @@ class BaseTableEntryModel(QtCore.QAbstractTableModel): - implement the `.data()` method and specify handling for your chosen columns and Qt display roles - define the header names + - define any custom functionality Enables the editable flag for the last row for open-page-buttons + + Parameters + ---------- + entries : Optional[List[Entry]], optional + A list of Entry objects to display in the table, by default None + """ entries: List[Entry] headers: List[str] @@ -465,17 +472,17 @@ def __init__( self.entries = entries or [] super().__init__(*args, **kwargs) - def rowCount(self, index): + def rowCount(self, parent_index: Optional[QtCore.QModelIndex] = None): return len(self.entries) - def columnCount(self, index): + def columnCount(self, parent_index: Optional[QtCore.QModelIndex] = None): return len(self.headers) def headerData( self, section: int, orientation: QtCore.Qt.Orientation, - role: int + role: int = QtCore.Qt.DisplayRole ) -> Any: """ Returns the header data for the model. @@ -542,27 +549,31 @@ class LivePVTableModel(BaseTableEntryModel): # shows setpoints (can be blank) # TO-DO: # open details delegate - # Hide un-needed rows + # methods for hide un-needed rows (user interaction?) headers: List[str] + _data_cache: Dict[str, EpicsData] + _poll_thread: Optional[_PVPollThread] def __init__( self, *args, + client: Client, entries: Optional[List[PVEntry]] = None, - client: Optional[Client] = None, open_page_slot: Optional[Callable] = None, + poll_rate: float = 1.0, **kwargs ) -> None: + super().__init__(*args, entries=entries, **kwargs) + self.headers = [h.name.replace('_', ' ') for h in LivePVHeaderEnum] self.HEADS = LivePVHeaderEnum self.client = client self.open_page_slot = open_page_slot - super().__init__(*args, entries=entries, **kwargs) - self._data_cache: Dict[str, EpicsData] = {e.pv_name: None for e in self.entries} # noqa: F821 - self._poll_thread: Optional[_PVPollThread] = None + self.poll_rate = poll_rate + self._data_cache = {e.pv_name: None for e in self.entries} # noqa: F821 + self._poll_thread = None self._polling = False - self.start_polling() def start_polling(self) -> None: @@ -572,11 +583,10 @@ def start_polling(self) -> None: self._poll_thread = _PVPollThread( data=self._data_cache, - poll_rate=1.0, + poll_rate=self.poll_rate, client=self.client ) - self._data_cache = self._poll_thread.data # Shared reference self._poll_thread.data_ready.connect(self._data_ready) self._poll_thread.finished.connect(self._poll_thread_finished) @@ -584,6 +594,7 @@ def start_polling(self) -> None: self._polling = True def stop_polling(self) -> None: + """stop the polling thread, and mark it as stopped""" if not self._polling: return @@ -606,14 +617,17 @@ def _data_ready(self) -> None: Slot: initial indication from _DevicePollThread that the data dictionary is ready. """ self.beginResetModel() - self.endResetModel() + if self._poll_thread is not None: self._poll_thread.data_changed.connect(self._data_changed) @QtCore.Slot(str) def _data_changed(self, pv_name: str) -> None: - """Slot: data changed for the given attribute in the thread.""" + """ + Slot: data changed for the given attribute in the thread. + Signals the entire row to update (a single PV worth of data) + """ try: row = list(self._data_cache).index(pv_name) except IndexError: @@ -624,6 +638,18 @@ def _data_changed(self, pv_name: str) -> None: self.createIndex(row, self.columnCount()), ) + def index_from_item( + self, + item: PVEntry, + column: Union[str, int] + ) -> QtCore.QModelIndex: + row = self.entries.index(item) + if isinstance(column, int): + col = column + elif isinstance(column, str): + col = self.HEADS[column.replace(' ', '_')].value + return self.createIndex(row, col, item) + def data(self, index: QtCore.QModelIndex, role: int) -> Any: """ Returns the data stored under the given role for the item @@ -662,7 +688,6 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: if index.column() == self.HEADS.Stored_Value: return getattr(entry, 'data', '--') elif index.column() == self.HEADS.Live_Value: - # TODO: cache / control polling live_value = self._get_live_data_field(entry, 'data') is_close = self.is_close(live_value, getattr(entry, 'data', None)) if role == QtCore.Qt.BackgroundRole and not is_close: @@ -686,8 +711,22 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: # if nothing is found, return invalid QVariant return QtCore.QVariant() - def _get_live_data_field(self, entry: PVEntry, field: str) -> str: - """helper to get field from data cache""" + def _get_live_data_field(self, entry: PVEntry, field: str) -> Any: + """ + Helper to get field from data cache + + Parameters + ---------- + entry : PVEntry + The Entry to get data from + field : str + The field in the EpicsData to fetch (data, status, severity, timestamp) + + Returns + ------- + Any + The data from EpicsData(entry.pv_name).field + """ live_data = self.get_cache_data(entry.pv_name) if not isinstance(live_data, EpicsData): # Data is probably fetching, return as is @@ -700,7 +739,10 @@ def _get_live_data_field(self, entry: PVEntry, field: str) -> str: return data_field def is_close(self, l_data, r_data) -> bool: - """returns true if ``l_data`` is close to ``r_data``""" + """ + Returns True if ``l_data`` is close to ``r_data``, False otherwise. + Intended for use with numeric values. + """ is_close = False try: is_close = np.isclose(l_data, r_data) @@ -726,21 +768,35 @@ class _PVPollThread(QtCore.QThread): """ Polling thread for LivePVTableModel - emits ``data_changed(pv: str)`` when a pv has new data + Emits ``data_changed(pv: str)`` when a pv has new data + Parameters + ---------- + client : superscore.client.Client + The client to communicate to PVs through + + data : dict[str, EpicsData] + Per-PV EpicsData, potentially generated previously. + + poll_rate : float + The poll rate in seconds. A zero or negative poll rate will indicate + single-shot mode. In "single shot" mode, the data is queried exactly + once and then the thread exits. + + parent : QWidget, optional, keyword-only + The parent widget. """ data_ready: ClassVar[QtCore.Signal] = QtCore.Signal() data_changed: ClassVar[QtCore.Signal] = QtCore.Signal(str) running: bool - # TODO: replace Any with unified superscore data type - data: Dict[str, Any] + data: Dict[str, EpicsData] poll_rate: float def __init__( self, - poll_rate: float, - data: Dict[str, Any], client: Client, + data: Dict[str, EpicsData], + poll_rate: float, *, parent: Optional[QtWidgets.QWidget] = None ): @@ -756,12 +812,20 @@ def stop(self) -> None: self.running = False def _update_data(self, pv_name): + """ + Update the internal data cache with new data from EPICS. + Emit self.data_changed signal if data has changed + """ try: val = self.client.cl.get(pv_name) except Exception as e: logger.warning(f'Unable to get data from {pv_name}: {e}') return - self.data[pv_name] = val + + # ControlLayer.get may return CommunicationError instead of raising + if not isinstance(val, Exception) and self.data[pv_name] != val: + self.data_changed.emit(pv_name) + self.data[pv_name] = val def run(self): """The thread polling loop.""" From 123ae2391253d10e4ce02e791dba35b919c57c7a Mon Sep 17 00:00:00 2001 From: tangkong Date: Wed, 28 Aug 2024 13:42:38 -0700 Subject: [PATCH 6/9] TST: add basic LivePVTableModel tests --- superscore/tests/test_views.py | 55 ++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 superscore/tests/test_views.py diff --git a/superscore/tests/test_views.py b/superscore/tests/test_views.py new file mode 100644 index 0000000..31c3a15 --- /dev/null +++ b/superscore/tests/test_views.py @@ -0,0 +1,55 @@ +from unittest.mock import MagicMock + +import pytest +from pytestqt.qtbot import QtBot +from qtpy import QtCore + +from superscore.client import Client +from superscore.model import Parameter +from superscore.widgets.views import LivePVTableModel + + +@pytest.fixture(scope='function') +def pv_poll_model( + mock_client: Client, + parameter_with_readback: Parameter, + qtbot: QtBot +) -> LivePVTableModel: + model = LivePVTableModel( + client=mock_client, + entries=[parameter_with_readback], + poll_rate=1.0 + ) + + # Make sure we never actually call EPICS + model.client.cl.get = MagicMock(return_value=1) + yield model + + model.stop_polling() + + qtbot.wait_until(lambda: not model._polling) + + +def test_pvmodel_polling(pv_poll_model: LivePVTableModel, qtbot: QtBot): + thread = pv_poll_model._poll_thread + qtbot.wait_until(lambda: thread.running) + + pv_poll_model.stop_polling() + qtbot.wait_until(lambda: thread.isFinished(), timeout=10000) + assert not thread.running + + +def test_pvmodel_update(pv_poll_model: LivePVTableModel, qtbot: QtBot): + qtbot.wait_until(lambda: pv_poll_model._poll_thread.running) + + assert pv_poll_model._data_cache + + # make the mock cl return a new value + pv_poll_model.client.cl.get = MagicMock(return_value=3) + + qtbot.wait_signal(pv_poll_model.dataChanged) + + data_index = pv_poll_model.index_from_item(pv_poll_model.entries[0], 'Live Value') + qtbot.wait_until( + lambda: pv_poll_model.data(data_index, QtCore.Qt.DisplayRole) == '3' + ) From 934c65aa1e4d83bd4bc158ce7b62d4fec011551b Mon Sep 17 00:00:00 2001 From: tangkong Date: Wed, 28 Aug 2024 14:03:04 -0700 Subject: [PATCH 7/9] DOC: pre-release notes --- .../72-mnt_common_views.rst | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/source/upcoming_release_notes/72-mnt_common_views.rst diff --git a/docs/source/upcoming_release_notes/72-mnt_common_views.rst b/docs/source/upcoming_release_notes/72-mnt_common_views.rst new file mode 100644 index 0000000..f2d512f --- /dev/null +++ b/docs/source/upcoming_release_notes/72-mnt_common_views.rst @@ -0,0 +1,24 @@ +72 mnt_common_views +################### + +API Breaks +---------- +- N/A + +Features +-------- +- Adds ``BaseTableEntryModel``, from which ``LivePVTableModel`` and ``NestableTableModel`` inherit. + These table models are intended to display ``Entry`` data and be reused across the application. +- Implements ``LivePVTableModel``, including polling behavior for live PV display. + +Bugfixes +-------- +- N/A + +Maintenance +----------- +- N/A + +Contributors +------------ +- tangkong From 9e6b8576376177dcd2b046472f0ca28ca067590e Mon Sep 17 00:00:00 2001 From: tangkong Date: Wed, 28 Aug 2024 15:28:42 -0700 Subject: [PATCH 8/9] DOC: more dosctring updates --- superscore/widgets/views.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index 70df826..e57b4ab 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -643,6 +643,23 @@ def index_from_item( item: PVEntry, column: Union[str, int] ) -> QtCore.QModelIndex: + """ + Create an index given a `PVEntry` and desired column. + The column name must be an option in `LivePVHeaderEnum`, or able to be + converted to one by swapping ' ' with '_' + + Parameters + ---------- + item : PVEntry + A PVEntry dataclass instance + column : Union[str, int] + A column name or column index + + Returns + ------- + QtCore.QModelIndex + The corresponding model index + """ row = self.entries.index(item) if isinstance(column, int): col = column @@ -753,11 +770,15 @@ def is_close(self, l_data, r_data) -> bool: def get_cache_data(self, pv_name: str) -> EpicsData: """ - Get data from cache if possible. If unavailable, dispatch to background - thread to fill. String-ifies data for display + Get data from cache if possible. If missing from cache, add pv_name for + the polling thread to update. """ data = self._data_cache.get(pv_name, None) + if data is None: + if pv_name not in self._data_cache: + self._data_cache[pv_name] = None + # TODO: A neat spinny icon maybe? return "fetching..." else: From 7a0772986a24ace4617e0603c2c9e25b05e0b2c3 Mon Sep 17 00:00:00 2001 From: tangkong Date: Thu, 29 Aug 2024 13:34:08 -0700 Subject: [PATCH 9/9] MNT: refine enum name handling, clean up unneeded attributes --- superscore/tests/test_views.py | 9 ++-- superscore/widgets/views.py | 94 +++++++++++++++++----------------- 2 files changed, 50 insertions(+), 53 deletions(-) diff --git a/superscore/tests/test_views.py b/superscore/tests/test_views.py index 31c3a15..ea13a54 100644 --- a/superscore/tests/test_views.py +++ b/superscore/tests/test_views.py @@ -18,30 +18,27 @@ def pv_poll_model( model = LivePVTableModel( client=mock_client, entries=[parameter_with_readback], - poll_rate=1.0 + poll_period=1.0 ) # Make sure we never actually call EPICS model.client.cl.get = MagicMock(return_value=1) + qtbot.wait_until(lambda: model._poll_thread.running) yield model model.stop_polling() - qtbot.wait_until(lambda: not model._polling) + qtbot.wait_until(lambda: not model._poll_thread.isRunning()) def test_pvmodel_polling(pv_poll_model: LivePVTableModel, qtbot: QtBot): thread = pv_poll_model._poll_thread - qtbot.wait_until(lambda: thread.running) - pv_poll_model.stop_polling() qtbot.wait_until(lambda: thread.isFinished(), timeout=10000) assert not thread.running def test_pvmodel_update(pv_poll_model: LivePVTableModel, qtbot: QtBot): - qtbot.wait_until(lambda: pv_poll_model._poll_thread.running) - assert pv_poll_model._data_cache # make the mock cl return a new value diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index e57b4ab..92a6289 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -528,19 +528,26 @@ def icon(self, entry: Entry) -> Optional[QtGui.QIcon]: return qta.icon(icon_id) -class LivePVHeaderEnum(IntEnum): +class LivePVHeader(IntEnum): """ Enum for more readable header names. Underscores will be replaced with spaces """ - PV_Name = 0 - Stored_Value = auto() - Live_Value = auto() - Timestamp = auto() - Stored_Status = auto() - Live_Status = auto() - Stored_Severity = auto() - Live_Severity = auto() - Open = auto() + PV_NAME = 0 + STORED_VALUE = auto() + LIVE_VALUE = auto() + TIMESTAMP = auto() + STORED_STATUS = auto() + LIVE_STATUS = auto() + STORED_SEVERITY = auto() + LIVE_SEVERITY = auto() + OPEN = auto() + + def header_name(self) -> str: + return self.name.title().replace('_', ' ') + + @classmethod + def from_header_name(cls, name: str) -> LivePVHeader: + return LivePVHeader[name.upper().replace(' ', '_')] class LivePVTableModel(BaseTableEntryModel): @@ -560,46 +567,43 @@ def __init__( client: Client, entries: Optional[List[PVEntry]] = None, open_page_slot: Optional[Callable] = None, - poll_rate: float = 1.0, + poll_period: float = 1.0, **kwargs ) -> None: super().__init__(*args, entries=entries, **kwargs) - self.headers = [h.name.replace('_', ' ') for h in LivePVHeaderEnum] - self.HEADS = LivePVHeaderEnum + self.headers = [h.header_name() for h in LivePVHeader] self.client = client self.open_page_slot = open_page_slot - self.poll_rate = poll_rate - self._data_cache = {e.pv_name: None for e in self.entries} # noqa: F821 + self.poll_period = poll_period + self._data_cache = {e.pv_name: None for e in entries} self._poll_thread = None - self._polling = False self.start_polling() def start_polling(self) -> None: """Start the polling thread""" - if self._polling: + if self._poll_thread and self._poll_thread.isRunning(): return self._poll_thread = _PVPollThread( data=self._data_cache, - poll_rate=self.poll_rate, - client=self.client + poll_period=self.poll_period, + client=self.client, + parent=self ) self._poll_thread.data_ready.connect(self._data_ready) self._poll_thread.finished.connect(self._poll_thread_finished) self._poll_thread.start() - self._polling = True def stop_polling(self) -> None: """stop the polling thread, and mark it as stopped""" - if not self._polling: + if not self._poll_thread.isRunning(): return self._poll_thread.stop() - self._polling = False @QtCore.Slot() def _poll_thread_finished(self): @@ -609,7 +613,6 @@ def _poll_thread_finished(self): self._poll_thread.data_ready.disconnect(self._data_ready) self._poll_thread.finished.disconnect(self._poll_thread_finished) - self._polling = False @QtCore.Slot() def _data_ready(self) -> None: @@ -664,7 +667,7 @@ def index_from_item( if isinstance(column, int): col = column elif isinstance(column, str): - col = self.HEADS[column.replace(' ', '_')].value + col = LivePVHeader.from_header_name(column).value return self.createIndex(row, col, item) def data(self, index: QtCore.QModelIndex, role: int) -> Any: @@ -691,7 +694,7 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): return 'click to open' - if index.column() == self.HEADS.PV_Name: + if index.column() == LivePVHeader.PV_NAME: if role == QtCore.Qt.DecorationRole: return self.icon(entry) elif role == QtCore.Qt.DisplayRole: @@ -702,27 +705,27 @@ def data(self, index: QtCore.QModelIndex, role: int) -> Any: # Other parts of the table are read only return QtCore.QVariant() - if index.column() == self.HEADS.Stored_Value: + if index.column() == LivePVHeader.STORED_VALUE: return getattr(entry, 'data', '--') - elif index.column() == self.HEADS.Live_Value: + elif index.column() == LivePVHeader.LIVE_VALUE: live_value = self._get_live_data_field(entry, 'data') is_close = self.is_close(live_value, getattr(entry, 'data', None)) if role == QtCore.Qt.BackgroundRole and not is_close: return QtGui.QColor('red') return str(live_value) - elif index.column() == self.HEADS.Timestamp: + elif index.column() == LivePVHeader.TIMESTAMP: return entry.creation_time.strftime('%Y/%m/%d %H:%M') - elif index.column() == self.HEADS.Stored_Status: + elif index.column() == LivePVHeader.STORED_STATUS: status = getattr(entry, 'status', '--') return getattr(status, 'name', status) - elif index.column() == self.HEADS.Live_Status: + elif index.column() == LivePVHeader.LIVE_STATUS: return self._get_live_data_field(entry, 'status') - elif index.column() == self.HEADS.Stored_Severity: + elif index.column() == LivePVHeader.STORED_SEVERITY: severity = getattr(entry, 'severity', '--') return getattr(severity, 'name', severity) - elif index.column() == self.HEADS.Live_Severity: + elif index.column() == LivePVHeader.LIVE_SEVERITY: return self._get_live_data_field(entry, 'severity') - elif index.column() == self.HEADS.Open: + elif index.column() == LivePVHeader.OPEN: return "Open" # if nothing is found, return invalid QVariant @@ -760,13 +763,10 @@ def is_close(self, l_data, r_data) -> bool: Returns True if ``l_data`` is close to ``r_data``, False otherwise. Intended for use with numeric values. """ - is_close = False try: - is_close = np.isclose(l_data, r_data) + return np.isclose(l_data, r_data) except TypeError: - pass - - return is_close + return False def get_cache_data(self, pv_name: str) -> EpicsData: """ @@ -798,10 +798,10 @@ class _PVPollThread(QtCore.QThread): data : dict[str, EpicsData] Per-PV EpicsData, potentially generated previously. - poll_rate : float - The poll rate in seconds. A zero or negative poll rate will indicate - single-shot mode. In "single shot" mode, the data is queried exactly - once and then the thread exits. + poll_period : float + The poll period in seconds (time between poll events). A zero or + negative poll rate will indicate single-shot mode. In "single shot" + mode, the data is queried exactly once and then the thread exits. parent : QWidget, optional, keyword-only The parent widget. @@ -811,19 +811,19 @@ class _PVPollThread(QtCore.QThread): running: bool data: Dict[str, EpicsData] - poll_rate: float + poll_period: float def __init__( self, client: Client, data: Dict[str, EpicsData], - poll_rate: float, + poll_period: float, *, parent: Optional[QtWidgets.QWidget] = None ): super().__init__(parent=parent) self.data = data - self.poll_rate = poll_rate + self.poll_period = poll_period self.client = client self.running = False self._attrs = set() @@ -862,12 +862,12 @@ def run(self): break time.sleep(0) - if self.poll_rate <= 0.0: + if self.poll_period <= 0.0: # A zero or below means "single shot" updates. break elapsed = time.monotonic() - t0 - time.sleep(max((0, self.poll_rate - elapsed))) + time.sleep(max((0, self.poll_period - elapsed))) class NestableTableModel(BaseTableEntryModel):