Skip to content

Commit

Permalink
Merge pull request #984 from YektaY/pva_nttable
Browse files Browse the repository at this point in the history
ENH: PyDMNTTable Widget
  • Loading branch information
jbellister-slac authored Apr 5, 2023
2 parents 5be83f3 + ebc7a6e commit dded7d8
Show file tree
Hide file tree
Showing 13 changed files with 498 additions and 29 deletions.
25 changes: 24 additions & 1 deletion docs/source/data_plugins/p4p_plugin.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ P4P is the only option (and will be chosen automatically if this variable is not
in the future.

Supported Types
---------------
===============

Currently this data plugin supports all `normative types`_. The values and control variables are pulled out of
the data received and sent through the existing PyDM signals to be read by widgets via the channels they are
Expand All @@ -39,6 +39,29 @@ currently possible in this version of the plugin. For example, defining a group
result in the named fields being sent to the widgets. Full support for structured data is planned to be supported
as part of a future release.

NTTables
--------

The plugin accepts NTTables. It will convert NTTables in python dictionaries which are then passed to the pydm widgets.
Not all widgets will accept a dictionary (or the whole NTTable) as an input.
A specified section of the NTTable can be passed to a those pydm widgets which do not accept dictionaries.
If the PV is passing an NTTable and the user wants to pass only a specific subfield of the NTTable this can be achieved via appending a ``/``
followed by the key or name of the column header of the subfield of the NTTable.
For example::

pva://MTEST/subfield

multiple layers of subfields also works::

pva://MTEST/sub-field/subfield_of_a_subfield

Note: currenty subfields can only be used to read a subset of data from an NTTables
and can't be used to write to the subfield. A follow up release will add an ability to
write to the subfield.

Image decompression
-------------------

Image decompression is performed when image data is specified using an ``NTNDArray`` with the ``codec`` field set.
The decompression algorithm to apply will be determined by what the ``codec`` field is set to. In order
for decompression to happen, the python package for the associated codec must be installed in the environment
Expand Down
13 changes: 13 additions & 0 deletions docs/source/widgets/nt_table.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#######################
PyDMNTTable
#######################

.. autoclass:: pydm.widgets.nt_table.PythonTableModel
:members:
:inherited-members:
:show-inheritance:

.. autoclass:: pydm.widgets.nt_table.PyDMNTTable
:members:
:inherited-members:
:show-inheritance:
13 changes: 9 additions & 4 deletions pydm/data_plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@
from qtpy.QtWidgets import QApplication

from .. import config
from ..utilities import (import_module_by_filename, log_failures,
protocol_and_address)
from ..utilities import (import_module_by_filename, log_failures, parsed_address)
from .plugin import PyDMPlugin

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -77,15 +76,20 @@ def plugin_for_address(address: str) -> Optional[PyDMPlugin]:
Find the correct PyDMPlugin for a channel
"""
# Check for a configured protocol
protocol, addr = protocol_and_address(address)
try:
protocol = parsed_address(address).scheme
except AttributeError:
protocol = None

# Use default protocol
if protocol is None and config.DEFAULT_PROTOCOL is not None:
logger.debug("Using default protocol %s for %s",
config.DEFAULT_PROTOCOL, address)
# If no protocol was specified, and the default protocol
# environment variable is specified, try to use that instead.
protocol = config.DEFAULT_PROTOCOL
# Load proper plugin module

# Load proper plugin module
if protocol:
initialize_plugins_if_needed()
try:
Expand All @@ -98,6 +102,7 @@ def plugin_for_address(address: str) -> Optional[PyDMPlugin]:
"will receive no data. To specify a default protocol, "
"set the PYDM_DEFAULT_PROTOCOL environment variable."
"".format(addr=address))

return None


Expand Down
55 changes: 49 additions & 6 deletions pydm/data_plugins/epics_plugins/p4p_plugin_component.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
import numpy as np

import collections
from p4p.client.thread import Context, Disconnected
from p4p.wrapper import Value
from .pva_codec import decompress
Expand All @@ -19,7 +19,6 @@ def __init__(self, channel: PyDMChannel, address: str,
protocol: Optional[str] = None, parent: Optional[QObject] = None):
"""
Manages the connection to a channel using the P4P library. A given channel can have multiple listeners.
Parameters
----------
channel : PyDMChannel
Expand All @@ -33,7 +32,7 @@ def __init__(self, channel: PyDMChannel, address: str,
"""
super().__init__(channel, address, protocol, parent)
self._connected = False
self.monitor = P4PPlugin.context.monitor(name=address,
self.monitor = P4PPlugin.context.monitor(name=self.address,
cb=self.send_new_value,
notify_disconnect=True)
self.add_listener(channel)
Expand All @@ -49,6 +48,7 @@ def __init__(self, channel: PyDMChannel, address: str,
self._upper_warning_limit = None
self._lower_warning_limit = None
self._timestamp = None
self.nttable_data_location = PyDMPlugin.get_subfield(channel)

def clear_cache(self) -> None:
""" Clear out all the stored values of this connection. """
Expand All @@ -65,6 +65,8 @@ def clear_cache(self) -> None:
self._lower_warning_limit = None
self._timestamp = None

self.nttable_data_location = None

def send_new_value(self, value: Value) -> None:
""" Callback invoked whenever a new value is received by our monitor. Emits signals based on values changed. """
if isinstance(value, Disconnected):
Expand All @@ -79,9 +81,45 @@ def send_new_value(self, value: Value) -> None:
self.write_access_signal.emit(True)

self._value = value
has_value_changed_yet = False
for changed_value in value.changedSet():
if changed_value == 'value':
new_value = value.value
if changed_value == 'value' or changed_value.split('.')[0] == 'value':
# NTTable has a changedSet item for each column that has changed
# Since we want to send an update on any table change, let's track
# if the value item has been updated yet
if has_value_changed_yet:
continue
else:
has_value_changed_yet = True

if 'NTTable' in value.getID():
new_value = value.value.todict()
else:
new_value = value.value

if self.nttable_data_location:
msg = f"Invalid channel... {self.nttable_data_location}"

for value in self.nttable_data_location:
if isinstance(new_value, collections.Container) and not isinstance(new_value, str):

if type(value) == str:
try:
new_value = new_value[value]
continue
except TypeError:
logger.debug('Type Error when attempting to use the given key, code will next attempt to convert the key to an int')
except KeyError:
logger.exception(msg)

try:
new_value = new_value[int(value)]
except ValueError:
logger.exception(msg, exc_info=True)
else:
logger.exception(msg, exc_info=True)
raise ValueError(msg)

if new_value is not None:
if isinstance(new_value, np.ndarray):
if 'NTNDArray' in value.getID():
Expand All @@ -95,6 +133,8 @@ def send_new_value(self, value: Value) -> None:
self.new_value_signal[int].emit(new_value)
elif isinstance(new_value, str):
self.new_value_signal[str].emit(new_value)
elif isinstance(new_value, dict):
self.new_value_signal[dict].emit(new_value)
else:
raise ValueError(f'No matching signal for value: {new_value} with type: {type(new_value)}')
# Sometimes unchanged control variables appear to be returned with value changes, so checking against
Expand Down Expand Up @@ -149,7 +189,6 @@ def put_value(self, value):
def add_listener(self, channel: PyDMChannel):
"""
Adds a listener to this connection, connecting the appropriate signals/slots to the input PyDMChannel.
Parameters
----------
channel : PyDMChannel
Expand Down Expand Up @@ -183,6 +222,10 @@ def add_listener(self, channel: PyDMChannel):
channel.value_signal[np.ndarray].connect(self.put_value, Qt.QueuedConnection)
except KeyError:
pass
try:
channel.value_signal[dict].connect(self.put_value, Qt.QueuedConnection)
except KeyError:
pass

def close(self):
""" Closes out this connection. """
Expand Down
55 changes: 44 additions & 11 deletions pydm/data_plugins/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
from numpy import ndarray
from typing import Optional, Callable

from ..utilities.remove_protocol import protocol_and_address
from ..utilities.remove_protocol import parsed_address
from qtpy.QtCore import Signal, QObject, Qt
from qtpy.QtWidgets import QApplication
from .. import config

import re

class PyDMConnection(QObject):
new_value_signal = Signal([float], [int], [str], [ndarray], [bool])
new_value_signal = Signal([float], [int], [str], [bool], [object])
connection_state_signal = Signal(bool)
new_severity_signal = Signal(int)
write_access_signal = Signal(bool)
Expand Down Expand Up @@ -55,14 +55,14 @@ def add_listener(self, channel):
except TypeError:
pass
try:
self.new_value_signal[ndarray].connect(channel.value_slot, Qt.QueuedConnection)
self.new_value_signal[bool].connect(channel.value_slot, Qt.QueuedConnection)
except TypeError:
pass
try:
self.new_value_signal[bool].connect(channel.value_slot, Qt.QueuedConnection)
self.new_value_signal[object].connect(channel.value_slot, Qt.QueuedConnection)
except TypeError:
pass

if channel.severity_slot is not None:
self.new_severity_signal.connect(channel.severity_slot, Qt.QueuedConnection)

Expand Down Expand Up @@ -134,14 +134,14 @@ def remove_listener(self, channel, destroying: Optional[bool] = False) -> None:
except TypeError:
pass
try:
self.new_value_signal[ndarray].disconnect(channel.value_slot)
self.new_value_signal[bool].disconnect(channel.value_slot)
except TypeError:
pass
try:
self.new_value_signal[bool].disconnect(channel.value_slot)
self.new_value_signal[object].disconnect(channel.value_slot)
except TypeError:
pass

if self._should_disconnect(channel.severity_slot, destroying):
try:
self.new_severity_signal.disconnect(channel.severity_slot)
Expand Down Expand Up @@ -250,19 +250,52 @@ def __init__(self):
self.channels = weakref.WeakSet()
self.lock = threading.Lock()

@staticmethod
def get_full_address(channel):
parsed_addr = parsed_address(channel.address)

if parsed_addr:
full_addr = parsed_addr.netloc + parsed_addr.path
else:
full_addr = None

return full_addr

@staticmethod
def get_address(channel):
return protocol_and_address(channel.address)[1]
parsed_addr = parsed_address(channel.address)
addr = parsed_addr.netloc
protocol = parsed_addr.scheme

if protocol == 'calc' or protocol == 'loc':
addr = parsed_addr.netloc + '?' + parsed_addr.query

return addr

@staticmethod
def get_subfield(channel):
parsed_addr = parsed_address(channel.address)

if parsed_addr:
subfield = parsed_addr.path

if subfield != '':
subfield = subfield[1:].split('/')
else:
subfield = None

return subfield

@staticmethod
def get_connection_id(channel):
return PyDMPlugin.get_address(channel)
return PyDMPlugin.get_full_address(channel)

def add_connection(self, channel):
from pydm.utilities import is_qt_designer
with self.lock:
connection_id = self.get_connection_id(channel)
address = self.get_address(channel)

# If this channel is already connected to this plugin lets ignore
if channel in self.channels:
return
Expand Down
4 changes: 2 additions & 2 deletions pydm/tests/test_data_plugins_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,13 @@ def test_plugin_directory_loading(qapp, caplog):
os.remove(os.path.join(cur_dir, 'plugin_foo.py'))


def test_plugin_for_address(test_plugin):
def test_plugin_for_address(test_plugin, monkeypatch):
# Get by protocol
assert isinstance(plugin_for_address('tst://tst:this'),
test_plugin)
assert plugin_for_address('tst:this') is None
# Default protocol
config.DEFAULT_PROTOCOL = 'tst'
monkeypatch.setattr(config, 'DEFAULT_PROTOCOL', 'tst')
assert isinstance(plugin_for_address('tst:this'),
test_plugin)

Expand Down
22 changes: 21 additions & 1 deletion pydm/tests/utilities/test_remove_protocol.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from ...utilities.remove_protocol import remove_protocol

from ...utilities.remove_protocol import protocol_and_address
from ...utilities.remove_protocol import parsed_address

def test_remove_protocol():
out = remove_protocol('foo://bar')
Expand All @@ -10,3 +11,22 @@ def test_remove_protocol():

out = remove_protocol('foo://bar://foo2')
assert (out == 'bar://foo2')

def test_protocol_and_address():
out = protocol_and_address('foo://bar')
assert (out == ('foo', 'bar'))

out = protocol_and_address('foo:/bar')
assert (out == (None, 'foo:/bar'))

def test_parsed_address():
out = parsed_address(1)
assert (out == None)

out = parsed_address('foo:/bar')
assert (out == None)

out = parsed_address('foo://bar')
assert (out == ('foo', 'bar', '', '', '', ''))


2 changes: 1 addition & 1 deletion pydm/utilities/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from . import colors, macro, shortcuts
from .connection import close_widget_connections, establish_widget_connections
from .iconfont import IconFont
from .remove_protocol import protocol_and_address, remove_protocol
from .remove_protocol import protocol_and_address, remove_protocol, parsed_address
from .units import convert, find_unit_options, find_unittype

logger = logging.getLogger(__name__)
Expand Down
Loading

0 comments on commit dded7d8

Please sign in to comment.