Skip to content

Commit

Permalink
Merge pull request #1076 from zdomke/timeplot_auto_scroll
Browse files Browse the repository at this point in the history
ENH: Timeplot auto scrolling
  • Loading branch information
jbellister-slac authored May 9, 2024
2 parents bfb76c1 + 920fbf2 commit 9315bca
Show file tree
Hide file tree
Showing 2 changed files with 116 additions and 15 deletions.
91 changes: 77 additions & 14 deletions pydm/widgets/archiver_time_plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
logger = logging.getLogger(__name__)

DEFAULT_ARCHIVE_BUFFER_SIZE = 18000
DEFAULT_TIME_SPAN = 5.0
DEFAULT_TIME_SPAN = 3600.0
MIN_TIME_SPAN = 5.0


class ArchivePlotCurveItem(TimePlotCurveItem):
Expand Down Expand Up @@ -80,7 +81,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
)
Expand Down Expand Up @@ -250,6 +251,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()

def receiveNewValue(self, new_value):
""" """
if self._liveData:
Expand Down Expand Up @@ -297,12 +327,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
Expand All @@ -312,11 +342,13 @@ 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
Expand All @@ -325,13 +357,15 @@ 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:
Expand All @@ -348,10 +382,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:
Expand All @@ -360,13 +395,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"""
Expand All @@ -383,14 +443,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

Expand Down
40 changes: 39 additions & 1 deletion pydm/widgets/timeplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,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.
Expand Down Expand Up @@ -653,7 +656,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:
Expand Down Expand Up @@ -783,6 +786,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.
Expand Down

0 comments on commit 9315bca

Please sign in to comment.