diff --git a/docs/source/data_plugins/p4p_plugin.rst b/docs/source/data_plugins/p4p_plugin.rst index f271b164c..b5f7725ab 100644 --- a/docs/source/data_plugins/p4p_plugin.rst +++ b/docs/source/data_plugins/p4p_plugin.rst @@ -81,3 +81,36 @@ Examples A small pva testing ioc is included under ``examples/testing/pva_testing_ioc.py``. This can be run in order to generate a couple of test PVs, which can be connected to using the example .ui file under ``examples/pva/pva.ui``. + + +RPC +--- + +The P4P data plugin also supports **remote method calls** (RPC) addresses. + +RPC addresses allow for calling methods on a target IOC, and receiving back the method's result. +RPC addresses must contain arguments matching the name and data-type of those defined in the target's method. +These arguments are static and set in the widget's channel address. + +RPCs can be set using a pva address in the following format:: + + pva://
?=&=&...(pydm_pollrate=) + +"pydm_pollrate" is an optional parameter, but when included must be placed after the arg name/value pairs in the address. +When "pydm_pollrate" is not used, the last arg name/value pair must still end with a "&" character. +(when not used, the RPC will be called once and not be polled) + +Arguments are also optional. When not used, end the address with the "&" character (followed by the optional "pydm_pollrate"):: + + pva://
&(pydm_pollrate=) + +Example RPC addresses: + + pva://my_address?arg1=value1& + pva://my_address?arg1=value1&arg2=value2&pydm_pollrate=10.5 + pva://KLYS:LI12:11:ATTN_CUR& + pva://KLYS:LI12:11:ATTN_CUR&pydm_pollrate=2.0 + +Additional examples of using RPCs with PyDMLabels are provided in ``examples/rpc/rpc_labels.py``. +To run examples, first make sure ``python examples/testing_ioc/rpc_testing_ioc.py`` is actively +running in another terminal. \ No newline at end of file diff --git a/examples/archiver_time_plot/archiver_time_plot_example.py b/examples/archiver_time_plot/archiver_time_plot_example.py new file mode 100644 index 000000000..66abecab1 --- /dev/null +++ b/examples/archiver_time_plot/archiver_time_plot_example.py @@ -0,0 +1,57 @@ +from pydm import Display + +from qtpy import QtCore +from qtpy.QtWidgets import QHBoxLayout, QApplication, QCheckBox +from pydm.widgets import PyDMArchiverTimePlot + + +class archiver_time_plot_example(Display): + def __init__(self, parent=None, args=None, macros=None): + super(archiver_time_plot_example, self).__init__(parent=parent, args=args, macros=None) + self.app = QApplication.instance() + self.setup_ui() + + def minimumSizeHint(self): + return QtCore.QSize(100, 100) + + def ui_filepath(self): + return None + + def setup_ui(self): + self.main_layout = QHBoxLayout() + self.setLayout(self.main_layout) + self.plot_live = PyDMArchiverTimePlot(background=[255, 255, 255, 255]) + self.plot_archived = PyDMArchiverTimePlot(background=[255, 255, 255, 255]) + self.chkbx_live = QCheckBox() + self.chkbx_live.setChecked(True) + self.chkbx_archived = QCheckBox() + self.chkbx_archived.setChecked(True) + self.main_layout.addWidget(self.chkbx_live) + self.main_layout.addWidget(self.plot_live) + self.main_layout.addWidget(self.plot_archived) + self.main_layout.addWidget(self.chkbx_archived) + + curve_live = self.plot_live.addYChannel( + y_channel="ca://XCOR:LI29:302:IACT", + name="name", + color="red", + yAxisName="Axis", + useArchiveData=True, + liveData=True, + ) + + curve_archived = self.plot_archived.addYChannel( + y_channel="ca://XCOR:LI28:302:IACT", + name="name", + color="blue", + yAxisName="Axis", + useArchiveData=True, + liveData=False, + ) + + self.chkbx_live.stateChanged.connect(lambda x: self.set_live(curve_live, x)) + self.chkbx_archived.stateChanged.connect(lambda x: self.set_live(curve_archived, x)) + + @staticmethod + def set_live(curve, live): + curve.liveData = live diff --git a/examples/rpc/rpc_labels.ui b/examples/rpc/rpc_labels.ui new file mode 100644 index 000000000..4656deac4 --- /dev/null +++ b/examples/rpc/rpc_labels.ui @@ -0,0 +1,262 @@ + + + Form + + + + 0 + 0 + 562 + 502 + + + + Form + + + + + + + 0 + 0 + + + + <html><head/><body><p>This label displays the result of a Remote Procedure Call (RPC), which gets back the result of simple function of the &quot;target object&quot; (connected channel)</p><p>The RPC channel specifies two int args and gets back the calculated result of adding them together. Arguments specified in RPC channels must have constant values.</p><p>The following 3 example PyDMLabels display the result from 3 different functions with 3 different RPC calls, showcasing use of differing number of args and arg types. (hold middle-click and hover over the result to see the RPC address used)</p><p>For this example to work properly, first have running in another terminal &quot;python examples/testing_ioc/rpc_testing_ioc.py&quot;</p></body></html> + + + true + + + + + + + + + + + (no polling) 2 + 7 = + + + + + + + + + + 0 + + + false + + + true + + + false + + + true + + + + + + pva://pv:call:add_two_ints?a=2&b=7& + + + false + + + + + + + + + + + + + + + (pollrate=0.2 sec) 2 + 7 (*-1 if negate) = + + + + + + + + + + 0 + + + false + + + true + + + false + + + true + + + + + + pva://pv:call:add_three_ints_negate_option?a=2&b=7&negate=True&pydm_pollrate=0.2 + + + false + + + + + + + + + + + + + (pollrate=1 sec) 3 + 7.8 = + + + + + + + + + + 2 + + + false + + + false + + + false + + + true + + + + + + pva://pv:call:add_int_float?a=3&b=7.8&pydm_pollrate=1.0 + + + false + + + + + + + + + + + (no polling) returns string "Hello!!" + + + + + + + + + + 0 + + + false + + + false + + + false + + + true + + + + + + pva://pv:call:take_return_string?a=Hello& + + + false + + + + + + + + + + + (pollrate=1 sec, no args) random float [0,10] = + + + + + + + + + + 2 + + + false + + + false + + + false + + + true + + + + + + pva://pv:call:no_args&pydm_pollrate=1.0 + + + false + + + + + + + + + + PyDMLabel + QLabel +
pydm.widgets.label
+
+
+ + +
diff --git a/examples/rpc/rpc_testing_client.py b/examples/rpc/rpc_testing_client.py new file mode 100644 index 000000000..eebba2ba1 --- /dev/null +++ b/examples/rpc/rpc_testing_client.py @@ -0,0 +1,20 @@ +""" +This is an example of a simple client that sends RPCs. +To demo, first run 'python examples/testing_ioc/rpc_testing_ioc.py' +from another terminal, +then run this file with 'python rpc_testing_client.py' +""" + +from p4p.client.thread import Context +from p4p.nt import NTURI + +ctx = Context("pva") + +# NTURI() lets us wrap argument into Value type needed in rpc call +# https://mdavidsaver.github.io/p4p/nt.html#p4p.nt.NTURI +AidaBPMSURI = NTURI([("a", "i"), ("b", "i")]) + +request = AidaBPMSURI.wrap("pv:call:add_two_ints", scheme="pva", kws={"a": 7, "b": 3}) +response = ctx.rpc("pv:call:add_two_ints", request, timeout=10) + +print(response) # should print something like 'Wed Dec 31 16:00:00 1969 10' diff --git a/examples/testing_ioc/rpc_testing_ioc.py b/examples/testing_ioc/rpc_testing_ioc.py new file mode 100644 index 000000000..912a12989 --- /dev/null +++ b/examples/testing_ioc/rpc_testing_ioc.py @@ -0,0 +1,38 @@ +""" +This is an example of a server that sends back RPC results, mimicking the behavior of an ioc. +The server defines three functions with differing names, number of args, and arg types. +To view demo, first run this file with 'python rpc_testing_ioc.py', +and then run 'pydm examples/rpc/rpc_lables.ui' from another terminal. +(code adapted from p4p docs: https://mdavidsaver.github.io/p4p/rpc.html) +""" +from p4p.rpc import rpc, quickRPCServer +from p4p.nt import NTScalar +import random + + +class Demo(object): + @rpc(NTScalar("i")) + def add_two_ints(self, a, b): + return a + b + + @rpc(NTScalar("f")) + def add_int_float(self, a, b): + return a + b + + @rpc(NTScalar("i")) + def add_three_ints_negate_option(self, a, b, negate): + res = a + b + return -1 * res if negate else res + + @rpc(NTScalar("s")) + def take_return_string(self, a): + return a + "!!" + + @rpc(NTScalar("f")) + def no_args(self): + randomFloat = random.uniform(0, 10) + return randomFloat + + +adder = Demo() +quickRPCServer(provider="Example", prefix="pv:call:", target=adder) diff --git a/pydm/data_plugins/epics_plugins/p4p_plugin_component.py b/pydm/data_plugins/epics_plugins/p4p_plugin_component.py index 2407a2a5d..43bb9138b 100644 --- a/pydm/data_plugins/epics_plugins/p4p_plugin_component.py +++ b/pydm/data_plugins/epics_plugins/p4p_plugin_component.py @@ -1,18 +1,26 @@ import logging import numpy as np import collections +import threading +import p4p +import re +import time from p4p.client.thread import Context, Disconnected from p4p.wrapper import Value +from p4p.nt import NTURI from .pva_codec import decompress from pydm.data_plugins import is_read_only from pydm.data_plugins.plugin import PyDMPlugin, PyDMConnection from pydm.widgets.channel import PyDMChannel from qtpy.QtCore import QObject, Qt from typing import Optional -import p4p +from urllib.parse import urlparse, parse_qs logger = logging.getLogger(__name__) +# arbitrary default for non-polled RPC +DEFAULT_RPC_TIMEOUT = 5.0 + class Connection(PyDMConnection): def __init__( @@ -34,8 +42,6 @@ def __init__( super().__init__(channel, address, protocol, parent) self._connected = False self.nttable_data_location = PyDMPlugin.get_subfield(channel) - self.monitor = P4PPlugin.context.monitor(name=self.address, cb=self.send_new_value, notify_disconnect=True) - self.add_listener(channel) self._value = None self._severity = None self._precision = None @@ -49,6 +55,159 @@ def __init__( self._lower_warning_limit = None self._timestamp = None + # RPC = Remote Procedure Call (https://mdavidsaver.github.io/p4p/rpc.html#p4p.rpc.rpcproxy) + # example address: pva://pv:call:add?lhs=4&rhs=7&pydm_pollrate=10 + self._rpc_function_name = "" # pv:call:add (in case of above example) + self._rpc_arg_names = [] # ['lhs', 'rhs'] (in case of above example) + self._rpc_arg_values = [] # ['4', '7'] (in case of above example) + self._value_obj = None + # Poll rate in seconds + self._rpc_poll_rate = 0 # (in case of above example) + self._background_polling_thread = None + + self.monitor = None + self.is_rpc = self.is_rpc_address(channel.address) + if self.is_rpc: + # channel.address provides the entire user-entered channel (instead of 'channel' var) + self.parse_rpc_channel(channel.address) + + # RPC requests are handled simply and don't require continuous monitoring, + # instead they use the p4p 'rpc' call at a specified a pollrate. + self.add_listener(channel) + if not self.is_rpc: + self.monitor = P4PPlugin.context.monitor(name=self.address, cb=self.send_new_value, notify_disconnect=True) + + def emit_for_type(self, value) -> None: + # Emit for the types currently supported as RPC request args + if isinstance(value, int): + self.new_value_signal[int].emit(value) + elif isinstance(value, float): + self.new_value_signal[float].emit(value) + elif isinstance(value, bool): + self.new_value_signal[bool].emit(value) + elif isinstance(value, str): + self.new_value_signal[str].emit(value) + + def poll_rpc_channel(self) -> None: + # Keep executing this function at the polling rate + + # When polling-rate is not specified by user (is 0), just do a single RPC request + only_poll_once = False + if self._rpc_poll_rate == 0: + self._rpc_poll_rate = DEFAULT_RPC_TIMEOUT + only_poll_once = True + + while True: + start_time = time.process_time() + + result = None + try: + result = P4PPlugin.context.rpc( + name=self._rpc_function_name, value=self._value_obj, timeout=self._rpc_poll_rate + ) + except Exception: + # So widget displays name of channel when can't connect to RPC channel + self.connection_state_signal.emit(False) + + if result: + self.connection_state_signal.emit(True) + self.emit_for_type(result.value) + else: + self.connection_state_signal.emit(False) + + if only_poll_once: + break + + rpc_call_time = time.process_time() - start_time + # We want to call "rpc" every self._rpc_poll_rate seconds, + # so wait when the call returns faster than the polling-rate. + # The timeout arg makes sure a single call is never slower then the polling-rate. + poll_rate_and_rpc_call_time_dif = self._rpc_poll_rate - rpc_call_time + if poll_rate_and_rpc_call_time_dif > 0: + time.sleep(poll_rate_and_rpc_call_time_dif) + + def get_arg_datatype(self, arg_value_string): + # Try to figure out the datatype of RPC request args + try: + int(arg_value_string) + return "i", int(arg_value_string) + except Exception: + pass + try: + float(arg_value_string) + return "f", float(arg_value_string) + except Exception: + pass + if arg_value_string.lower() == "True" or arg_value_string.lower() == "False": + return "?", bool(arg_value_string) + # Assume arg is just a string if no other type works + return "s", arg_value_string + + def create_request(self, rpc_function_name, rpc_arg_names, rpc_arg_values) -> Value: + # example addr: pv:call:add_two_ints?a=2&b=7& + arg_datatypes = [] + for i in range(len(rpc_arg_names)): + data_type, _ = self.get_arg_datatype(rpc_arg_values[i]) + if data_type is None: + return None + arg_datatypes.append((rpc_arg_names[i], data_type)) + # example arg_datatypes: [('a', 'i'), ('b', 'i')] + arg_val_mapping = {key: value for (key, _), value in zip(arg_datatypes, rpc_arg_values)} + # example arg_val_mapping: {'a': '2', 'b': '7'} + + # https://mdavidsaver.github.io/p4p/nt.html#p4p.nt.NTURI + nturi_obj = NTURI(arg_datatypes) + + request = nturi_obj.wrap(rpc_function_name, scheme="pva", kws=arg_val_mapping) + return request + + def parse_rpc_channel(self, input_string) -> None: + # url parsing is close to what we need, so use with some adjusting + parsed_url = urlparse(input_string) + raw_args = parsed_url.query + parsed_args = parse_qs(raw_args) + function_name = parsed_url.netloc + + # if RPC has no args and no polling specified, url parsing will leave name with ending '&' char we need remove + + if len(function_name) >= 1 and function_name[-1] == "&": + function_name = function_name[:-1] + + # now handle case when no args givin but specified polling + pollrate = 0.0 + if "pydm_pollrate" in function_name: + index = function_name.find("&pydm_pollrate=") + if index != -1: + value_str = function_name[index + len("&pydm_pollrate=") :] + pollrate = value_str + + function_name = function_name.split("&")[0] + else: + # because url-parsing function put value-string in 1 item list + pollrate = parsed_args.get("pydm_pollrate", "0.0")[0] + if "pydm_pollrate" in parsed_args: + # delete because we don't pass pollrate as argument to RPC + del parsed_args["pydm_pollrate"] + + for curr_arg_name, curr_arg_value in parsed_args.items(): + parsed_args[curr_arg_name] = curr_arg_value[0] # [0] takes value out of 1 item list + + self._rpc_function_name = function_name + self._rpc_arg_names = list(parsed_args.keys()) + self._rpc_arg_values = list(parsed_args.values()) + self._rpc_poll_rate = float(pollrate) + + def is_rpc_address(self, full_channel_name): + """ + Keep this simple for now, say it's an RPC just if either ends with '&' or '&pydm_pollrate=. + This should be enough to differentiate between non-rpc requests, + bad RPCs will just fail and log error when we try to connect latter. + """ + if full_channel_name is None: + return False + pattern = re.compile(r"(&|\&pydm_pollrate=\d+(\.\d+)?)$") + return bool(pattern.search(full_channel_name)) + def clear_cache(self) -> None: """Clear out all the stored values of this connection.""" self._value = None @@ -245,11 +404,12 @@ def put_value(self, value): nttable = nttable["value"] Connection.set_value_by_keys(nttable, self.nttable_data_location, value) value = {"value": nttable} - if is_read_only(): logger.warning(f"PyDM read-only mode is enabled, could not write value: {value} to {self.address}") return + if self.is_rpc: + return try: P4PPlugin.context.put(self.monitor.name, value) except Exception as e: @@ -264,6 +424,22 @@ def add_listener(self, channel: PyDMChannel): The channel that will be listening to any changes from this connection """ super().add_listener(channel) + + if self.is_rpc: + # In case of a RPC, we can just query the channel immediately and emit the value, + # and let the pollrate dictate if/when we query and emit again.f + self._value_obj = self.create_request(self._rpc_function_name, self._rpc_arg_names, self._rpc_arg_values) + if self._value_obj is None: + logger.warning(f"failed to create request object for RPC to {self._rpc_function_name}") + return + + # Use daemon threads so they will be stopped when all the non-daemon + # threads (in our case just the main thread) are killed, preventing them from running forever. + self._background_polling_thread = threading.Thread(target=self.poll_rpc_channel, daemon=True) + self._background_polling_thread.start() + + return + if self.monitor is not None and self._connected: # Adding a listener to an already connected PV. Manually send the signals indicating the PV is # connected, and what the last known values were. @@ -298,7 +474,9 @@ def add_listener(self, channel: PyDMChannel): def close(self): """Closes out this connection.""" - self.monitor.close() + # If RPC, we have no monitor to close + if self.monitor: + self.monitor.close() super().close() diff --git a/pydm/tests/data_plugins/test_p4p_plugin_component.py b/pydm/tests/data_plugins/test_p4p_plugin_component.py index c3e98384e..972407d68 100644 --- a/pydm/tests/data_plugins/test_p4p_plugin_component.py +++ b/pydm/tests/data_plugins/test_p4p_plugin_component.py @@ -6,8 +6,7 @@ from pydm.tests.conftest import ConnectionSignals from pydm.widgets.channel import PyDMChannel from pytest import MonkeyPatch -from p4p.wrapper import Value -from p4p import Type +from p4p.wrapper import Type, Value class MockContext: @@ -155,3 +154,149 @@ def test_convert_epics_nttable(): result = Connection.convert_epics_nttable(epics_struct) assert result == solution + + +@pytest.mark.parametrize( + "address, expected_function_name, expected_arg_names, expected_arg_values, expected_poll_rate", + [ + ("pva://pv:call:add?a=4&b=7&pydm_pollrate=10", "pv:call:add", ["a", "b"], ["4", "7"], 10), + ("pva://pv:call:add?a=4&b=7&", "pv:call:add", ["a", "b"], ["4", "7"], 5.0), + ( + "pva://pv:call:add_three_ints_negate_option?a=2&b=7&negate=True&pydm_pollrate=10", + "pv:call:add_three_ints_negate_option", + ["a", "b", "negate"], + ["2", "7", "True"], + 10, + ), + ( + "pva://pv:call:add_ints_floats?a=3&b=4&c=5&d=6&e=7.8&pydm_pollrate=1", + "pv:call:add_ints_floats", + ["a", "b", "c", "d", "e"], + ["3", "4", "5", "6", "7.8"], + 1, + ), + ( + "pva://pv:call:take_return_string?a=Hello&", + "pv:call:take_return_string", + ["a"], + ["Hello"], + 5.0, + ), + ("", "", [], [], 0.0), # poll-rate 0 because only set to DEFAULT_RPC_TIMEOUT (5) when have realistic address + ], +) +def test_parsing_rpc_channel( + monkeypatch, address, expected_function_name, expected_arg_names, expected_arg_values, expected_poll_rate +): + """ + Ensure we can tell when a pva channel is an RPC call or not, + and when it is make sure we are extracting its data correctly. + """ + mock_channel = PyDMChannel(address=address) + monkeypatch.setattr(P4PPlugin, "context", MockContext()) + monkeypatch.setattr(P4PPlugin.context, "monitor", lambda **args: None) # Don't want to actually setup a monitor + p4p_connection = Connection(mock_channel, address) + + assert p4p_connection._rpc_function_name == expected_function_name + assert p4p_connection._rpc_arg_names == expected_arg_names + assert p4p_connection._rpc_arg_values == expected_arg_values + assert p4p_connection._rpc_poll_rate == expected_poll_rate + + +@pytest.mark.parametrize( + "address, is_valid_rpc", + [ + # Valid RPC + ("pva://pv:call:add?a=4&b=7&pydm_pollrate=10", True), + ("pva://pv:call:add?a=4&b=7&pydm_pollrate=5.5", True), + # When pollrate is not specified, the last argument must also end with '&' character + ("pva://pv:call:add?a=4&b=7&", True), + # Valid pva addresses but not RPC + ("pva://PyDM:PVA:IntValue", False), + # Totally invalid + ("this is not valid!! pva://&&==!!", False), + ("pva://", False), + ("", False), + (None, False), + ], +) +def test_is_rpc_check( + monkeypatch, + address, + is_valid_rpc, +): + """Ensure that the regex is working for checking if a pva address is a RPC request or not""" + mock_channel = PyDMChannel(address=address) + monkeypatch.setattr(P4PPlugin, "context", MockContext()) + monkeypatch.setattr(P4PPlugin.context, "monitor", lambda **args: None) # Don't want to actually setup a monitor + p4p_connection = Connection(mock_channel, address) + + assert p4p_connection.is_rpc_address(address) == is_valid_rpc + + +@pytest.mark.parametrize( + "address, expected_query", + [ + ( + "pva://pv:call:add?a=4&b=7&pydm_pollrate=10", + { + "a": 4, + "b": 7, + }, + ), + # Check that not specifying pollrate doesn't effect Value obj creation + ( + "pva://pv:call:add?a=4&b=7&", + { + "a": 4, + "b": 7, + }, + ), + # Make sure args of mixed datatypes work correctly + ( + "pva://pv:call:add?a=4&b=7.5&pydm_pollrate=10", + { + "a": 4, + "b": 7.5, + }, + ), + # Try with more args + ( + "pva://pv:call:add?a=1&b=2&c=3&d=4&e=5&pydm_pollrate=10", + { + "a": 1, + "b": 2, + "c": 3, + "d": 4, + "e": 5, + }, + ), + # Check using no args + ( + "pva://pv:call:no_args&", + {}, + ), + ], +) +def test_create_rpc_value_obj( + monkeypatch, + address, + expected_query, +): + """ + To make a successful RPC call, we first need to create ("wrap") a request object to pass in. + """ + mock_channel = PyDMChannel(address=address) + monkeypatch.setattr(P4PPlugin, "context", MockContext()) + monkeypatch.setattr(P4PPlugin.context, "monitor", lambda **args: None) # Don't want to actually setup a monitor + p4p_connection = Connection(mock_channel, address) + request = p4p_connection.create_request( + p4p_connection._rpc_function_name, p4p_connection._rpc_arg_names, p4p_connection._rpc_arg_values + ) + + result_query = request.query + + assert len(result_query.items()) == len(expected_query.items()) + + for item1, item2 in zip(result_query.items(), expected_query.items()): + assert item1 == item2 diff --git a/pydm/tests/widgets/test_drawing.py b/pydm/tests/widgets/test_drawing.py index 2b126414a..f1b2de8c5 100644 --- a/pydm/tests/widgets/test_drawing.py +++ b/pydm/tests/widgets/test_drawing.py @@ -1230,21 +1230,42 @@ def test_pydmdrawingpolyline_arrows(qapp, qtbot, points, num_points): num_points :int The actual number of vertices of the polygon. """ - drawing = PyDMDrawingPolyline() - qtbot.addWidget(drawing) + # try with polyline + polyLine = PyDMDrawingPolyline() + qtbot.addWidget(polyLine) # make sure points seutp correctly before drawing arrows - drawing.setPoints(points) - assert len(drawing.getPoints()) == num_points + polyLine.setPoints(points) + assert len(polyLine.getPoints()) == num_points # enable all arrow options - drawing._arrow_end_point_selection = True - drawing._arrow_start_point_selection = True - drawing._arrow_mid_point_selection = True - drawing._arrow_mid_point_flipped = True - drawing.draw_item(drawing._painter) + polyLine._arrow_end_point_selection = True + polyLine._arrow_start_point_selection = True + polyLine._arrow_mid_point_selection = True + polyLine._arrow_mid_point_flipped = True + polyLine.draw_item(polyLine._painter) + polyLine.show() + + # just be sure size prop exists and can be changed without breaking things + polyLine._arrow_size += 3 + polyLine.draw_item(polyLine._painter) + polyLine.show() + + # now try with line + line = PyDMDrawingLine() + qtbot.addWidget(line) - drawing.show() + # enable all arrow options + line._arrow_end_point_selection = True + line._arrow_start_point_selection = True + line._arrow_mid_point_selection = True + line._arrow_mid_point_flipped = True + line.draw_item(polyLine._painter) + line.show() + + line._arrow_size += 3 + line.draw_item(polyLine._painter) + line.show() # # --------------------------- diff --git a/pydm/tests/widgets/test_slider.py b/pydm/tests/widgets/test_slider.py index bc8a1e5d8..ba12cbb77 100644 --- a/pydm/tests/widgets/test_slider.py +++ b/pydm/tests/widgets/test_slider.py @@ -159,46 +159,6 @@ def test_internal_slider_value_changed(qtbot, signals, new_value, mute_change): assert signals.value is None -''' -@pytest.mark.parametrize( - "value, step_size, precision, precision_from_pv", [("0.5", "1", "5", False), ("1", "0.1", "3", False)] -) -def test_parameters_menu(qtbot, value, step_size, precision, precision_from_pv): - """ - Tests the slider widgets parameters menu - - Expectations: - The values passed from the menu will update the corresponding values of the widget. - - Parameters - ---------- - qtbot : fixture - pytest-qt window for widget test - """ - pydm_slider = PyDMSlider() - qtbot.addWidget(pydm_slider) - pydm_slider.userDefinedLimits = True - pydm_slider.userMaximum = 10 - pydm_slider.userMinimum = -10 - # pydm_slider.value = 0 - pydm_slider.slider_parameters_menu(QPoint(0, 0)) - - # value - pydm_slider.slider_parameters_menu_input_widgets[0].setText(value) - # step size - pydm_slider.slider_parameters_menu_input_widgets[1].setText(step_size) - # precision - pydm_slider.slider_parameters_menu_input_widgets[3].setText(precision) - # boolean precision from PV - pydm_slider.slider_parameters_menu_input_widgets[4].setChecked(precision_from_pv) - # apply changes - pydm_slider.apply_step_size_menu_changes() - assert pydm_slider.value == float(value) - assert pydm_slider.step_size == float(step_size) - assert pydm_slider.precision == float(precision) -''' - - @pytest.mark.parametrize( "show_labels, tick_position", [ @@ -467,59 +427,6 @@ def test_set_slider_to_closest_value(qtbot, new_value, minimum, maximum): assert pydm_slider._slider.value() == expected_slider_value -''' -@pytest.mark.parametrize( - "new_channel_value, is_slider_down", - [ - (15, False), - (15, True), - ], -) -def test_value_changed(qtbot, signals, monkeypatch, new_channel_value, is_slider_down): - """ - Test the updating of the widget's slider component value when the channel value has changed. - - Expectations: - The widget's text component will display the correct new value, and the widget's slider component will reflect - the right movement as calculated. - - Parameters - ---------- - qtbot : fixture - Window for widget testing - signals : fixture - The signals fixture, which provides access signals to be bound to the appropriate slots - monkeypatch : fixture - To override the default behavior of isSliderDown while simulating whether the widget's slider is being held down - by the user or not - new_channel_value : int - The new value coming from the channel - is_slider_down : bool - True if the slider is to be simulated as being held down by the user; False otherwise. - """ - pydm_slider = PyDMSlider() - qtbot.addWidget(pydm_slider) - - pydm_slider.userDefinedLimits = True - pydm_slider.userMinimum = 10 - pydm_slider.userMaximum = 100 - - pydm_slider._slider.setValue(0) - assert pydm_slider._slider.value() == 0 - - monkeypatch.setattr(QSlider, "isSliderDown", lambda *args: is_slider_down) - signals.new_value_signal.connect(pydm_slider.channelValueChanged) - signals.new_value_signal.emit(new_channel_value) - - assert pydm_slider.value_label.text() == pydm_slider.format_string.format(pydm_slider.value) - if not is_slider_down: - expected_slider_value = np.argmin(abs(pydm_slider._slider_position_to_value_map - float(new_channel_value))) - assert pydm_slider._slider.value() == expected_slider_value - else: - assert pydm_slider._slider.value() == 0 -''' - - @pytest.mark.parametrize( "channel, alarm_sensitive_content, alarm_sensitive_border, new_alarm_severity", [ diff --git a/pydm/widgets/archiver_time_plot.py b/pydm/widgets/archiver_time_plot.py index 242156244..4518a24c7 100644 --- a/pydm/widgets/archiver_time_plot.py +++ b/pydm/widgets/archiver_time_plot.py @@ -4,6 +4,7 @@ from collections import OrderedDict from typing import List, Optional from pyqtgraph import DateAxisItem, ErrorBarItem +from pydm.utilities import remove_protocol from pydm.widgets.channel import PyDMChannel from pydm.widgets.timeplot import TimePlotCurveItem from pydm.widgets import PyDMTimePlot @@ -15,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): @@ -39,46 +41,75 @@ class ArchivePlotCurveItem(TimePlotCurveItem): archive_data_request_signal = Signal(float, float, str) archive_data_received_signal = Signal() - def __init__(self, channel_address: Optional[str] = None, use_archive_data: bool = True, **kws): - super(ArchivePlotCurveItem, self).__init__(channel_address, **kws) + def __init__( + self, channel_address: Optional[str] = None, use_archive_data: bool = True, liveData: bool = True, **kws + ): + super(ArchivePlotCurveItem, self).__init__(**kws) self.use_archive_data = use_archive_data self.archive_channel = None self.archive_points_accumulated = 0 self._archiveBufferSize = DEFAULT_ARCHIVE_BUFFER_SIZE self.archive_data_buffer = np.zeros((2, self._archiveBufferSize), order="f", dtype=float) + self._liveData = liveData # When optimized or mean value data is requested, we can display error bars representing # the full range of values retrieved self.error_bar_item = ErrorBarItem() self.error_bar_needs_set = True - if channel_address is not None and use_archive_data: - self.setArchiveChannel(channel_address) + self.address = channel_address def to_dict(self) -> OrderedDict: """Returns an OrderedDict representation with values for all properties needed to recreate this curve.""" - dic_ = OrderedDict( - [ - ("useArchiveData", self.use_archive_data), - ] - ) + dic_ = OrderedDict([("useArchiveData", self.use_archive_data), ("liveData", self.liveData)]) dic_.update(super(ArchivePlotCurveItem, self).to_dict()) return dic_ - def setArchiveChannel(self, address: str) -> None: + @property + def address(self): + return super().address + + @address.setter + def address(self, new_address: str) -> None: """Creates the channel for the input address for communicating with the archiver appliance plugin.""" - archiver_prefix = "archiver://pv=" - if address.startswith("ca://"): - archive_address = address.replace("ca://", archiver_prefix, 1) - elif address.startswith("pva://"): - archive_address = address.replace("pva://", archiver_prefix, 1) - else: - archive_address = archiver_prefix + address + TimePlotCurveItem.address.__set__(self, new_address) + if not new_address: + self.archive_channel = None + return + elif self.archive_channel and new_address == self.archive_channel.address: + return + + # Prepare new address to use the archiver plugin and create the new channel + 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 ) + # Clear the archive data of the previous channel and redraw the curve + if self.archive_points_accumulated: + self.initializeArchiveBuffer() + self.redrawCurve() + + @property + def liveData(self): + return self._liveData + + @liveData.setter + def liveData(self, get_live: bool): + if not get_live: + self._liveData = False + return + + min_x = self.data_buffer[0, self._bufferSize - 1] + max_x = time.time() + + # Avoids noisy requests when first rendering the plot + if max_x - min_x > 5: + self.archive_data_request_signal.emit(min_x, max_x - 1, "") + + self._liveData = True + @Slot(np.ndarray) def receiveArchiveData(self, data: np.ndarray) -> None: """Receive data from archiver appliance and place it into the archive data buffer. @@ -95,6 +126,13 @@ def receiveArchiveData(self, data: np.ndarray) -> None: archive_data_length = len(data[0]) max_x = data[0][archive_data_length - 1] + # Filling live buffer if data is more recent than Archive Data Buffer + last_ts = self.archive_data_buffer[0][-1] + if self.archive_data_buffer.any() and (int(last_ts) <= data[0][0]): + self.insert_live_data(data) + self.data_changed.emit() + return + if self.points_accumulated != 0: while max_x > self.data_buffer[0][-self.points_accumulated]: # Sometimes optimized queries return data past the current timestamp, this will delete those data points @@ -213,6 +251,40 @@ 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: + super().receiveNewValue(new_value) + class PyDMArchiverTimePlot(PyDMTimePlot): """ @@ -255,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 @@ -270,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 @@ -283,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: @@ -306,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: @@ -318,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""" @@ -341,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 @@ -400,6 +505,46 @@ def setCurves(self, new_list: List[str]) -> None: symbolSize=d.get("symbolSize"), yAxisName=d.get("yAxisName"), useArchiveData=d.get("useArchiveData"), + liveData=d.get("liveData"), ) curves = Property("QStringList", getCurves, setCurves, designable=False) + + def addYChannel( + self, + y_channel=None, + plot_style=None, + name=None, + color=None, + lineStyle=None, + lineWidth=None, + symbol=None, + symbolSize=None, + barWidth=None, + upperThreshold=None, + lowerThreshold=None, + thresholdColor=None, + yAxisName=None, + useArchiveData=False, + liveData=True, + ) -> ArchivePlotCurveItem: + """ + Overrides timeplot addYChannel method to be able to pass the liveData flag. + """ + return super().addYChannel( + y_channel=y_channel, + plot_style=plot_style, + name=name, + color=color, + lineStyle=lineStyle, + lineWidth=lineWidth, + symbol=symbol, + symbolSize=symbolSize, + barWidth=barWidth, + upperThreshold=upperThreshold, + lowerThreshold=lowerThreshold, + thresholdColor=thresholdColor, + yAxisName=yAxisName, + useArchiveData=useArchiveData, + liveData=liveData, + ) diff --git a/pydm/widgets/archiver_time_plot_editor.py b/pydm/widgets/archiver_time_plot_editor.py index 181a8abd4..22101179f 100644 --- a/pydm/widgets/archiver_time_plot_editor.py +++ b/pydm/widgets/archiver_time_plot_editor.py @@ -1,5 +1,5 @@ from typing import Any, Optional -from qtpy.QtCore import QModelIndex, QObject, QVariant +from qtpy.QtCore import Qt, QModelIndex, QObject, QVariant from qtpy.QtGui import QColor from .archiver_time_plot import ArchivePlotCurveItem from .baseplot import BasePlot @@ -12,7 +12,25 @@ class PyDMArchiverTimePlotCurvesModel(BasePlotCurvesModel): def __init__(self, plot: BasePlot, parent: Optional[QObject] = None): super().__init__(plot, parent=parent) - self._column_names = ("Channel", "Archive Data") + self._column_names + self._column_names = ("Channel", "Live Data", "Archive Data") + self._column_names + + self.checkable_cols = {self.getColumnIndex("Live Data"), self.getColumnIndex("Archive Data")} + + def flags(self, index): + flags = super().flags(index) + if index.column() in self.checkable_cols: + flags = Qt.ItemIsEnabled | Qt.ItemIsUserCheckable | Qt.ItemIsSelectable + return flags + + def data(self, index, role=Qt.DisplayRole): + if not index.isValid(): + return QVariant() + if role == Qt.CheckStateRole and index.column() in self.checkable_cols: + value = super().data(index, Qt.DisplayRole) + return Qt.Checked if value else Qt.Unchecked + elif index.column() not in self.checkable_cols: + return super().data(index, role) + return None def get_data(self, column_name: str, curve: ArchivePlotCurveItem) -> Any: """Get data for the input column name""" @@ -20,14 +38,27 @@ def get_data(self, column_name: str, curve: ArchivePlotCurveItem) -> Any: if curve.address is None: return QVariant() return str(curve.address) + elif column_name == "Live Data": + return bool(curve.liveData) elif column_name == "Archive Data": return bool(curve.use_archive_data) return super().get_data(column_name, curve) + def setData(self, index, value, role=Qt.DisplayRole): + if not index.isValid(): + return QVariant() + elif role == Qt.CheckStateRole and index.column() in self.checkable_cols: + return super().setData(index, value, Qt.EditRole) + elif index.column() not in self.checkable_cols: + return super().setData(index, value, role) + return None + def set_data(self, column_name: str, curve: ArchivePlotCurveItem, value: Any) -> bool: """Set data on the input curve for the given name and value. Return true if successful.""" if column_name == "Channel": curve.address = str(value) + elif column_name == "Live Data": + curve.liveData = bool(value) elif column_name == "Archive Data": curve.use_archive_data = bool(value) else: diff --git a/pydm/widgets/baseplot.py b/pydm/widgets/baseplot.py index 0ed26b2e4..849dcab4d 100644 --- a/pydm/widgets/baseplot.py +++ b/pydm/widgets/baseplot.py @@ -5,7 +5,15 @@ from qtpy.QtCore import Signal, Slot, Property, QTimer, Qt, QEvent, QObject, QRect from qtpy.QtWidgets import QToolTip, QWidget from .. import utilities -from pyqtgraph import AxisItem, PlotWidget, PlotDataItem, mkPen, ViewBox, InfiniteLine, SignalProxy +from pyqtgraph import ( + AxisItem, + PlotWidget, + PlotDataItem, + mkPen, + ViewBox, + InfiniteLine, + SignalProxy, +) from collections import OrderedDict from typing import Dict, List, Optional, Union from .base import PyDMPrimitiveWidget, widget_destroyed @@ -34,7 +42,7 @@ class BasePlotCurveItem(PlotDataItem): ---------- color : QColor, optional The color used to draw the curve line and the symbols. - lineStyle: int, optional + lineStyle: Qt.PenStyle, optional Style of the line connecting the data points. Must be a value from the Qt::PenStyle enum (see http://doc.qt.io/qt-5/qt.html#PenStyle-enum). @@ -81,7 +89,7 @@ def __init__( lineStyle: Optional[Qt.PenStyle] = None, lineWidth: Optional[int] = None, yAxisName: Optional[str] = None, - **kws + **kws, ) -> None: self._color = QColor("white") self._thresholdColor = QColor("white") @@ -193,7 +201,7 @@ def threshold_color(self) -> QColor: return self._thresholdColor @threshold_color.setter - def threshold_color(self, new_color: QColor): + def threshold_color(self, new_color: Union[QColor, str]): """ Set the color used for bars exceeding either the upper or lower thresholds. @@ -201,6 +209,8 @@ def threshold_color(self, new_color: QColor): ------- new_color: QColor """ + if isinstance(new_color, str): + new_color = QColor(new_color) self._thresholdColor = new_color @property @@ -407,6 +417,9 @@ class BasePlotAxisItem(AxisItem): Extra arguments for CSS style options for this axis """ + log_mode_updated = Signal(str, bool) + sigXRangeChanged = Signal(object, object) + sigYRangeChanged = Signal(object, object) axis_orientations = OrderedDict([("Left", "left"), ("Right", "right")]) def __init__( @@ -418,17 +431,24 @@ def __init__( maxRange: Optional[float] = 1.0, autoRange: Optional[bool] = True, logMode: Optional[bool] = False, - **kws + **kws, ) -> None: super(BasePlotAxisItem, self).__init__(orientation, **kws) self._name = name self._orientation = orientation self._label = label - self._min_range = minRange - self._max_range = maxRange self._auto_range = autoRange self._log_mode = logMode + self.setRange(minRange, maxRange) + + def linkToView(self, view): + if oldView := self.linkedView(): + oldView.sigXRangeChanged.disconnect(self.sigXRangeChanged.emit) + oldView.sigYRangeChanged.disconnect(self.sigYRangeChanged.emit) + view.sigXRangeChanged.connect(self.sigXRangeChanged.emit) + view.sigYRangeChanged.connect(self.sigYRangeChanged.emit) + super().linkToView(view) @property def name(self) -> str: @@ -494,7 +514,7 @@ def min_range(self) -> float: ------- float """ - return self._min_range + return self.range[0] @min_range.setter def min_range(self, min_range: float) -> None: @@ -505,7 +525,7 @@ def min_range(self, min_range: float) -> None: ---------- min_range: float """ - self._min_range = min_range + self.linkedView().setYRange(min_range, self.range[1], padding=0) @property def max_range(self) -> float: @@ -516,7 +536,7 @@ def max_range(self) -> float: ------- float """ - return self._max_range + return self.range[1] @max_range.setter def max_range(self, max_range: float) -> None: @@ -527,7 +547,7 @@ def max_range(self, max_range: float) -> None: ---------- max_range: float """ - self._max_range = max_range + self.linkedView().setYRange(self.range[0], max_range, padding=0) @property def auto_range(self) -> bool: @@ -572,6 +592,8 @@ def log_mode(self, log_mode: bool) -> None: log_mode: bool """ self._log_mode = log_mode + self.setLogMode(x=False, y=log_mode) + self.log_mode_updated.emit(self.name, log_mode) def to_dict(self) -> OrderedDict: """ @@ -587,8 +609,8 @@ def to_dict(self) -> OrderedDict: ("name", self._name), ("orientation", self._orientation), ("label", self._label), - ("minRange", self._min_range), - ("maxRange", self._max_range), + ("minRange", self.range[0]), + ("maxRange", self.range[1]), ("autoRange", self._auto_range), ("logMode", self._log_mode), ] @@ -650,7 +672,7 @@ def __init__( self._redraw_rate = 1 # Redraw at 1 Hz by default. self.maxRedrawRate = self._redraw_rate self._axes = [] - self._curves = [] + self._curves: List[BasePlotCurveItem] = [] self._x_labels = [] self._y_labels = [] self._title = None @@ -694,7 +716,10 @@ def eventFilter(self, obj: QObject, event: QEvent) -> bool: return ret def addCurve( - self, plot_data_item: BasePlotCurveItem, curve_color: Optional[QColor] = None, y_axis_name: Optional[str] = None + self, + plot_data_item: BasePlotCurveItem, + curve_color: Optional[QColor] = None, + y_axis_name: Optional[str] = None, ): """ Adds a curve to this plot. @@ -799,6 +824,7 @@ def addAxis( if plot_data_item is not None: plot_data_item.setLogMode(False, log_mode) axis.setLogMode(log_mode) + axis.log_mode_updated.connect(self.setAxisLogMode) self._axes.append(axis) # If the x axis is just timestamps, we don't want autorange on the x axis setXLink = hasattr(self, "_plot_by_timestamps") and self._plot_by_timestamps @@ -879,6 +905,13 @@ def clearAxes(self) -> None: def redrawPlot(self) -> None: pass + @Slot(str, bool) + def setAxisLogMode(self, axis_name: str, log_mode: bool) -> None: + axis_curves = [c for c in self._curves if c.y_axis_name == axis_name] + for curve in axis_curves: + curve.setLogMode(False, log_mode) + self.plotItem.recomputeAverages() + def getShowXGrid(self) -> bool: """True if showing x grid lines on the plot, False otherwise""" return self._show_x_grid @@ -955,6 +988,10 @@ def getYAxes(self) -> List[str]: """ return [json.dumps(axis.to_dict()) for axis in self._axes] + def getXAxis(self) -> BasePlotAxisItem: + """Return the plot's X-Axis item.""" + return self.getAxis("bottom") + def setYAxes(self, new_list: List[str]) -> None: """ Add a list of axes into the graph. @@ -1340,7 +1377,7 @@ def maxRedrawRate(self, redraw_rate: int) -> None: self.redraw_timer.setInterval(int((1.0 / self._redraw_rate) * 1000)) def pausePlotting(self) -> bool: - self.redraw_timer.stop() if self.redraw_timer.isActive() else self.redraw_timer.start() + (self.redraw_timer.stop() if self.redraw_timer.isActive() else self.redraw_timer.start()) return self.redraw_timer.isActive() def mouseMoved(self, evt: QMouseEvent) -> None: diff --git a/pydm/widgets/drawing.py b/pydm/widgets/drawing.py index 14c942484..5b5af9a93 100644 --- a/pydm/widgets/drawing.py +++ b/pydm/widgets/drawing.py @@ -9,6 +9,7 @@ from qtpy.QtDesigner import QDesignerFormWindowInterface from .base import PyDMWidget from ..utilities import is_qt_designer, find_file +from typing import List, Optional logger = logging.getLogger(__name__) @@ -454,9 +455,9 @@ def alarm_severity_changed(self, new_alarm_severity): self.penStyle = self._original_pen_style -class PyDMDrawingLine(PyDMDrawing): +class PyDMDrawingLineBase(PyDMDrawing): """ - A widget with a line drawn in it. + A base class for single and poly line widgets. This class inherits from PyDMDrawing. Parameters @@ -468,17 +469,135 @@ class PyDMDrawingLine(PyDMDrawing): """ def __init__(self, parent=None, init_channel=None): - super(PyDMDrawingLine, self).__init__(parent, init_channel) + super(PyDMDrawingLineBase, self).__init__(parent, init_channel) + self.penStyle = Qt.SolidLine + self.penWidth = 1 + self._arrow_size = 6 # 6 is arbitrary size that looked good for default, not in any specific 'units' self._arrow_end_point_selection = False self._arrow_start_point_selection = False self._arrow_mid_point_selection = False - self._mid_point_arrow_flipped = False - self.rotation = 0 - self.penStyle = Qt.SolidLine - self.penWidth = 1 + self._arrow_mid_point_flipped = False + + @Property(int) + def arrowSize(self) -> int: + """ + Size to render line arrows. + + Returns + ------- + bool + """ + return self._arrow_size + + @arrowSize.setter + def arrowSize(self, new_size) -> None: + """ + Size to render line arrows. + + Parameters + ------- + new_selection : bool + """ + if self._arrow_size != new_size: + self._arrow_size = new_size + self.update() + + @Property(bool) + def arrowEndPoint(self) -> bool: + """ + If True, an arrow will be drawn at the end of the line. + + Returns + ------- + bool + """ + return self._arrow_end_point_selection + + @arrowEndPoint.setter + def arrowEndPoint(self, new_selection) -> None: + """ + If True, an arrow will be drawn at the end of the line. + + Parameters + ------- + new_selection : bool + """ + if self._arrow_end_point_selection != new_selection: + self._arrow_end_point_selection = new_selection + self.update() + + @Property(bool) + def arrowStartPoint(self) -> bool: + """ + If True, an arrow will be drawn at the start of the line. + + Returns + ------- + bool + """ + return self._arrow_start_point_selection + + @arrowStartPoint.setter + def arrowStartPoint(self, new_selection) -> None: + """ + If True, an arrow will be drawn at the start of the line. + + Parameters + ------- + new_selection : bool + """ + if self._arrow_start_point_selection != new_selection: + self._arrow_start_point_selection = new_selection + self.update() + + @Property(bool) + def arrowMidPoint(self) -> bool: + """ + If True, an arrow will be drawn at the midpoint of the line. + Returns + ------- + bool + """ + return self._arrow_mid_point_selection + + @arrowMidPoint.setter + def arrowMidPoint(self, new_selection) -> None: + """ + If True, an arrow will be drawn at the midpoint of the line. + Parameters + ------- + new_selection : bool + """ + if self._arrow_mid_point_selection != new_selection: + self._arrow_mid_point_selection = new_selection + self.update() + + @Property(bool) + def flipMidPointArrow(self) -> bool: + """ + Flips the direction of the midpoint arrow. + + Returns + ------- + bool + """ + return self._arrow_mid_point_flipped + + @flipMidPointArrow.setter + def flipMidPointArrow(self, new_selection) -> None: + """ + Flips the direction of the midpoint arrow. + + Parameters + ------- + new_selection : bool + """ + if self._arrow_mid_point_flipped != new_selection: + self._arrow_mid_point_flipped = new_selection + self.update() @staticmethod - def _arrow_points(startpoint, endpoint, height, width): + def _arrow_points(startpoint, endpoint, height, width) -> QPolygonF: """ Returns the three points needed to make a triangle with .drawPolygon """ @@ -503,7 +622,24 @@ def _arrow_points(startpoint, endpoint, height, width): return QPolygonF([left, endpoint, right]) - def draw_item(self, painter): + +class PyDMDrawingLine(PyDMDrawingLineBase, new_properties=_penRuleProperties): + """ + A widget with a line drawn in it. + This class inherits from PyDMDrawingLineBase. + + Parameters + ---------- + parent : QWidget + The parent widget for the Label + init_channel : str, optional + The channel to be used by the widget. + """ + + def __init__(self, parent=None, init_channel=None): + super(PyDMDrawingLine, self).__init__(parent, init_channel) + + def draw_item(self, painter) -> None: """ Draws the line after setting up the canvas with a call to ```PyDMDrawing.draw_item```. @@ -554,114 +690,175 @@ def draw_item(self, painter): # Draw the arrows if self._arrow_end_point_selection: - points = self._arrow_points(start_point, end_point, 6, 6) + points = self._arrow_points(start_point, end_point, self._arrow_size, self._arrow_size) painter.drawPolygon(points) if self._arrow_start_point_selection: - points = self._arrow_points(end_point, start_point, 6, 6) + points = self._arrow_points(end_point, start_point, self._arrow_size, self._arrow_size) painter.drawPolygon(points) if self._arrow_mid_point_selection: - if self._mid_point_arrow_flipped: - points = self._arrow_points(start_point, mid_point, 6, 6) + if self._arrow_mid_point_flipped: + points = self._arrow_points(start_point, mid_point, self._arrow_size, self._arrow_size) else: - points = self._arrow_points(end_point, mid_point, 6, 6) + points = self._arrow_points(end_point, mid_point, self._arrow_size, self._arrow_size) painter.drawPolygon(points) - @Property(bool) - def arrowEndPoint(self): - """ - If True, an arrow will be drawn at the end of the line. - Returns - ------- - bool - """ - return self._arrow_end_point_selection +class PyDMDrawingPolyline(PyDMDrawingLineBase): + """ + A widget with a multi-segment, piecewise-linear line drawn in it. + This class inherits from PyDMDrawingLineBase. - @arrowEndPoint.setter - def arrowEndPoint(self, new_selection): - """ - If True, an arrow will be drawn at the end of the line. + Parameters + ---------- + parent : QWidget + The parent widget for the Label + init_channel : str, optional + The channel to be used by the widget. + """ - Parameters - ------- - new_selection : bool - """ - if self._arrow_end_point_selection != new_selection: - self._arrow_end_point_selection = new_selection - self.update() + def __init__(self, parent=None, init_channel=None): + super(PyDMDrawingPolyline, self).__init__(parent, init_channel) + self._points = [] - @Property(bool) - def arrowStartPoint(self): + def draw_item(self, painter) -> None: """ - If True, an arrow will be drawn at the start of the line. - - Returns - ------- - bool + Draws the segmented line after setting up the canvas with a call to + ``PyDMDrawing.draw_item``. """ - return self._arrow_start_point_selection + super(PyDMDrawingPolyline, self).draw_item(painter) + x, y, w, h = self.get_bounds() - @arrowStartPoint.setter - def arrowStartPoint(self, new_selection): + def p2d(pt): + "convert point to drawing coordinates" + # drawing coordinates are centered: (0,0) is in center + # our points are absolute: (0,0) is upper-left corner + if isinstance(pt, str): + # 2022-05-11: needed for backwards compatibility support + # PyDM releases up to v1.15.1 + # adl2pydm tags up to 0.0.2 + pt = tuple(map(int, pt.split(","))) + u, v = pt + return QPointF(u + x, v + y) + + if len(self._points) > 1: + for i, p1 in enumerate(self._points[:-1]): + painter.drawLine(p2d(p1), p2d(self._points[i + 1])) + if self._arrow_mid_point_selection: + point1 = p2d(p1) + point2 = p2d(self._points[i + 1]) + if self._arrow_mid_point_flipped: + point1, point2 = point2, point1 # swap values + + # arrow points at midpoint of line + midpoint_x = (point1.x() + point2.x()) / 2 + midpoint_y = (point1.y() + point2.y()) / 2 + midpoint = QPointF(midpoint_x, midpoint_y) + points = self._arrow_points( + point1, midpoint, self._arrow_size, self._arrow_size + ) # 6 = arbitrary arrow size + painter.drawPolygon(points) + + # Draw the arrows + # While we enforce >=2 points when user adds points, we need to check '(len(self._points) > 0)' here so we + # don't break trying to add arrows to new polyline with no points yet. + if self._arrow_end_point_selection and (len(self._points) > 0) and (len(self._points[1]) >= 2): + points = self._arrow_points(p2d(self._points[1]), p2d(self._points[0]), self._arrow_size, self._arrow_size) + painter.drawPolygon(points) + + if self._arrow_start_point_selection and (len(self._points) > 0) and (len(self._points[1]) >= 2): + points = self._arrow_points( + p2d(self._points[len(self._points) - 2]), + p2d(self._points[len(self._points) - 1]), + self._arrow_size, + self._arrow_size, + ) + painter.drawPolygon(points) + + def getPoints(self) -> List[str]: + """Convert internal points representation for use as QStringList.""" + points = [f"{pt[0]}, {pt[1]}" for pt in self._points] + return points + + def _validator(self, value) -> bool: """ - If True, an arrow will be drawn at the start of the line. + ensure that `value` has correct form Parameters - ------- - new_selection : bool - """ - if self._arrow_start_point_selection != new_selection: - self._arrow_start_point_selection = new_selection - self.update() + ---------- + value : [ordered pairs] + List of strings representing ordered pairs + of integer coordinates. Each ordered pair + is a tuple or list. - @Property(bool) - def arrowMidPoint(self): - """ - If True, an arrow will be drawn at the midpoint of the line. Returns - ------- - bool - """ - return self._arrow_mid_point_selection + ---------- + verified : [ordered pairs] + List of `tuple(number, number)`. - @arrowMidPoint.setter - def arrowMidPoint(self, new_selection): - """ - If True, an arrow will be drawn at the midpoint of the line. - Parameters - ------- - new_selection : bool """ - if self._arrow_mid_point_selection != new_selection: - self._arrow_mid_point_selection = new_selection - self.update() - @Property(bool) - def flipMidPointArrow(self): - """ - Flips the direction of the midpoint arrow. + def isfloat(value) -> bool: + if isinstance(value, str): + value = value.strip() + try: + float(value) + return True + except Exception: + return False + + def validate_point(i, point) -> Optional[List[float]]: + """Ignore (instead of fail on) any of these pathologies.""" + if isinstance(point, str): + try: + point = ast.literal_eval(point) + except SyntaxError: + logger.error( + "point %d must be two numbers, comma-separated, received '%s'", + i, + pt, + ) + return + if not isinstance(point, (list, tuple)) or len(point) != 2: + logger.error( + "point %d must be two numbers, comma-separated, received '%s'", + i, + pt, + ) + return + try: + point = list(map(float, point)) # ensure all values are float + except ValueError: + logger.error("point %d content must be numeric, received '%s'", i, pt) + return + + return point - Returns - ------- - bool - """ - return self._mid_point_arrow_flipped + verified = [] + for i, pt in enumerate(value, start=1): + point = validate_point(i, pt) + if point is not None: + verified.append(point) - @flipMidPointArrow.setter - def flipMidPointArrow(self, new_selection): - """ - Flips the direction of the midpoint arrow. + return verified - Parameters - ------- - new_selection : bool - """ - if self._mid_point_arrow_flipped != new_selection: - self._mid_point_arrow_flipped = new_selection + def setPoints(self, value) -> None: + verified = self._validator(value) + if verified is not None: + if len(verified) < 2: + logger.error("Must have two or more points") + return + + self._points = verified self.update() + def resetPoints(self) -> None: + self._points = [] + self.update() + + points = Property("QStringList", getPoints, setPoints, resetPoints) + class PyDMDrawingImage(PyDMDrawing): """ @@ -716,11 +913,11 @@ def get_designer_window(self): # pragma: no cover def designer_form_saved(self, filename): # pragma: no cover self.filename = self._file - def reload_image(self): + def reload_image(self) -> None: self.filename = self._file @Property(str) - def filename(self): + def filename(self) -> str: """ The filename of the image to be displayed. This can be an absolute or relative path to the display file. @@ -733,7 +930,7 @@ def filename(self): return self._file @filename.setter - def filename(self, new_file): + def filename(self, new_file) -> None: """ The filename of the image to be displayed. @@ -1186,257 +1383,6 @@ def draw_item(self, painter): painter.drawPolygon(QPolygonF(poly)) -class PyDMDrawingPolyline(PyDMDrawing): - """ - A widget with a multi-segment, piecewise-linear line drawn in it. - This class inherits from PyDMDrawing. - - Parameters - ---------- - parent : QWidget - The parent widget for the Label - init_channel : str, optional - The channel to be used by the widget. - """ - - def __init__(self, parent=None, init_channel=None): - super(PyDMDrawingPolyline, self).__init__(parent, init_channel) - self._arrow_end_point_selection = False - self._arrow_start_point_selection = False - self._arrow_mid_point_selection = False - self._arrow_mid_point_flipped = False - self.penStyle = Qt.SolidLine - self.penWidth = 1 - self._points = [] - - def draw_item(self, painter): - """ - Draws the segmented line after setting up the canvas with a call to - ``PyDMDrawing.draw_item``. - """ - super(PyDMDrawingPolyline, self).draw_item(painter) - x, y, w, h = self.get_bounds() - - def p2d(pt): - "convert point to drawing coordinates" - # drawing coordinates are centered: (0,0) is in center - # our points are absolute: (0,0) is upper-left corner - if isinstance(pt, str): - # 2022-05-11: needed for backwards compatibility support - # PyDM releases up to v1.15.1 - # adl2pydm tags up to 0.0.2 - pt = tuple(map(int, pt.split(","))) - u, v = pt - return QPointF(u + x, v + y) - - if len(self._points) > 1: - for i, p1 in enumerate(self._points[:-1]): - painter.drawLine(p2d(p1), p2d(self._points[i + 1])) - if self._arrow_mid_point_selection: - point1 = p2d(p1) - point2 = p2d(self._points[i + 1]) - if self._arrow_mid_point_flipped: - point1, point2 = point2, point1 # swap values - - # arrow points at midpoint of line - midpoint_x = (point1.x() + point2.x()) / 2 - midpoint_y = (point1.y() + point2.y()) / 2 - midpoint = QPointF(midpoint_x, midpoint_y) - points = PyDMDrawingLine._arrow_points(point1, midpoint, 6, 6) # 6 = arbitrary arrow size - painter.drawPolygon(points) - - # Draw the arrows - if self._arrow_end_point_selection and (len(self._points[1]) >= 2): - points = PyDMDrawingLine._arrow_points(p2d(self._points[1]), p2d(self._points[0]), 6, 6) - painter.drawPolygon(points) - - if self._arrow_start_point_selection and (len(self._points[1]) >= 2): - points = PyDMDrawingLine._arrow_points( - p2d(self._points[len(self._points) - 2]), - p2d(self._points[len(self._points) - 1]), - 6, - 6, - ) - painter.drawPolygon(points) - - def getPoints(self): - """Convert internal points representation for use as QStringList.""" - points = [f"{pt[0]}, {pt[1]}" for pt in self._points] - return points - - def _validator(self, value): - """ - ensure that `value` has correct form - - Parameters - ---------- - value : [ordered pairs] - List of strings representing ordered pairs - of integer coordinates. Each ordered pair - is a tuple or list. - - Returns - ---------- - verified : [ordered pairs] - List of `tuple(number, number)`. - - """ - - def isfloat(value): - if isinstance(value, str): - value = value.strip() - try: - float(value) - return True - except Exception: - return False - - def validate_point(i, point): - """Ignore (instead of fail on) any of these pathologies.""" - if isinstance(point, str): - try: - point = ast.literal_eval(point) - except SyntaxError: - logger.error( - "point %d must be two numbers, comma-separated, received '%s'", - i, - pt, - ) - return - if not isinstance(point, (list, tuple)) or len(point) != 2: - logger.error( - "point %d must be two numbers, comma-separated, received '%s'", - i, - pt, - ) - return - try: - point = list(map(float, point)) # ensure all values are float - except ValueError: - logger.error("point %d content must be numeric, received '%s'", i, pt) - return - - return point - - verified = [] - for i, pt in enumerate(value, start=1): - point = validate_point(i, pt) - if point is not None: - verified.append(point) - - return verified - - def setPoints(self, value): - verified = self._validator(value) - if verified is not None: - if len(verified) < 2: - logger.error("Must have two or more points") - return - - self._points = verified - self.update() - - def resetPoints(self): - self._points = [] - self.update() - - @Property(bool) - def arrowEndPoint(self): - """ - If True, an arrow will be drawn at the start of the last polyline segment. - - Returns - ------- - bool - """ - return self._arrow_end_point_selection - - @arrowEndPoint.setter - def arrowEndPoint(self, new_selection): - """ - If True, an arrow will be drawn at the start of the last polyline segment. - - Parameters - ------- - new_selection : bool - """ - if self._arrow_end_point_selection != new_selection: - self._arrow_end_point_selection = new_selection - self.update() - - @Property(bool) - def arrowStartPoint(self): - """ - If True, an arrow will be drawn at the start of the first polyline segment. - - Returns - ------- - bool - """ - return self._arrow_start_point_selection - - @arrowStartPoint.setter - def arrowStartPoint(self, new_selection): - """ - If True, an arrow will be drawn at the start of the first polyline segment. - - Parameters - ------- - new_selection : bool - """ - if self._arrow_start_point_selection != new_selection: - self._arrow_start_point_selection = new_selection - self.update() - - @Property(bool) - def arrowMidPoint(self): - """ - If True, an arrows will be drawn at the midpoints of the segments of the polyline. - Returns - ------- - bool - """ - return self._arrow_mid_point_selection - - @arrowMidPoint.setter - def arrowMidPoint(self, new_selection): - """ - If True, an arrows will be drawn at the midpoints of the segments of the polyline. - Parameters - ------- - new_selection : bool - """ - if self._arrow_mid_point_selection != new_selection: - self._arrow_mid_point_selection = new_selection - self.update() - - @Property(bool) - def flipMidPointArrow(self): - """ - Flips the direction of the midpoint arrows. - - Returns - ------- - bool - """ - return self._arrow_mid_point_flipped - - @flipMidPointArrow.setter - def flipMidPointArrow(self, new_selection): - """ - Flips the direction of the midpoint arrows. - - Parameters - ------- - new_selection : bool - """ - if self._arrow_mid_point_flipped != new_selection: - self._arrow_mid_point_flipped = new_selection - self.update() - - points = Property("QStringList", getPoints, setPoints, resetPoints) - - class PyDMDrawingIrregularPolygon(PyDMDrawingPolyline): """ A widget contains an irregular polygon (arbitrary number of vertices, arbitrary lengths). diff --git a/pydm/widgets/embedded_display.py b/pydm/widgets/embedded_display.py index a1d1713c9..868a85e76 100644 --- a/pydm/widgets/embedded_display.py +++ b/pydm/widgets/embedded_display.py @@ -130,7 +130,7 @@ def macros(self, new_macros): if new_macros != self._macros: self._macros = new_macros self._needs_load = True - self.load_if_needed() + self.load_if_needed() @Property(str) def filename(self): @@ -164,7 +164,7 @@ def filename(self, filename): self._load_error_timer.stop() self._load_error_timer = None self.clear_error_text() - self.load_if_needed() + self.load_if_needed() def set_macros_and_filename(self, new_filename, new_macros): """ @@ -180,6 +180,7 @@ def set_macros_and_filename(self, new_filename, new_macros): new_macros = str(new_macros) if new_macros != self._macros: self._macros = new_macros + self._needs_load = True self.filename = new_filename diff --git a/pydm/widgets/slider.py b/pydm/widgets/slider.py index 173f92f59..9683aefc3 100644 --- a/pydm/widgets/slider.py +++ b/pydm/widgets/slider.py @@ -92,7 +92,7 @@ def __init__(self, parent=None, init_channel=None): self._num_steps = 101 self._orientation = Qt.Horizontal self._step_size_channel = None - self.allowMaxEmit = True + # Set up all the internal widgets that make up a PyDMSlider. # We'll add all these things to layouts when we call setup_widgets_for_orientation label_size_policy = QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) @@ -252,8 +252,20 @@ def apply_step_size_menu_changes(self): Method which attempts to set the user imputed data from the slider parameters menu. """ # check - self.remap_flag = True try: + slider_value = float(self.slider_parameters_menu_input_widgets[0].text()) + + if slider_value < self.minimum or slider_value > self.maximum: + raise ValueError + if slider_value != self.value: + self.remap_flag = True + self.value_changed(slider_value) + self.send_value_signal[float].emit(self.value) + except ValueError: + logger.error("the given value is not a valid type or outside of the slider range") + + try: + self.remap_flag = True # checks if input can be converted to a float if not connect to a new channel new_step_size = float(self.slider_parameters_menu_input_widgets[1].text()) new_step_size_scaled = new_step_size * float(self.slider_parameters_menu_input_widgets[2].currentText()) @@ -262,13 +274,11 @@ def apply_step_size_menu_changes(self): # disconnect the step size channel and reset step size if self.step_size_channel_pv is not None: - self.allowMaxEmit = False self.step_size_channel_pv.disconnect() self.step_size_channel_pv = None self.step_size_channel = None self.step_size = new_step_size_scaled else: - # calc step size and update? logger.error("step input is incorrect or 0") except ValueError: # want all logic for receiving a string here @@ -287,16 +297,6 @@ def apply_step_size_menu_changes(self): except ValueError: logger.error("precision input is incorrect") - try: - slider_value = float(self.slider_parameters_menu_input_widgets[0].text()) - - if slider_value < self.minimum or slider_value > self.maximum: - raise ValueError - if slider_value != self.value: - self.value_changed(slider_value) - self.send_value_signal[float].emit(self.value) - except ValueError: - logger.error("the given value is not a valid type or outside of the slider range") format_type = self.slider_parameters_menu_input_widgets[5].currentText() @@ -416,12 +416,13 @@ def reset_slider_limits(self): logger.debug("Has both limits, proceeding.") self._needs_limit_info = False if self._parameters_menu_flag: - self._slider_position_to_value_map = self.create_slider_positions_map() + self._slider_position_to_value_map = self.create_slider_positions_to_value_map() self._parameters_menu_flag = False else: # if no defaults. create a linear space from min to max from default num_steps = 101, # this means we cant step to max, but also cant call create map without a value self._slider_position_to_value_map = np.linspace(self.minimum, self.maximum, num=self._num_steps) + if is_channel_valid(self.step_size_channel): self.init_step_size_channel(pv_address=self.step_size_channel, slot=self.step_size_changed) @@ -432,15 +433,23 @@ def reset_slider_limits(self): self._slider.setMaximum(self._num_steps) self._slider.valueChanged.connect(self.internal_slider_value_changed) - self.allowMaxEmit = True - self.set_slider_to_closest_value(self.value) self._slider.setSingleStep(1) self._slider.setPageStep(1) self.set_enable_state() - def init_step_size_channel(self, pv_address, slot): + def init_step_size_channel(self, pv_address: str, slot: callable): + """ + Connects instance attribute self.step_size_channel_pv to a PyDMChannel + + Parameters + --------- + pv_address: str + Channel name of PV that connection will be established too. + slot: callable + callback function to invoke when channel value changes. + """ if self.step_size_channel_pv is None: self.step_size_channel_pv = PyDMChannel(address=pv_address, value_slot=slot) self.step_size_channel_pv.connect() @@ -450,28 +459,38 @@ def init_step_size_channel(self, pv_address, slot): self.step_size_channel_pv = None self.step_size_channel_pv = PyDMChannel(address=pv_address, value_slot=slot) self.step_size_channel_pv.connect() - # else: - # once channel is set the value_slot calls the step_size_change slot which call step_size setter, - # really want to make sure this isnt too recursive - def create_slider_positions_map(self): + def create_slider_positions_to_value_map(self): + """ + Wrapper around step_size_to_slider_positions_value_map to ensure + the func is only called after a step_size has been set + + Returns + ------- + positions_to_value_map: list + list with all possible allowed slider readback values + """ if self._step_size > 0: - positions_map = self.step_size_to_slider_positions_map() + positions_to_values_map = self.step_size_to_slider_positions_value_map() elif self._step_size == 0: self.calc_step_size() - positions_map = self.step_size_to_slider_positions_map() + positions_to_values_map = self.step_size_to_slider_positions_value_map() - return positions_map + self.remap_flag = False + return positions_to_values_map - def step_size_to_slider_positions_map(self): - # there is a case where this gets called and self.value is None!!! - self._num_steps = int((self.maximum - self.minimum) / self.step_size) + 1 + def step_size_to_slider_positions_value_map(self): + """ + Given a value increment by step size and record allowed values until self.maximum, + Repeat with decrementer until self.minimum, take results and create a linear + array of allowed values where the length of the array is the number of slider positions. + and the slider position i has value array[i]. + """ forward_map = [] backward_map = [] forward_map_value = self.value backward_map_value = self.value - # fix floating error while forward_map_value < self.maximum: forward_map.append(forward_map_value) @@ -497,6 +516,7 @@ def step_size_to_slider_positions_map(self): backward_map.append(self.minimum) elif backward_map[-1] < self.minimum: backward_map[-1] = self.minimum + backward_map = list(reversed(backward_map)) slider_position_map = np.array(backward_map + forward_map) self._num_steps = len(slider_position_map) - 1 @@ -504,12 +524,12 @@ def step_size_to_slider_positions_map(self): return slider_position_map def calc_step_size(self): + """ + Given max, min, and num steps calculate step size + """ self.step_size = (self.maximum - self.minimum) / self.num_steps # maybe do callback to stepsize line edit here? - def calc_num_steps(self): - self._num_steps = int((self.maximum - self.minimum) / self.step_size) + 1 - def find_closest_slider_position_to_value(self, val): """ Find and returns the index for the closest position on the slider @@ -548,7 +568,6 @@ def set_slider_to_closest_value(self, val): # the value of the channel. logger.debug("Setting slider to closest value.") self._mute_internal_slider_changes = True - self._slider.setValue(self.find_closest_slider_position_to_value(val)) self._mute_internal_slider_changes = False @@ -589,15 +608,17 @@ def value_changed(self, new_val): new_val : int or float The new value from the channel. """ + self.check_if_value_in_map(new_val) - PyDMWritableWidget.value_changed(self, new_val) + # Calls find_closest_slider_position_to_value twice. - # Comment for the reviewer. I am aware this is not an elegant solution. - # I need a way to know if something is an external change or an internal change + PyDMWritableWidget.value_changed(self, new_val) if hasattr(self, "value_label"): logger.debug("Setting text for value label.") self.value_label.setText(self.format_string.format(self.value)) + # isSliderDown code is probably deprecable, if you want use it + # PyDMWritableWidget.value_changed(self, new_val) should be moved inside if not self._slider.isSliderDown(): self.set_slider_to_closest_value(self.value) self.update_format_string() @@ -694,6 +715,7 @@ def internal_slider_value_changed(self, val): val : int """ # Avoid potential crash if limits are undefined + if self._slider_position_to_value_map is None: return if not self._mute_internal_slider_changes: @@ -1000,7 +1022,9 @@ def step_size_changed(self, new_val): @Property(str) def step_size_channel(self): - """String to connect to pydm channel after initialization""" + """ + String to connect to pydm channel after initialization + """ return self._step_size_channel @step_size_channel.setter @@ -1009,11 +1033,14 @@ def step_size_channel(self, step_size_channel): @property def value(self): + """ + Channel readback value used to determine slider position + and generate a slider positions to value map + """ return self._value @value.setter def value(self, new_value): self._value = new_value if self.remap_flag: - self.remap_flag = False - self._slider_position_to_value_map = self.create_slider_positions_map() + self._slider_position_to_value_map = self.create_slider_positions_to_value_map() diff --git a/pydm/widgets/timeplot.py b/pydm/widgets/timeplot.py index 52a23f9e7..31bb938cd 100644 --- a/pydm/widgets/timeplot.py +++ b/pydm/widgets/timeplot.py @@ -115,14 +115,23 @@ def address(self): return self.channel.address @address.setter - def address(self, new_address): - if new_address is None or len(str(new_address)) < 1: + def address(self, new_address: str): + """Creates the channel for the input address for communicating with the address' plugin.""" + if not new_address: self.channel = None return + elif self.channel and new_address == self.channel.address: + return + self.channel = PyDMChannel( address=new_address, connection_slot=self.connectionStateChanged, value_slot=self.receiveNewValue ) + # Clear the data from the previous channel and redraw the curve + if self.points_accumulated: + self.initialize_buffer() + self.redrawCurve() + @property def plotByTimeStamps(self): return self._plot_by_timestamps @@ -257,6 +266,39 @@ def resetBufferSize(self): self._bufferSize = DEFAULT_BUFFER_SIZE self.initialize_buffer() + def insert_live_data(self, data: np.ndarray) -> None: + """ + Inserts data directly into the live buffer. + + Example use case would be pausing the gathering of data and + filling the buffer with missed data. + + Parameters + ---------- + data : np.ndarray + A numpy array of shape (2, length_of_data). Index 0 contains + timestamps and index 1 contains the data observations. + """ + live_data_length = len(data[0]) + min_x = data[0][0] + max_x = data[0][live_data_length - 1] + # Get the indices between which we want to insert the data + min_insertion_index = np.searchsorted(self.data_buffer[0], min_x) + max_insertion_index = np.searchsorted(self.data_buffer[0], max_x) + # Delete any non-raw data between the indices so we don't have multiple data points for the same timestamp + self.data_buffer = np.delete(self.data_buffer, slice(min_insertion_index, max_insertion_index), axis=1) + num_points_deleted = max_insertion_index - min_insertion_index + delta_points = live_data_length - num_points_deleted + if live_data_length > num_points_deleted: + # If the insertion will overflow the data buffer, need to delete the oldest points + self.data_buffer = np.delete(self.data_buffer, slice(0, delta_points), axis=1) + else: + self.data_buffer = np.insert(self.data_buffer, [0], np.zeros((2, delta_points)), axis=1) + min_insertion_index = np.searchsorted(self.data_buffer[0], min_x) + self.data_buffer = np.insert(self.data_buffer, [min_insertion_index], data[0:2], axis=1) + + self.points_accumulated += live_data_length - num_points_deleted + @Slot() def redrawCurve(self, min_x: Optional[float] = None, max_x: Optional[float] = None): """ @@ -447,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. @@ -468,6 +513,7 @@ def addYChannel( thresholdColor=None, yAxisName=None, useArchiveData=False, + **kwargs ): """ Adds a new curve to the current plot @@ -516,6 +562,8 @@ def addYChannel( plot_opts["lineStyle"] = lineStyle if lineWidth is not None: plot_opts["lineWidth"] = lineWidth + if kwargs: + plot_opts.update(kwargs) # Add curve new_curve = self.createCurveItem( @@ -544,7 +592,6 @@ def addYChannel( new_curve.data_changed.connect(self.set_needs_redraw) self.redraw_timer.start() - return new_curve def createCurveItem(self, *args, **kwargs): @@ -609,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: @@ -739,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.