From 7e1cf56d3c5475903fa8e5d0efb238f4fcec30f8 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Mon, 13 Jan 2025 11:56:18 -0600 Subject: [PATCH] Added an Ophyd-async device to match the threaded Ophyd device. --- apsbss/apsbss_ophyd_async.py | 318 ++++++++++++++++++++++++ apsbss/tests/conftest.py | 2 +- apsbss/tests/test_apsbss_ophyd_async.py | 39 +++ pyproject.toml | 3 + pytest.ini | 1 + 5 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 apsbss/apsbss_ophyd_async.py create mode 100644 apsbss/tests/test_apsbss_ophyd_async.py diff --git a/apsbss/apsbss_ophyd_async.py b/apsbss/apsbss_ophyd_async.py new file mode 100644 index 0000000..23f871e --- /dev/null +++ b/apsbss/apsbss_ophyd_async.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python + +""" +ophyd support for apsbss + +EXAMPLE:: + + apsbss = EpicsBssDevice("ioc:bss:", name="apsbss") + +.. autosummary:: + + ~EpicsBssDevice + ~EpicsEsafDevice + ~EpicsEsafExperimenterDevice + ~EpicsProposalDevice + ~EpicsProposalExperimenterDevice + +""" + +__all__ = [ + "EpicsBssDevice", +] + +# from ..plans import addDeviceDataAsStream +import asyncio +import datetime as dt + +import pyRestTable +from bluesky import plan_stubs as bps +from ophyd_async.core import DeviceVector +from ophyd_async.core import StandardReadable +from ophyd_async.epics.core import epics_signal_rw + +from .core import trim + + +class EpicsEsafExperimenterDevice(StandardReadable): + """Ophyd-async device for experimenter info from APS ESAF. + + .. autosummary:: + + ~clear + + """ + + def __init__(self, prefix: str, *, name: str = ""): + with self.add_children_as_readables(): + self.badge_number = epics_signal_rw(str, f"{prefix}badgeNumber") + self.email = epics_signal_rw(str, f"{prefix}email") + self.first_name = epics_signal_rw(str, f"{prefix}firstName") + self.last_name = epics_signal_rw(str, f"{prefix}lastName") + super().__init__(name=name) + + async def clear(self): + """Clear the fields for this user.""" + await asyncio.gather( + self.badge_number.set(""), + self.email.set(""), + self.first_name.set(""), + self.last_name.set(""), + ) + + +class EpicsEsafDevice(StandardReadable): + """ + Ophyd device for info from APS ESAF. + + .. autosummary:: + + ~clear + ~clear_users + """ + + def __init__(self, prefix: str, *, name: str = "", num_users: int = 9): + with self.add_children_as_readables(): + self.aps_run = epics_signal_rw(str, f"{prefix}run") + self.description = epics_signal_rw(str, f"{prefix}description") + self.end_date = epics_signal_rw(str, f"{prefix}endDate") + self.end_date_timestamp = epics_signal_rw(int, f"{prefix}endDate:timestamp") + self.esaf_id = epics_signal_rw(int, f"{prefix}id") + self.esaf_status = epics_signal_rw(str, f"{prefix}status") + self.number_users_in_pvs = epics_signal_rw(int, f"{prefix}users_in_pvs") + self.number_users_total = epics_signal_rw(int, f"{prefix}users_total") + self.sector = epics_signal_rw(str, f"{prefix}sector") + self.start_date = epics_signal_rw(str, f"{prefix}startDate") + self.start_date_timestamp = epics_signal_rw(int, f"{prefix}startDate:timestamp") + self.title = epics_signal_rw(str, f"{prefix}title") + self.user_last_names = epics_signal_rw(str, f"{prefix}users") + self.user_badges = epics_signal_rw(str, f"{prefix}userBadges") + self.users = DeviceVector( + {idx: EpicsEsafExperimenterDevice(f"{prefix}user{idx+1}:") for idx in range(num_users)} + ) + self.raw = epics_signal_rw(str, f"{prefix}raw") + + super().__init__(name=name) + + async def clear(self): + """ + Clear the most of the ESAF info. + + Do not clear these items: + + * ``aps_run`` + * ``esaf_id`` + * ``sector`` + """ + await asyncio.gather( + # self.aps_run.put(""), # user controls this + self.description.set(""), + self.end_date.set(""), + self.end_date_timestamp.set(0), + # self.esaf_id.set(""), # user controls this + self.esaf_status.set(""), + # self.sector.set(""), + self.start_date.set(""), + self.start_date_timestamp.set(0), + self.title.set(""), + self.user_last_names.set(""), + self.user_badges.set(""), + self.clear_users(), + ) + + async def clear_users(self): + """Clear the info for all users.""" + user_devices = [user.clear() for user in self.users.values()] + await asyncio.gather(*user_devices) + + +class EpicsProposalExperimenterDevice(StandardReadable): + """ + Ophyd device for experimenter info from APS Proposal. + + .. autosummary:: + + ~clear + """ + + def __init__(self, prefix: str, *, name: str = ""): + with self.add_children_as_readables(): + self.badge_number = epics_signal_rw(str, f"{prefix}badgeNumber") + self.email = epics_signal_rw(str, f"{prefix}email") + self.first_name = epics_signal_rw(str, f"{prefix}firstName") + self.institution = epics_signal_rw(str, f"{prefix}institution") + self.institution_id = epics_signal_rw(int, f"{prefix}instId") + self.last_name = epics_signal_rw(str, f"{prefix}lastName") + self.pi_flag = epics_signal_rw(bool, f"{prefix}piFlag") + self.user_id = epics_signal_rw(int, f"{prefix}userId") + super().__init__(name=name) + + async def clear(self): + """Clear the info for this user.""" + await asyncio.gather( + self.badge_number.set(""), + self.email.set(""), + self.first_name.set(""), + self.last_name.set(""), + self.user_id.set(0), + self.institution_id.set(0), + self.institution.set(""), + self.pi_flag.set(0), + ) + + +class EpicsProposalDevice(StandardReadable): + """ + Ophyd device for info from APS Proposal. + + .. autosummary:: + + ~clear + ~clear_users + """ + + def __init__(self, prefix: str, *, name: str = "", num_users: int = 9): + with self.add_children_as_readables(): + self.beamline_name = epics_signal_rw(str, f"{prefix}beamline") + self.end_date = epics_signal_rw(str, f"{prefix}endDate") + self.end_date_timestamp = epics_signal_rw(int, f"{prefix}endDate:timestamp") + self.mail_in_flag = epics_signal_rw(str, f"{prefix}mailInFlag") + self.number_users_in_pvs = epics_signal_rw(int, f"{prefix}users_in_pvs") + self.number_users_total = epics_signal_rw(int, f"{prefix}users_total") + self.proposal_id = epics_signal_rw(int, f"{prefix}id") + self.proprietary_flag = epics_signal_rw(str, f"{prefix}proprietaryFlag") + self.start_date = epics_signal_rw(str, f"{prefix}startDate") + self.start_date_timestamp = epics_signal_rw(int, f"{prefix}startDate:timestamp") + self.submitted_date = epics_signal_rw(str, f"{prefix}submittedDate") + self.submitted_date_timestamp = epics_signal_rw(int, f"{prefix}submittedDate:timestamp") + self.title = epics_signal_rw(str, f"{prefix}title") + self.user_badges = epics_signal_rw(str, f"{prefix}userBadges") + self.user_last_names = epics_signal_rw(str, f"{prefix}users") + self.users = DeviceVector( + {idx: EpicsProposalExperimenterDevice(f"{prefix}user{idx+1}:") for idx in range(num_users)} + ) + self.raw = epics_signal_rw(str, f"{prefix}raw") + super().__init__(name=name) + + async def clear(self): + """ + Clear the most of the proposal info. + + Do not clear these items: + + * ``beamline_name`` + * ``proposal_id`` + """ + await asyncio.gather( + # self.beamline_name.put(""), # user controls this + self.end_date.set(""), + self.end_date_timestamp.set(0), + self.mail_in_flag.set(0), + # self.proposal_id.set(-1), # user controls this + self.proprietary_flag.set(0), + self.start_date.set(""), + self.start_date_timestamp.set(0), + self.submitted_date.set(""), + self.submitted_date_timestamp.set(0), + self.title.set(""), + self.user_last_names.set(""), + self.user_badges.set(""), + self.clear_users(), + ) + + async def clear_users(self): + """Clear the info for all users.""" + aws = [user.clear() for user in self.users.values()] + await asyncio.gather(*aws) + + +class EpicsBssDevice(StandardReadable): + """ + Ophyd-async device for info from APS Proposal and ESAF databases. + + .. autosummary:: + + ~_table + ~addDeviceDataAsStream + ~clear + """ + + def __init__(self, prefix: str, *, name: str = ""): + with self.add_children_as_readables(): + self.esaf = EpicsEsafDevice(f"{prefix}esaf:") + self.proposal = EpicsProposalDevice(f"{prefix}proposal:") + + self.ioc_host = epics_signal_rw(str, f"{prefix}ioc_host") + self.ioc_user = epics_signal_rw(str, f"{prefix}ioc_user") + self.status_msg = epics_signal_rw(str, f"{prefix}status") + + super().__init__(name=name) + + def addDeviceDataAsStream(self, stream_name=None): + """Write the data as a separate stream.""" + yield from bps.create(name=stream_name or "apsbss") + yield from bps.read(self) + yield from bps.save() + + async def clear(self): + """Clear the proposal and ESAF info.""" + await asyncio.gather( + self.esaf.clear(), + self.proposal.clear(), + ) + await self.status_msg.set("Cleared") + + def descendants(self): + def _descendants(parent, dotted_name): + yield dotted_name, parent + if not hasattr(parent, "children"): + return + for attr, child in parent.children(): + yield from _descendants(child, f"{dotted_name}.{attr}") + + yield from _descendants(self, self.name) + + async def _table(self, *, show_name=False, length=40): + """Make a table of all Component Signal values.""" + # Prepare individual data columns + descendants = [(name, sig) for name, sig in self.descendants() if hasattr(sig, "get_value")] + dotted_names, signals = zip(*descendants) + readings = await asyncio.gather(*[sig.read() for sig in signals]) + readings = [reading[sig.name] for sig, reading in zip(signals, readings)] + timestamps = [reading["timestamp"] for reading in readings] + timestamps = [dt.datetime.fromtimestamp(ts).astimezone() for ts in timestamps] + timestamps = [ts if ts.year >= 2000 else "--" for ts in timestamps] + values = [reading["value"] for reading in readings] + values = [trim(str(val), length=length) for val in values] + sources = [sig.source for sig in signals] + # Combine into sorted data table + if show_name: + dotted_names, sources, values, timestamps = zip( + *sorted(zip(dotted_names, sources, values, timestamps)) + ) + data = { + "name": dotted_names, + "source": sources, + "value": values, + "updated": timestamps, + } + else: + sources, values, timestamps = zip(*sorted(zip(sources, values, timestamps))) + data = { + "source": sources, + "value": values, + "updated": timestamps, + } + table = pyRestTable.Table(data) + return table + + +# ----------------------------------------------------------------------------- +# :author: Mark Wolfman +# :email: wolfman +# :copyright: (c) 2017-2025, UChicago Argonne, LLC +# +# Distributed under the terms of the Creative Commons Attribution 4.0 International Public License. +# +# The full license is in the file LICENSE.txt, distributed with this software. +# ----------------------------------------------------------------------------- diff --git a/apsbss/tests/conftest.py b/apsbss/tests/conftest.py index 4f2b915..e0ae341 100644 --- a/apsbss/tests/conftest.py +++ b/apsbss/tests/conftest.py @@ -54,7 +54,7 @@ def ioc(): yield cfg # tear down - if cfg.bss is not None: + if hasattr(cfg.bss, "destroy"): cfg.bss.destroy() cfg.bss = None if cfg.ioc_process is not None: diff --git a/apsbss/tests/test_apsbss_ophyd_async.py b/apsbss/tests/test_apsbss_ophyd_async.py new file mode 100644 index 0000000..b6932b3 --- /dev/null +++ b/apsbss/tests/test_apsbss_ophyd_async.py @@ -0,0 +1,39 @@ +"""Test module apsbss_ophyd.""" + +import datetime + +import pyRestTable + +from ..apsbss_ophyd_async import EpicsBssDevice +from ._core import BSS_TEST_IOC_PREFIX + + +async def test_EpicsBssDevice(ioc): + ioc.bss = EpicsBssDevice(prefix=BSS_TEST_IOC_PREFIX, name="bss") + await ioc.bss.connect(mock=False) + + child_names = [name for name, child in ioc.bss.children()] + for cpt in "esaf proposal ioc_host ioc_user status_msg".split(): + assert cpt in child_names + + await ioc.bss.status_msg.set("") + assert (await ioc.bss.status_msg.get_value()) == "" + + await ioc.bss.status_msg.set("this is a test") + assert (await ioc.bss.status_msg.get_value()) == "this is a test" + + await ioc.bss.clear() + assert (await ioc.bss.status_msg.get_value()) == "Cleared" + + table = await ioc.bss._table() + assert isinstance(table, pyRestTable.Table) + assert len(table.labels) == 3 + assert len(table.rows) >= 137 + assert len(table.rows[0]) == 3 + assert table.rows[0][0] == f"ca://{BSS_TEST_IOC_PREFIX}esaf:description" + assert table.rows[0][1] == "" + assert isinstance(table.rows[0][2], (datetime.datetime, str)) + + assert table.rows[-1][0] == f"ca://{BSS_TEST_IOC_PREFIX}status" + assert table.rows[-1][1] == "Cleared" + assert isinstance(table.rows[-1][2], (datetime.datetime, str)) diff --git a/pyproject.toml b/pyproject.toml index 189e5ef..516c4cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ dependencies = [ "bluesky", "databroker", "ophyd", + "ophyd-async", "pyRestTable", "pyyaml", "requests", @@ -64,6 +65,7 @@ dependencies = [ [project.optional-dependencies] dev = [ + "aioca", "build", "caproto", "coverage", @@ -73,6 +75,7 @@ dev = [ "pre-commit", "pyepics", "pytest", + "pytest-asyncio", "ruff", ] diff --git a/pytest.ini b/pytest.ini index ec319f0..31ff695 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,5 @@ [pytest] +asyncio_mode = auto filterwarnings = ignore:Using or importing the ABCs from:DeprecationWarning ignore:.*imp module is deprecated in favour of importlib:DeprecationWarning