diff --git a/apstools/_devices/kohzu_monochromator.py b/apstools/_devices/kohzu_monochromator.py index 869ecb32a..84bf33494 100644 --- a/apstools/_devices/kohzu_monochromator.py +++ b/apstools/_devices/kohzu_monochromator.py @@ -65,6 +65,7 @@ def inposition(self): return self.done.get() == self.done_value def move(self, *args, **kwargs): + """Reposition, with optional wait for completion.""" @run_in_thread def push_the_move_button_soon(delay_s=0.01): time.sleep(delay_s) # wait a short time diff --git a/apstools/_utils/__init__.py b/apstools/_utils/__init__.py index 692792a12..da2eff3ee 100644 --- a/apstools/_utils/__init__.py +++ b/apstools/_utils/__init__.py @@ -1,2 +1,15 @@ +__all__ = """ + device_read2table + listdevice_1_5_2 + listdevice + listplans + object_explorer + OverrideParameters +""".split() + +from .device_info import device_read2table +from .device_info import listdevice +from .device_info import listdevice_1_5_2 +from .device_info import object_explorer from .list_plans import listplans from .override_parameters import OverrideParameters diff --git a/apstools/_utils/device_info.py b/apstools/_utils/device_info.py new file mode 100644 index 000000000..6092a4954 --- /dev/null +++ b/apstools/_utils/device_info.py @@ -0,0 +1,277 @@ +""" +Device information +++++++++++++++++++ + +.. autosummary:: + + ~device_read2table + ~listdevice + ~listdevice_1_5_2 + ~object_explorer +""" + +__all__ = """ + device_read2table + listdevice + listdevice_1_5_2 + object_explorer +""".split() + + +from collections import defaultdict +from ophyd import Device +from ophyd import Signal +from ophyd.signal import EpicsSignalBase + +import datetime +import logging +import pandas as pd +import pyRestTable +import warnings + + +logger = logging.getLogger(__name__) +pd.set_option("display.max_rows", None) + + +def _all_signals(base): + if isinstance(base, Signal): + return [base] + items = [] + if hasattr(base, "component_names"): + for k in base.component_names: + obj = getattr(base, k) + if isinstance(obj, (Device, Signal)): + items += _all_signals(obj) + return items + + +def _get_named_child(obj, nm): + """ + return named child of ``obj`` or None + """ + try: + child = getattr(obj, nm) + return child + except TimeoutError: + logger.debug(f"timeout: {obj.name}_{nm}") + return "TIMEOUT" + + +def _get_pv(obj): + """ + Return PV name, prefix, or None from ophyd object. + """ + if hasattr(obj, "pvname"): + return obj.pvname + elif hasattr(obj, "prefix"): + return obj.prefix + + +def _list_epics_signals(obj): + """ + Return a list of the EPICS signals in obj. + + RETURNS + + list of ophyd objects that are children of ``obj`` + """ + # import pdb; pdb.set_trace() + if isinstance(obj, EpicsSignalBase): + return [obj] + elif isinstance(obj, Device): + items = [] + for nm in obj.component_names: + child = _get_named_child(obj, nm) + if child in (None, "TIMEOUT"): + continue + result = _list_epics_signals(child) + if result is not None: + items.extend(result) + return items + + +def device_read2table( + # fmt:off + device, show_ancient=True, use_datetime=True, printing=True + # fmt:on +): + """ + DEPRECATED (release 1.3.8): Use listdevice() instead. (Remove in 1.6.0.) + """ + # fmt: off + warnings.warn( + "DEPRECATED: device_read2table() will be removed" + " in release 1.6.0. Use listdevice() instead.", + DeprecationWarning, + ) + listdevice_1_5_2( + device, + show_ancient=show_ancient, + use_datetime=use_datetime, + printing=printing, + ) + # fmt: on + + +def listdevice( + obj, + scope=None, + cname=False, + dname=True, + show_pv=False, + use_datetime=True, + show_ancient=True, +): + """ + Describe the signal information from device ``obj`` in a pandas DataFrame. + + Look through all subcomponents to find all the signals to be shown. + + PARAMETERS + + obj + *object* : Instance of ophyd Signal or Device. + scope + *str* or None : Scope of content to be shown. + + - ``"full"`` (or ``None``) shows all Signal components + - ``"epics"`` shows only EPICS-based Signals + - ``"read"`` shows only the signals returned by ``obj.read()`` + + default: ``None`` + cname + *bool* : Show the _control_ (Python, dotted) name in column ``name``. + + default: ``False`` + dname + *bool* : Show the _data_ (databroker, with underlines) name in + column ``data name``. + + default: ``True`` + show_pv + *bool* : Show the EPICS process variable (PV) name in + column ``PV``. + + default: ``False`` + use_datetime + *bool* : Show the EPICS timestamp (time of last update) in + column ``timestamp``. + + default: ``True`` + show_ancient + *bool* : Show uninitialized EPICS process variables. + + In EPICS, an uninitialized PV has a timestamp of 1990-01-01 UTC. + This option enables or suppresses ancient values identified + by timestamp from 1989. These are values only defined in + the original ``.db`` file. + + default: ``True`` + """ + scope = (scope or "full").lower() + signals = _all_signals(obj) + if scope in ("full", "epics"): + if scope == "epics": + signals = [s for s in signals if isinstance(s, EpicsSignalBase)] + elif scope == "read": + reading = obj.read() + signals = [s for s in signals if s.name in reading] + else: + raise KeyError( + f"Unknown scope='{scope}'." " Must be one of None, 'full', 'epics', 'read'" + ) + + # in EPICS, an uninitialized PV has a timestamp of 1990-01-01 UTC + UNINITIALIZED = datetime.datetime.timestamp( + datetime.datetime.fromisoformat("1990-06-01") + ) + + if not cname and not dname: + cname = True + + dd = defaultdict(list) + for signal in signals: + if scope != "epics" or isinstance(signal, EpicsSignalBase): + ts = getattr(signal, "timestamp", 0) + if show_ancient or (ts >= UNINITIALIZED): + if cname: + dd["name"].append(f"{obj.name}.{signal.dotted_name}") + if dname: + dd["data name"].append(signal.name) + if show_pv: + dd["PV"].append(_get_pv(signal) or "") + dd["value"].append(signal.get()) + if use_datetime: + dd["timestamp"].append(datetime.datetime.fromtimestamp(ts)) + + return pd.DataFrame(dd) + + +def listdevice_1_5_2( + # fmt:off + device, show_ancient=True, use_datetime=True, printing=True + # fmt:on +): + """ + DEPRECATED (release 1.5.3): Use listdevice() instead. (Remove in 1.6.0.) + + Read an ophyd device and return a pyRestTable Table. + + Include an option to suppress ancient values identified + by timestamp from 1989. These are values only defined in + the original ``.db`` file. + """ + table = pyRestTable.Table() + table.labels = "name value timestamp".split() + ANCIENT_YEAR = 1989 + for k, rec in device.read().items(): + value = rec["value"] + ts = rec["timestamp"] + dt = datetime.datetime.fromtimestamp(ts) + if dt.year > ANCIENT_YEAR or show_ancient: + if use_datetime: + ts = dt + table.addRow((k, value, ts)) + + if printing: + print(table) + + return table + + +def object_explorer(obj, sortby=None, fmt="simple", printing=True): + """ + DEPRECATED (release 1.5.3): Use listdevice() instead. (Remove in 1.6.0.) + + print the contents of obj + """ + t = pyRestTable.Table() + t.addLabel("name") + t.addLabel("PV reference") + t.addLabel("value") + items = _list_epics_signals(obj) + if items is None: + logger.debug("No EPICS signals found.") + else: + logger.debug(f"number of items: {len(items)}") + + def sorter(obj): + if sortby is None: + key = obj.dotted_name + elif str(sortby).lower() == "pv": + key = _get_pv(obj) or "--" + else: + # fmt: off + raise ValueError( + f"sortby should be None or 'PV', found sortby='{sortby}'" + ) + # fmt: on + return key + + for item in sorted(items, key=sorter): + t.addRow((item.dotted_name, _get_pv(item), item.get())) + + if printing: + print(t.reST(fmt=fmt)) + return t diff --git a/apstools/_utils/tests/test_listdevice.py b/apstools/_utils/tests/test_listdevice.py new file mode 100644 index 000000000..e9ca4cf7d --- /dev/null +++ b/apstools/_utils/tests/test_listdevice.py @@ -0,0 +1,177 @@ +""" +Unit tests for :mod:`~apstools._utils.device_info`. +""" + +from ophyd import Component +from ophyd import Device +from ophyd import EpicsMotor +from ophyd import Signal +from ophyd.signal import EpicsSignalBase +import pandas as pd +import pyRestTable +import pytest + +from ...devices import SwaitRecord +from ..device_info import _list_epics_signals +from ..device_info import listdevice +from ..device_info import listdevice_1_5_2 +from ..device_info import object_explorer + + +IOC = "gp:" # for testing with an EPICS IOC + + +class MySignals(Device): + allowed = Component(Signal, value=True, kind="omitted") + background = Component(Signal, value=True, kind="normal") + tolerance = Component(Signal, value=1, kind="config") + visible = Component(Signal, value=True, kind="hinted") + + +class MyDevice(Device): + signals = Component(MySignals) + calc5 = Component(SwaitRecord, "5") + calc6 = Component(SwaitRecord, "6") + + +calcs = MyDevice(f"{IOC}userCalc", name="calcs") +motor = EpicsMotor(f"{IOC}m1", name="motor") +signal = Signal(name="signal", value=True) + +calcs.wait_for_connection() +motor.wait_for_connection() + + +def test_calcs(): + assert calcs.connected + assert calcs is not None + assert isinstance(calcs, Device) + + +@pytest.mark.parametrize( + "obj, length", + [ + (calcs, 28), + (calcs.calc5.description, 1), + (signal, 1), + (motor, 2), + (motor.user_setpoint, 1), + ], +) +def test_listdevice_1_5_2(obj, length): + result = listdevice_1_5_2(obj) + assert isinstance(result, pyRestTable.Table) + assert len(result.labels) == 3 + assert result.labels == ["name", "value", "timestamp"] + assert len(result.rows) == length + + +@pytest.mark.parametrize( + "obj, length", + [ + (calcs, 28), + (calcs.calc5.description, 1), + (signal, 1), + (motor, 2), + (motor.user_setpoint, 1), + ], +) +def test_listdevice(obj, length): + result = listdevice(obj, scope="read") + assert isinstance(result, pd.DataFrame) + assert len(result) == length + if length > 0: + assert len(result.columns) == 3 + expected = ["data name", "value", "timestamp"] + for r in result.columns: + assert r in expected + + +@pytest.mark.parametrize( + "obj, length", + [ + (calcs, 126), + (calcs.calc5.description, 1), + (signal, 0), + (motor, 19), + (motor.user_setpoint, 1), + ], +) +def test_object_explorer(obj, length): + result = object_explorer(obj) + assert isinstance(result, pyRestTable.Table) + assert len(result.labels) == 3 + assert result.labels == ["name", "PV reference", "value"] + assert len(result.rows) == length + + +@pytest.mark.parametrize( + "obj, length, ref", + [ + (calcs, 126, EpicsSignalBase), + (calcs.calc5.description, 1, EpicsSignalBase), + (signal, None, None), + (motor, 19, EpicsSignalBase), + (motor.user_setpoint, 1, EpicsSignalBase), + ], +) +def test__list_epics_signals(obj, length, ref): + result = _list_epics_signals(obj) + if length is None: + assert result is None + else: + assert isinstance(result, list) + assert len(result) == length + for item in result: + assert isinstance(item, ref) + + +@pytest.mark.parametrize( + "function, row, column, value", + [ + (listdevice, 0, "data name", "calcs_signals_background"), + (listdevice, 0, "value", True), + (listdevice, 2, "data name", "calcs_calc5_calculated_value"), + (listdevice, 2, "value", 0.0), + (listdevice, 27, "data name", "calcs_calc6_channels_L_input_value"), + (listdevice, 27, "value", 0.0), + (listdevice_1_5_2, 0, 0, "calcs_signals_background"), + (listdevice_1_5_2, 0, 1, True), + (listdevice_1_5_2, 2, 0, "calcs_calc5_calculated_value"), + (listdevice_1_5_2, 2, 1, 0.0), + (listdevice_1_5_2, 27, 0, "calcs_calc6_channels_L_input_value"), + (listdevice_1_5_2, 27, 1, 0.0), + (object_explorer, 0, 0, "calc5.alarm_severity"), + (object_explorer, 0, 1, f"{IOC}userCalc5.SEVR"), + (object_explorer, 125, 0, "calc6.trace_processing"), + (object_explorer, 125, 1, f"{IOC}userCalc6.TPRO"), + (object_explorer, 125, 2, 0), + ], +) +def test_spotchecks(function, row, column, value): + if function == listdevice: + result = function(calcs, scope="read") + else: + result = function(calcs) + if isinstance(result, pd.DataFrame): + assert result[column][row] == value + else: + assert result.rows[row][column] == value + + +@pytest.mark.parametrize( + "device, scope, ancient, length", + [ + (calcs, "epics", False, 0), + (calcs, "epics", True, 126), + (calcs, "full", False, 4), + (calcs, "full", True, 130), + (calcs, "read", False, 2), + (calcs, "read", True, 28), + (calcs, None, False, 4), + (calcs, None, True, 130), + ], +) +def test_listdevice_filters(device, scope, ancient, length): + result = listdevice(device, scope, show_ancient=ancient) + assert len(result) == length diff --git a/apstools/_utils/tests/test_listruns_class.py b/apstools/_utils/tests/test_listruns_class.py index ed86d3c9e..a3030ea12 100644 --- a/apstools/_utils/tests/test_listruns_class.py +++ b/apstools/_utils/tests/test_listruns_class.py @@ -19,6 +19,7 @@ def lr(): lr = APS_utils.ListRuns() lr.cat = databroker.catalog[TEST_CATALOG_NAME] lr._check_keys() + assert len(lr.cat) == 53 return lr diff --git a/apstools/_utils/tests/test_utils.py b/apstools/_utils/tests/test_utils.py index 857deedc7..95eaf3963 100644 --- a/apstools/_utils/tests/test_utils.py +++ b/apstools/_utils/tests/test_utils.py @@ -34,7 +34,7 @@ def test_utils_cleanupText(): def test_utils_listdevice(): motor1 = ophyd.sim.hw().motor1 - table = APS_utils.listdevice(motor1, show_ancient=True, use_datetime=True) + table = APS_utils.listdevice_1_5_2(motor1, show_ancient=True, use_datetime=True) expected = ( "=============== =====\n" "name value\n" @@ -46,12 +46,12 @@ def test_utils_listdevice(): received = "\n".join([v[:21] for v in str(table).strip().splitlines()]) assert received == expected - table = APS_utils.listdevice(motor1, show_ancient=True, use_datetime=False) + table = APS_utils.listdevice_1_5_2(motor1, show_ancient=True, use_datetime=False) # expected = """ """.strip() received = "\n".join([v[:21] for v in str(table).strip().splitlines()]) assert received == expected - table = APS_utils.listdevice(motor1, show_ancient=False, use_datetime=False) + table = APS_utils.listdevice_1_5_2(motor1, show_ancient=False, use_datetime=False) # expected = """ """.strip() received = "\n".join([v[:21] for v in str(table).strip().splitlines()]) assert received == expected diff --git a/apstools/beamtime/tests/test_apsbss.py b/apstools/beamtime/tests/test_apsbss.py index 26537db89..ff410dca5 100644 --- a/apstools/beamtime/tests/test_apsbss.py +++ b/apstools/beamtime/tests/test_apsbss.py @@ -24,9 +24,12 @@ ) # set default timeout for all EpicsSignal connections & communications -EpicsSignalBase.set_defaults( - auto_monitor=True, timeout=60, write_timeout=60, connection_timeout=60, -) +try: + EpicsSignalBase.set_defaults( + auto_monitor=True, timeout=60, write_timeout=60, connection_timeout=60, + ) +except RuntimeError: + pass # ignore if some EPICS object already created @pytest.fixture(scope="function") @@ -189,8 +192,6 @@ def test_EPICS(ioc, bss_PV): assert ioc.bss.esaf.aps_cycle.get() == "" assert ioc.bss.esaf.aps_cycle.connected is True - ioc.bss.esaf.aps_cycle.put(cycle) - assert ioc.bss.esaf.aps_cycle.get() != cycle if not using_APS_workstation(): return diff --git a/apstools/utils.py b/apstools/utils.py index 1f6464700..1a033b087 100644 --- a/apstools/utils.py +++ b/apstools/utils.py @@ -8,7 +8,7 @@ ~connect_pvlist ~copy_filtered_catalog ~db_query - ~device_read2table + ~apstools._utils.device_info.device_read2table ~dictionary_table ~EmailNotifications ~ExcelDatabaseFileBase @@ -30,14 +30,15 @@ ~itemizer ~json_export ~json_import - ~listdevice + ~apstools._utils.device_info.listdevice + ~apstools._utils.device_info.listdevice_1_5_2 ~listobjects ~apstools._utils.list_plans.listplans ~listRunKeys ~ListRuns ~listruns ~listruns_v1_4 - ~object_explorer + ~apstools._utils.device_info.object_explorer ~apstools._utils.override_parameters.OverrideParameters ~pairwise ~print_RE_md @@ -79,6 +80,7 @@ from dataclasses import dataclass from email.mime.text import MIMEText from event_model import NumpyEncoder + import databroker import databroker.queries import datetime @@ -103,8 +105,7 @@ import zipfile from .filewriters import _rebuild_scan_command -from ._utils import listplans -from ._utils import OverrideParameters +from ._utils import * logger = logging.getLogger(__name__) @@ -160,59 +161,6 @@ def command_list_as_table(commands, show_raw=False): return tbl -def device_read2table( - # fmt:off - device, show_ancient=True, use_datetime=True, printing=True - # fmt:on -): - """ - DEPRECATED: Use listdevice() instead. - """ - # fmt: off - warnings.warn( - "DEPRECATED: device_read2table() will be removed" - " in a future release. Use listdevice() instead.", - DeprecationWarning, - ) - listdevice( - device, - show_ancient=show_ancient, - use_datetime=use_datetime, - printing=printing, - ) - # fmt: on - - -def listdevice( - # fmt:off - device, show_ancient=True, use_datetime=True, printing=True - # fmt:on -): - """ - Read an ophyd device and return a pyRestTable Table. - - Include an option to suppress ancient values identified - by timestamp from 1989. These are values only defined in - the original ``.db`` file. - """ - table = pyRestTable.Table() - table.labels = "name value timestamp".split() - ANCIENT_YEAR = 1989 - for k, rec in device.read().items(): - value = rec["value"] - ts = rec["timestamp"] - dt = datetime.datetime.fromtimestamp(ts) - if dt.year > ANCIENT_YEAR or show_ancient: - if use_datetime: - ts = dt - table.addRow((k, value, ts)) - - if printing: - print(table) - - return table - - def dictionary_table(dictionary, **kwargs): """ return a text table from ``dictionary`` @@ -279,28 +227,6 @@ def full_dotted_name(obj): return ".".join(names[::-1]) -def _get_named_child(obj, nm): - """ - return named child of ``obj`` or None - """ - try: - child = getattr(obj, nm) - return child - except TimeoutError: - logger.debug(f"timeout: {obj.name}_{nm}") - return "TIMEOUT" - - -def _get_pv(obj): - """ - returns PV name, prefix of None from ophyd object - """ - if hasattr(obj, "pvname"): - return obj.pvname - elif hasattr(obj, "prefix"): - return obj.prefix - - def getDatabase(db=None, catalog_name=None): """ Return Bluesky database using keyword guides or default choice. @@ -1341,60 +1267,6 @@ def sorter(uid): return table -def _ophyd_structure_walker(obj): - """ - walk the structure of the ophyd obj - - RETURNS - - list of ophyd objects that are children of ``obj`` - """ - # import pdb; pdb.set_trace() - if isinstance(obj, ophyd.signal.EpicsSignalBase): - return [obj] - elif isinstance(obj, ophyd.Device): - items = [] - for nm in obj.component_names: - child = _get_named_child(obj, nm) - if child in (None, "TIMEOUT"): - continue - result = _ophyd_structure_walker(child) - if result is not None: - items.extend(result) - return items - - -def object_explorer(obj, sortby=None, fmt="simple", printing=True): - """ - print the contents of obj - """ - t = pyRestTable.Table() - t.addLabel("name") - t.addLabel("PV reference") - t.addLabel("value") - items = _ophyd_structure_walker(obj) - logger.debug(f"number of items: {len(items)}") - - def sorter(obj): - if sortby is None: - key = obj.dotted_name - elif str(sortby).lower() == "pv": - key = _get_pv(obj) or "--" - else: - # fmt: off - raise ValueError( - f"sortby should be None or 'PV', found sortby='{sortby}'" - ) - # fmt: on - return key - - for item in sorted(items, key=sorter): - t.addRow((item.dotted_name, _get_pv(item), item.get())) - if printing: - print(t.reST(fmt=fmt)) - return t - - def print_RE_md(dictionary=None, fmt="simple", printing=True): """ custom print the RunEngine metadata in a table diff --git a/docs/source/source/_utils.rst b/docs/source/source/_utils.rst index 1a43cd5d1..0bc0a22af 100644 --- a/docs/source/source/_utils.rst +++ b/docs/source/source/_utils.rst @@ -12,6 +12,9 @@ here. Submodules ++++++++++ +.. automodule:: apstools._utils.device_info + :members: + .. automodule:: apstools._utils.list_plans :members: diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..ec319f080 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +filterwarnings = + ignore:Using or importing the ABCs from:DeprecationWarning + ignore:.*imp module is deprecated in favour of importlib:DeprecationWarning + ignore:.*will be removed in a future release.:DeprecationWarning