From c103e803c998b83870995ec69b75f21fe5d5afc6 Mon Sep 17 00:00:00 2001 From: zdomke Date: Tue, 23 Apr 2024 16:25:09 -0700 Subject: [PATCH 1/5] ENH: Archiver Plot Autoscroll --- pydm/widgets/archiver_time_plot.py | 105 ++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 15 deletions(-) diff --git a/pydm/widgets/archiver_time_plot.py b/pydm/widgets/archiver_time_plot.py index f8e8a74db..545814381 100644 --- a/pydm/widgets/archiver_time_plot.py +++ b/pydm/widgets/archiver_time_plot.py @@ -8,7 +8,7 @@ from pydm.widgets.channel import PyDMChannel from pydm.widgets.timeplot import TimePlotCurveItem from pydm.widgets import PyDMTimePlot -from qtpy.QtCore import QObject, QTimer, Property, Signal, Slot +from qtpy.QtCore import QObject, QTimer, Property, Signal, Slot, Qt, QEvent from qtpy.QtGui import QColor import logging @@ -17,6 +17,7 @@ DEFAULT_ARCHIVE_BUFFER_SIZE = 18000 DEFAULT_TIME_SPAN = 5.0 +MIN_TIME_SPAN = 5.0 class ArchivePlotCurveItem(TimePlotCurveItem): @@ -77,7 +78,7 @@ def address(self, new_address: str) -> None: return # Prepare new address to use the archiver plugin and create the new channel - archive_address = "archiver://pv=" + remove_protocol(new_address) + archive_address = "archiver://pv=" + remove_protocol(new_address.strip()) self.archive_channel = PyDMChannel( address=archive_address, value_slot=self.receiveArchiveData, value_signal=self.archive_data_request_signal ) @@ -221,6 +222,35 @@ def channels(self) -> List[PyDMChannel]: """Return the list of channels this curve is connected to""" return [self.channel, self.archive_channel] + def min_archiver_x(self): + """ + Provide the the oldest valid timestamp from the archiver data buffer. + + Returns + ------- + float + The timestamp of the oldest data point in the archiver data buffer. + """ + if self.archive_points_accumulated: + return self.archive_data_buffer[0, -self.archive_points_accumulated] + else: + return self.min_x() + + def max_archiver_x(self): + """ + Provide the the most recent timestamp from the archiver data buffer. + This is useful for scaling the x-axis. + + Returns + ------- + float + The timestamp of the most recent data point in the archiver data buffer. + """ + if self.archive_points_accumulated: + return self.archive_data_buffer[0, -1] + else: + return self.min_x() + class PyDMArchiverTimePlot(PyDMTimePlot): """ @@ -252,7 +282,7 @@ def __init__( init_y_channels=init_y_channels, plot_by_timestamps=True, background=background, - bottom_axis=DateAxisItem("bottom"), + bottom_axis=PyDMDateAxisItem(), ) self.optimized_data_bins = optimized_data_bins self._min_x = None @@ -263,12 +293,12 @@ def __init__( def updateXAxis(self, update_immediately: bool = False) -> None: """Manages the requests to archiver appliance. When the user pans or zooms the x axis to the left, a request will be made for backfill data""" - if len(self._curves) == 0: + if len(self._curves) == 0 or self.auto_scroll_timer.isActive(): return min_x = self.plotItem.getAxis("bottom").range[0] # Gets the leftmost timestamp displayed on the x-axis - max_x = max([curve.max_x() for curve in self._curves]) - max_range = self.plotItem.getAxis("bottom").range[1] + max_x = self.plotItem.getAxis("bottom").range[1] + max_point = max([curve.max_x() for curve in self._curves]) if min_x == 0: # This is zero when the plot first renders min_x = time.time() self._min_x = min_x @@ -278,11 +308,11 @@ def updateXAxis(self, update_immediately: bool = False) -> None: self._min_x = self._min_x - self.getTimeSpan() self._archive_request_queued = True self.requestDataFromArchiver() - self.plotItem.setXRange(self._min_x, time.time(), padding=0.0, update=update_immediately) + self.plotItem.setXRange(time.time() - DEFAULT_TIME_SPAN, time.time(), padding=0.0, update=update_immediately) elif min_x < self._min_x and not self.plotItem.isAnyXAutoRange(): # This means the user has manually scrolled to the left, so request archived data self._min_x = min_x - self.setTimeSpan(max_x - min_x) + self.setTimeSpan(max_point - min_x) if not self._archive_request_queued: # Letting the user pan or scroll the plot is convenient, but can generate a lot of events in under # a second that would trigger a request for data. By using a timer, we avoid this burst of events @@ -291,13 +321,13 @@ def updateXAxis(self, update_immediately: bool = False) -> None: QTimer.singleShot(1000, self.requestDataFromArchiver) # Here we only update the x-axis if the user hasn't asked for autorange and they haven't zoomed in (as # detected by the max range showing on the plot being less than the data available) - elif not self.plotItem.isAnyXAutoRange() and not max_range < max_x - 10: + elif not self.plotItem.isAnyXAutoRange() and max_x >= max_point - 10: if min_x > (self._prev_x + 15) or min_x < (self._prev_x - 15): # The plus/minus 15 just makes sure we don't do this on every update tick of the graph - self.setTimeSpan(max_x - min_x) + self.setTimeSpan(max_point - min_x) else: # Keep the plot moving with a rolling window based on the current timestamp - self.plotItem.setXRange(max_x - self.getTimeSpan(), max_x, padding=0.0, update=update_immediately) + self.plotItem.setXRange(max_point - self.getTimeSpan(), max_point, padding=0.0, update=update_immediately) self._prev_x = min_x def requestDataFromArchiver(self, min_x: Optional[float] = None, max_x: Optional[float] = None) -> None: @@ -314,10 +344,11 @@ def requestDataFromArchiver(self, min_x: Optional[float] = None, max_x: Optional to the timestamp of the oldest live data point in the buffer if available. If no live points are recorded yet, then defaults to the timestamp at which the plot was first rendered. """ - processing_command = "" + req_queued = False if min_x is None: min_x = self._min_x for curve in self._curves: + processing_command = "" if curve.use_archive_data: if max_x is None: if curve.points_accumulated > 0: @@ -326,13 +357,38 @@ def requestDataFromArchiver(self, min_x: Optional[float] = None, max_x: Optional max_x = self._starting_timestamp requested_seconds = max_x - min_x if requested_seconds <= 5: - self._archive_request_queued = False continue # Avoids noisy requests when first rendering the plot # Max amount of raw data to return before using optimized data max_data_request = int(0.80 * self.getArchiveBufferSize()) if requested_seconds > max_data_request: processing_command = "optimized_" + str(self.optimized_data_bins) curve.archive_data_request_signal.emit(min_x, max_x - 1, processing_command) + req_queued |= True + + if not req_queued: + self._archive_request_queued = False + + def setAutoScroll(self, enable: bool = False, timespan: float = 60, padding: float = 0.1, refresh_rate: int = 5000): + """Enable/Disable autoscrolling along the x-axis. This will (un)pause + the autoscrolling QTimer, which calls the auto_scroll slot when time is up. + + Parameters + ---------- + enable : bool, optional + Whether or not to start the autoscroll QTimer, by default False + timespan : float, optional + The timespan to set for autoscrolling along the x-axis in seconds, by default 60 + padding : float, optional + The size of the empty space between the data and the sides of the plot, by default 0.1 + refresh_rate : int, optional + How often the scroll should occur in milliseconds, by default 5000 + """ + super().setAutoScroll(enable, timespan, padding, refresh_rate) + + self._min_x = min(self._min_x, self.getViewBox().viewRange()[0][0]) + if self._min_x != self._prev_x: + self.requestDataFromArchiver() + self._prev_x = self._min_x def getArchiveBufferSize(self) -> int: """Returns the size of the data buffer used to store archived data""" @@ -349,14 +405,17 @@ def createCurveItem(self, *args, **kwargs) -> ArchivePlotCurveItem: @Slot() def archive_data_received(self): """Take any action needed when this plot receives new data from archiver appliance""" + self._archive_request_queued = False + if self.auto_scroll_timer.isActive(): + return + max_x = max([curve.max_x() for curve in self._curves]) # Assure the user sees all data available whenever the request data is returned self.plotItem.setXRange(max_x - self.getTimeSpan(), max_x, padding=0.0, update=True) - self._archive_request_queued = False def setTimeSpan(self, value): """Set the value of the plot's timespan""" - if value < DEFAULT_TIME_SPAN: # Less than 5 seconds will break the plot + if value < MIN_TIME_SPAN: # Less than 5 seconds will break the plot return self._time_span = value @@ -411,3 +470,19 @@ def setCurves(self, new_list: List[str]) -> None: ) curves = Property("QStringList", getCurves, setCurves, designable=False) + + +class PyDMDateAxisItem(DateAxisItem): + sigMouseInteraction = Signal() + + def __init__(self, orientation='bottom', utcOffset=None, **kwargs): + super().__init__(orientation, utcOffset, **kwargs) + + def mouseDragEvent(self, event: QEvent): + if event.button() == Qt.LeftButton and event.isStart(): + self.sigMouseInteraction.emit() + return super().mouseDragEvent(event) + + def wheelEvent(self, event: QEvent): + self.sigMouseInteraction.emit() + return super().wheelEvent(event) From 38aa203fdfc77036fdf713575a9d2756d9aa9e0f Mon Sep 17 00:00:00 2001 From: zdomke Date: Tue, 23 Apr 2024 16:25:44 -0700 Subject: [PATCH 2/5] ENH: TimePlot Auto Scroll --- pydm/widgets/timeplot.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/pydm/widgets/timeplot.py b/pydm/widgets/timeplot.py index 58ed04038..ffc86d43a 100644 --- a/pydm/widgets/timeplot.py +++ b/pydm/widgets/timeplot.py @@ -456,6 +456,9 @@ def __init__( for channel in init_y_channels: self.addYChannel(channel) + self.auto_scroll_timer = QTimer() + self.auto_scroll_timer.timeout.connect(self.auto_scroll) + def initialize_for_designer(self): # If we are in Qt Designer, don't update the plot continuously. # This function gets called by PyDMTimePlot's designer plugin. @@ -618,7 +621,7 @@ def updateXAxis(self, update_immediately=False): Update the axis range(s) immediately if True, or defer until the next rendering. """ - if len(self._curves) == 0: + if len(self._curves) == 0 or self.auto_scroll_timer.isActive(): return if self._plot_by_timestamps: @@ -748,6 +751,41 @@ def refreshCurve(self, curve): yAxisName=curve.y_axis_name, ) + def setAutoScroll(self, enable: bool = False, timespan: float = 60, padding: float = 0.1, refresh_rate: int = 5000): + """Enable/Disable autoscrolling along the x-axis. This will (un)pause + the autoscrolling QTimer, which calls the auto_scroll slot when time is up. + + Parameters + ---------- + enable : bool, optional + Whether or not to start the autoscroll QTimer, by default False + timespan : float, optional + The timespan to set for autoscrolling along the x-axis in seconds, by default 60 + padding : float, optional + The size of the empty space between the data and the sides of the plot, by default 0.1 + refresh_rate : int, optional + How often the scroll should occur in milliseconds, by default 5000 + """ + if not enable: + self.auto_scroll_timer.stop() + return + + self.setAutoRangeX(False) + if timespan <= 0: + min_x, max_x = self.getViewBox().viewRange()[0] + timespan = max_x - min_x + self.scroll_timespan = timespan + self.scroll_padding = max(padding * timespan, refresh_rate / 1000) + + self.auto_scroll_timer.start(refresh_rate) + self.auto_scroll() + + def auto_scroll(self): + """Autoscrolling slot to be called by the autoscroll QTimer.""" + curr = time.time() + # Only include padding on the right + self.plotItem.setXRange(curr - self.scroll_timespan, curr + self.scroll_padding) + def addLegendItem(self, item, pv_name, force_show_legend=False): """ Add an item into the graph's legend. From d34fd86b878e8b6df1f45991959bebddfa8931b0 Mon Sep 17 00:00:00 2001 From: zdomke Date: Wed, 24 Apr 2024 14:48:34 -0700 Subject: [PATCH 3/5] STY: pre-commit check --- pydm/widgets/archiver_time_plot.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pydm/widgets/archiver_time_plot.py b/pydm/widgets/archiver_time_plot.py index 545814381..f2805e544 100644 --- a/pydm/widgets/archiver_time_plot.py +++ b/pydm/widgets/archiver_time_plot.py @@ -308,7 +308,9 @@ def updateXAxis(self, update_immediately: bool = False) -> None: self._min_x = self._min_x - self.getTimeSpan() self._archive_request_queued = True self.requestDataFromArchiver() - self.plotItem.setXRange(time.time() - DEFAULT_TIME_SPAN, time.time(), padding=0.0, update=update_immediately) + self.plotItem.setXRange( + time.time() - DEFAULT_TIME_SPAN, time.time(), padding=0.0, update=update_immediately + ) elif min_x < self._min_x and not self.plotItem.isAnyXAutoRange(): # This means the user has manually scrolled to the left, so request archived data self._min_x = min_x @@ -327,7 +329,9 @@ def updateXAxis(self, update_immediately: bool = False) -> None: self.setTimeSpan(max_point - min_x) else: # Keep the plot moving with a rolling window based on the current timestamp - self.plotItem.setXRange(max_point - self.getTimeSpan(), max_point, padding=0.0, update=update_immediately) + self.plotItem.setXRange( + max_point - self.getTimeSpan(), max_point, padding=0.0, update=update_immediately + ) self._prev_x = min_x def requestDataFromArchiver(self, min_x: Optional[float] = None, max_x: Optional[float] = None) -> None: @@ -475,7 +479,7 @@ def setCurves(self, new_list: List[str]) -> None: class PyDMDateAxisItem(DateAxisItem): sigMouseInteraction = Signal() - def __init__(self, orientation='bottom', utcOffset=None, **kwargs): + def __init__(self, orientation="bottom", utcOffset=None, **kwargs): super().__init__(orientation, utcOffset, **kwargs) def mouseDragEvent(self, event: QEvent): From 3a019ede8c0caea116340d0dd79074afe378343e Mon Sep 17 00:00:00 2001 From: zdomke Date: Wed, 24 Apr 2024 14:49:00 -0700 Subject: [PATCH 4/5] FIX: Change default timerange --- pydm/widgets/archiver_time_plot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydm/widgets/archiver_time_plot.py b/pydm/widgets/archiver_time_plot.py index f2805e544..b23def7e9 100644 --- a/pydm/widgets/archiver_time_plot.py +++ b/pydm/widgets/archiver_time_plot.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) DEFAULT_ARCHIVE_BUFFER_SIZE = 18000 -DEFAULT_TIME_SPAN = 5.0 +DEFAULT_TIME_SPAN = 3600.0 MIN_TIME_SPAN = 5.0 From 80119266942d7039395019f08d0fc29d3009b414 Mon Sep 17 00:00:00 2001 From: Zach Domke Date: Mon, 6 May 2024 11:50:09 -0700 Subject: [PATCH 5/5] DEP: Removing PyDMDateAxisItem as it's unnecessary --- pydm/widgets/archiver_time_plot.py | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/pydm/widgets/archiver_time_plot.py b/pydm/widgets/archiver_time_plot.py index b23def7e9..3a03580c8 100644 --- a/pydm/widgets/archiver_time_plot.py +++ b/pydm/widgets/archiver_time_plot.py @@ -8,7 +8,7 @@ from pydm.widgets.channel import PyDMChannel from pydm.widgets.timeplot import TimePlotCurveItem from pydm.widgets import PyDMTimePlot -from qtpy.QtCore import QObject, QTimer, Property, Signal, Slot, Qt, QEvent +from qtpy.QtCore import QObject, QTimer, Property, Signal, Slot from qtpy.QtGui import QColor import logging @@ -282,7 +282,7 @@ def __init__( init_y_channels=init_y_channels, plot_by_timestamps=True, background=background, - bottom_axis=PyDMDateAxisItem(), + bottom_axis=DateAxisItem("bottom"), ) self.optimized_data_bins = optimized_data_bins self._min_x = None @@ -474,19 +474,3 @@ def setCurves(self, new_list: List[str]) -> None: ) curves = Property("QStringList", getCurves, setCurves, designable=False) - - -class PyDMDateAxisItem(DateAxisItem): - sigMouseInteraction = Signal() - - def __init__(self, orientation="bottom", utcOffset=None, **kwargs): - super().__init__(orientation, utcOffset, **kwargs) - - def mouseDragEvent(self, event: QEvent): - if event.button() == Qt.LeftButton and event.isStart(): - self.sigMouseInteraction.emit() - return super().mouseDragEvent(event) - - def wheelEvent(self, event: QEvent): - self.sigMouseInteraction.emit() - return super().wheelEvent(event)