Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added an Ophyd-async device to match the threaded Ophyd device. #44

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
318 changes: 318 additions & 0 deletions apsbss/apsbss_ophyd_async.py
Original file line number Diff line number Diff line change
@@ -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.
# -----------------------------------------------------------------------------
2 changes: 1 addition & 1 deletion apsbss/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions apsbss/tests/test_apsbss_ophyd_async.py
Original file line number Diff line number Diff line change
@@ -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))
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ dependencies = [
"bluesky",
"databroker",
"ophyd",
"ophyd-async",
"pyRestTable",
"pyyaml",
"requests",
Expand All @@ -64,6 +65,7 @@ dependencies = [

[project.optional-dependencies]
dev = [
"aioca",
"build",
"caproto",
"coverage",
Expand All @@ -73,6 +75,7 @@ dev = [
"pre-commit",
"pyepics",
"pytest",
"pytest-asyncio",
"ruff",
]

Expand Down
1 change: 1 addition & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -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
Expand Down