diff --git a/docs/topic_guides/index.rst b/docs/topic_guides/index.rst index 14e1f6f5..4d108acb 100644 --- a/docs/topic_guides/index.rst +++ b/docs/topic_guides/index.rst @@ -1,9 +1,9 @@ -############## -How-To Guides -############## +############ +Topic Guides +############ -These guides cover a particular topic in depth, coving material useful -to users and beamline staff. +These guides cover **a particular topic in depth**, coving material +useful to users and beamline staff. These guides do not cover the individual beamlines thoroughly. @@ -21,5 +21,6 @@ These guides do not cover the individual beamlines thoroughly. fly_scanning.rst hardware_triggering.rst instrument_registry.rst + shutters_and_filters.rst motor_positions.rst xdi_writer.rst diff --git a/docs/topic_guides/shutters_and_filters.rst b/docs/topic_guides/shutters_and_filters.rst new file mode 100644 index 00000000..abb0c24f --- /dev/null +++ b/docs/topic_guides/shutters_and_filters.rst @@ -0,0 +1,197 @@ +#################### +Shutters and Filters +#################### + +A shutter is any device that has "shutters" in its `_ophyd_labels_` +attribute. + +.. contents:: Table of Contents + :depth: 3 + +Automatic Shutter Control +========================= + +To reduce radiation does, it is useful to include plans that +automatically open and close the shutters as needed. Additionally, the +run engine should be temporarily paused (suspeneded) when shutter +permit is lost. + +Auto-Open Shutters +------------------ + +In the case of **radiation-sensitive samples**, the experiment may +require **keeping the shutter closed** except when collecting +data. Haven includes two preprocessors that can be used to +automatically open shutters that are closed: +:py:func:`~haven.preprocessors.open_shutters_decorator` and +:py:func:`~haven.preprocessors.open_shutters_wrapper`. Both the +end-station PSS shutter, and a secondary fast shutter can both be left +closed and then opened automatically only when needed for recording +data. This feature is enabled by default on plans in +:py:mod:`haven.plans` that trigger detectors (except for +:py:func:`haven.plans.record_dark_current`). :py:mod:`~haven.plans` +also includes wrapped versions of common Bluesky plans, like +:py:func:`haven.plans.scan`. + +**Fast shutters** (those with ``"fast_shutters"`` in their +`_ophyd_labels_` attribute) will be opened before each "trigger" +message emitted by a plan, and closed after the subsequent "wait" +message. The "wait" message's *group* will be tracked, ensuring that +the fast shutters will only close after all triggers have been +awaited. + +**Slow shutters** (those without ``"fast_shutters"`` in their +`_ophyd_labels_` attribute) will be opened at the start of the wrapped +plan, and closed again at the end. + +Shutters that are open at the start of the plan, or haven *allow_open* +or *allow_close* set to ``False`` will be ignored. + + +Record Ion Chamber Dark Current +------------------------------- + +The :py:func:`~haven.plans.record_dark_current` plan accepts a +sequence of shutter devices as an optional argument: *shutters*. Any +devices included will automatically be closed before measuring the +dark current, and opened again if they were initially open. + + +Suspend When Shutter Permit is Lost +----------------------------------- + +.. note:: + + This is still a work in progress. If you have an interest in this + feature, please join the discussion. + + +Personnel Safety System (PSS) Shutters +====================================== + +The PSS shutters are typically large and slow to move. The APS PSS +shutters are controlled via three PVs: + +- Open signal +- Close signal +- Beam blocking signal + +Activating the open signal directly will instruct the IOC to open the +shutter, but will return a put complete before the shutter has +actually opened. This is not useful when actuating shutters in a +Bluesky plan. As such, the PSS shutters +(:py:class:`haven.devices.shutter.PssShutter`) are **implemented as +positioners** so that :py:meth:`haven.devices.shutter.PssShutter.set` +**completes only when** the beam blocking signal reports that the shutter +is open. + +.. code-block:: python + + from haven.devices import PssShutter, ShutterState + shutter = PssShutter(prefix="S255ID-PSS:FES:", name="front_end_shutter")] + # Waits for the shutter to actually close: + await shutter.set(ShutterState.CLOSED) + +Or add the following to a **TOML file** read by the beamline startup: + +.. code:: toml + + [[ pss_shutter ]] + name = "front_end_shutter" + prefix = "S255ID-PSS:FES:" + # allow_close = true # Default + # allow_open = true # Default + +The optional arguments *allow_open* and *allow_close* control whether +the device should be allowed to open and close the shutter. Typically, +if either *allow_open* or *allow_close* are false, the shutter will be +ignored by tools that automatically actuate the shutters, like +:py:func:`~haven.preprocessors.open_shutters_wrapper` and +:py:func:`~haven.plans.record_dark_current`. + + +XIA PFCU-4 Filter Bank +===================== + +One XIA PFCU controller can control four filters in a single +4-position PF4 filter box. Two filters in one box can be combined to +produce a shutter. + +To **create a filter bank**: + +.. code-block:: python + + from haven.devices import PFCUFilterBank + filter_bank = PFCUFilterBank("255idc:pfcu0:", name="filter_bank") + +Or add the following to a **TOML file** read by the beamline startup: + +.. code-block:: toml + + [[ pfcu4 ]] + name = "filter_bank1" + prefix = "255idc:pfcu1:" + +Each :py:class:`~haven.devices.xia_pfcu.PFCUFilterBank` device is a +positioner, and can be set with a string of the bits for all +filters. For example, ``await filter_bank.set("1100")`` will close +(``"1"``) filters 0 and 1, and open (``"0"``) filters 2 and 3. The +ophyd-async device uses this to set both blades on a shutter at once. + + +XIA PFCU Filter +--------------- + +The :py:class:`~haven.devices.xia_pfcu.PFCUFilterBank` has an +attribute *filters* which holds +:py:class:`~haven.devices.xia_pfcu.PFCUFilter` instances for the +individual filters in the bank. The **key for each filter** is its +position in the filter box, starting from 0. Some **filters may be +absent** if they are used for shutters, described below. + + +.. warning:: + + A **TimeoutError** may occur when attempting to set multiple + filters on the same filter bank concurrently. The IOC will often + not process these requests properly, and one of the filters will + not move. It is recommended to move filters individually, e.g.: + + .. code-block:: python + + RE(bps.mv(filter_bank.filters[0], FilterState.IN)) + RE(bps.mv(filter_bank.filters[1], FilterState.IN)) + + instead of combining into a single move plan. + + +XIA PFCU Shutter +---------------- + +Two filters in one filter bank can be combined to produce a +shutter. Provide the indices (starting from 0) of the filters to use +when creating the filter bank: + +.. code-block:: + + filter_bank = PFCUFilterBank(..., shutters=[[3, 2]]) + +The first number listed (``3``) is the index of the filter holding the +top of the shutter, that is the filter that should be ``"In"`` to block +X-rays. The second number (``2``) is the index of the bottom +filter. **If the shutter is open when it should be closed**, consider +swapping the order of these numbers. + +The resulting :py:class:`~haven.devices.xia_pfcu.PFCUShutter` instance +is available in the *shutters* device vector, with keys based on their +order in the original *shutters* argument. The recommended way to +**actuate the shutter** is by setting it directly rather than moving +the individual filters: + +.. code-block:: python + + from haven.devices import ShutterState + + shutter = filter_bank.shutters[0] + await shutter.set(ShutterState.CLOSED) + diff --git a/src/conftest.py b/src/conftest.py index 88acf19f..660a7284 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -1,3 +1,4 @@ +import asyncio import os from pathlib import Path @@ -26,7 +27,7 @@ from haven.devices.robot import Robot from haven.devices.shutter import PssShutter from haven.devices.slits import ApertureSlits, BladeSlits -from haven.devices.xia_pfcu import PFCUFilter, PFCUFilterBank, PFCUShutter +from haven.devices.xia_pfcu import PFCUFilter, PFCUFilterBank from haven.devices.xspress import Xspress3Detector from haven.devices.xspress import add_mcas as add_xspress_mcas @@ -199,30 +200,18 @@ def aps(sim_registry): @pytest.fixture() -def xia_shutter_bank(sim_registry): - class ShutterBank(PFCUFilterBank): - shutters = DCpt( - { - "shutter_0": ( - PFCUShutter, - "", - {"top_filter": 4, "bottom_filter": 3, "labels": {"shutters"}}, - ) - } - ) - - def __new__(cls, *args, **kwargs): - return object.__new__(cls) - - FakeBank = make_fake_device(ShutterBank) - bank = FakeBank(prefix="255id:pfcu4:", name="xia_filter_bank", shutters=[[3, 4]]) +async def xia_shutter_bank(sim_registry): + bank = PFCUFilterBank( + prefix="255id:pfcu4:", name="xia_filter_bank", shutters=[[2, 3]] + ) + await bank.connect(mock=True) sim_registry.register(bank) yield bank @pytest.fixture() def xia_shutter(xia_shutter_bank): - shutter = xia_shutter_bank.shutters.shutter_0 + shutter = xia_shutter_bank.shutters[0] yield shutter @@ -242,16 +231,13 @@ def shutters(sim_registry): @pytest.fixture() -def filters(sim_registry): - FakeFilter = make_fake_device(PFCUFilter) - kw = { - "labels": {"filters"}, - } +async def filters(sim_registry): filters = [ - FakeFilter(name="Filter A", prefix="filter1", **kw), - FakeFilter(name="Filter B", prefix="filter2", **kw), + PFCUFilter(name="Filter A", prefix="filter1"), + PFCUFilter(name="Filter B", prefix="filter2"), ] [sim_registry.register(f) for f in filters] + await asyncio.gather(*(filter.connect(mock=True) for filter in filters)) return filters diff --git a/src/firefly/filters.py b/src/firefly/filters.py index 35166cfd..442f2192 100644 --- a/src/firefly/filters.py +++ b/src/firefly/filters.py @@ -15,9 +15,7 @@ def ui_filename(self): def customize_device(self): filters = beamline.devices.findall(label="filters", allow_none=True) - self.filters = sorted( - filters, key=lambda dev: (dev.material.get(), dev.thickness.get()) - ) + self.filters = sorted(filters, key=lambda dev: dev.name) def customize_ui(self): # Delete existing filter widgets diff --git a/src/firefly/tests/test_controller.py b/src/firefly/tests/test_controller.py index a5db4b2c..065b7907 100644 --- a/src/firefly/tests/test_controller.py +++ b/src/firefly/tests/test_controller.py @@ -1,6 +1,7 @@ -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest +from bluesky_queueserver_api.zmq.aio import REManagerAPI from ophyd import Device from ophyd.sim import make_fake_device from ophydregistry import Registry @@ -16,16 +17,16 @@ async def controller(qapp): controller = FireflyController() await controller.setup_instrument(load_instrument=False) - return controller + yield controller @pytest.fixture() -def ffapp(): - return MagicMock() +async def api(): + _api = REManagerAPI() + return _api -def test_prepare_queue_client(controller): - api = MagicMock() +def test_prepare_queue_client(controller, api): controller.prepare_queue_client(api=api) assert isinstance(controller._queue_client, QueueClient) @@ -107,7 +108,7 @@ async def test_load_instrument_registry(controller, qtbot, monkeypatch): """Check that the instrument registry gets created.""" assert isinstance(controller.registry, Registry) # Mock the underlying haven instrument loader - loader = AsyncMock() + loader = MagicMock() monkeypatch.setattr(firefly.controller.beamline, "load", loader) monkeypatch.setattr(controller, "prepare_queue_client", MagicMock()) # Reload the devices and see if the registry is changed @@ -132,12 +133,12 @@ def test_queue_stopped(controller): assert not controller.actions.queue_controls["stop_queue"].isChecked() -def test_autostart_changed(controller, qtbot): +async def test_autostart_changed(controller, qtbot, api): """Does the action respond to changes in the queue autostart status? """ - client = controller.prepare_queue_client(api=MagicMock()) + client = controller.prepare_queue_client(api=api) autostart_action = controller.actions.queue_settings["autostart"] autostart_action.setChecked(True) assert autostart_action.isChecked() diff --git a/src/firefly/tests/test_line_scan_window.py b/src/firefly/tests/test_line_scan_window.py index e9364db6..7b571d79 100644 --- a/src/firefly/tests/test_line_scan_window.py +++ b/src/firefly/tests/test_line_scan_window.py @@ -26,7 +26,6 @@ async def motors(sim_registry, sync_motors): @pytest.fixture() async def display(qtbot, sim_registry, sync_motors, motors, dxp, ion_chamber): - print(motors[0].name) display = LineScanDisplay() qtbot.addWidget(display) await display.update_devices(sim_registry) @@ -125,7 +124,7 @@ async def test_line_scan_plan_queued(display, monkeypatch, qtbot): # time is calculated when the selection is changed display.ui.detectors_list.selected_detectors = mock.MagicMock( - return_value=["vortex_me4", "I0"] + return_value=["vortex_me4", "I00"] ) # set up meta data @@ -135,7 +134,7 @@ async def test_line_scan_plan_queued(display, monkeypatch, qtbot): expected_item = BPlan( "rel_scan", - ["vortex_me4", "I0"], + ["vortex_me4", "I00"], "async_motor_1", 1.0, 111.0, diff --git a/src/firefly/voltmeters.py b/src/firefly/voltmeters.py index e0e56fba..c9a21b23 100644 --- a/src/firefly/voltmeters.py +++ b/src/firefly/voltmeters.py @@ -33,7 +33,7 @@ class VoltmetersDisplay(display.FireflyDisplay): def customize_ui(self): # Connect support for running the auto_gain and dark current plans - self.ui.auto_gain_button.setToolTip(haven.auto_gain.__doc__) + self.ui.auto_gain_button.setToolTip(haven.plans.auto_gain.__doc__) self.ui.auto_gain_button.clicked.connect(self.run_auto_gain) self.ui.dark_current_button.clicked.connect(self.record_dark_current) # Adjust layouts diff --git a/src/haven/__init__.py b/src/haven/__init__.py index a3065ac4..1b65a39d 100644 --- a/src/haven/__init__.py +++ b/src/haven/__init__.py @@ -50,21 +50,11 @@ recall_motor_position, save_motor_position, ) -from .plans.align_motor import align_motor, align_pitch2 # noqa: F401 -from .plans.align_slits import align_slits # noqa: F401 -from .plans.auto_gain import GainRecommender, auto_gain # noqa:F401 -from .plans.beam_properties import fit_step # noqa: F401 -from .plans.beam_properties import knife_scan # noqa: F401 -from .plans.energy_scan import energy_scan # noqa: F401 -from .plans.fly import fly_scan, grid_fly_scan # noqa: F401 -from .plans.record_dark_current import record_dark_current # noqa: F401 -from .plans.robot_transfer_sample import robot_transfer_sample # noqa: F401 -from .plans.set_energy import set_energy # noqa: F401 -from .plans.shutters import close_shutters, open_shutters # noqa: F401 -from .plans.xafs_scan import xafs_scan # noqa: F401 from .preprocessors import ( # noqa: F401 baseline_decorator, baseline_wrapper, + open_shutters_decorator, + open_shutters_wrapper, shutter_suspend_decorator, shutter_suspend_wrapper, ) diff --git a/src/haven/devices/__init__.py b/src/haven/devices/__init__.py index 36fc2038..bb37dcfe 100644 --- a/src/haven/devices/__init__.py +++ b/src/haven/devices/__init__.py @@ -4,7 +4,9 @@ from .monochromator import Monochromator # noqa: F401 from .motor import HavenMotor, Motor # noqa: F401 from .robot import Robot # noqa: F401 +from .shutter import PssShutter, ShutterState # noqa: F401 from .table import Table # noqa: F401 +from .xia_pfcu import PFCUFilter, PFCUFilterBank # noqa: F401 # ----------------------------------------------------------------------------- # :author: Mark Wolfman diff --git a/src/haven/devices/detectors/xspress.py b/src/haven/devices/detectors/xspress.py index fa58a42b..eddd31de 100644 --- a/src/haven/devices/detectors/xspress.py +++ b/src/haven/devices/detectors/xspress.py @@ -48,6 +48,7 @@ def get_deadtime(self, exposure: float) -> float: @AsyncStatus.wrap async def prepare(self, trigger_info: TriggerInfo): + print("preparing") await asyncio.gather( self._drv.num_images.set(trigger_info.total_number_of_triggers), self._drv.image_mode.set(adcore.ImageMode.MULTIPLE), @@ -56,9 +57,12 @@ async def prepare(self, trigger_info: TriggerInfo): # https://github.com/epics-modules/xspress3/issues/57 self._drv.deadtime_correction.set(False), ) + print("prepared") async def wait_for_idle(self): + print("Waiting for idle") if self._arm_status: + print("doing it") await self._arm_status async def arm(self): diff --git a/src/haven/devices/shutter.py b/src/haven/devices/shutter.py index 8a2cc29c..c20040cb 100644 --- a/src/haven/devices/shutter.py +++ b/src/haven/devices/shutter.py @@ -10,7 +10,7 @@ from ..positioner import Positioner from .signal import derived_signal_rw, epics_signal_xval -# from apstools.devices.shutters import ApsPssShutterWithStatus as Shutter +__all__ = ["PssShutter", "ShutterState"] log = logging.getLogger(__name__) @@ -49,7 +49,8 @@ def __init__( self.units = soft_signal_rw(str, initial_value="") self.precision = soft_signal_rw(int, initial_value=0) # Positioner signals for moving the shutter - self.readback = epics_signal_r(bool, f"{prefix}BeamBlockingM.VAL") + with self.add_children_as_readables(): + self.readback = epics_signal_r(bool, f"{prefix}BeamBlockingM.VAL") self.setpoint = derived_signal_rw( int, derived_from={ diff --git a/src/haven/devices/xia_pfcu.py b/src/haven/devices/xia_pfcu.py index c7488676..2b9413ab 100644 --- a/src/haven/devices/xia_pfcu.py +++ b/src/haven/devices/xia_pfcu.py @@ -5,84 +5,176 @@ """ +import logging from enum import IntEnum from typing import Sequence -from ophyd import Component as Cpt -from ophyd import DynamicDeviceComponent as DCpt -from ophyd import EpicsSignal, EpicsSignalRO -from ophyd import FormattedComponent as FCpt -from ophyd import PVPositionerIsClose -from ophyd.signal import DerivedSignal - -from .shutter import ShutterState - - -class FilterPosition(IntEnum): - OUT = 0 - IN = 1 - - -class PFCUFilter(PVPositionerIsClose): +from ophyd_async.core import ( + DeviceVector, + StandardReadable, + StrictEnum, + SubsetEnum, + soft_signal_rw, +) +from ophyd_async.epics.core import epics_signal_r, epics_signal_rw + +from haven.devices.shutter import ShutterState +from haven.devices.signal import derived_signal_r, derived_signal_rw +from haven.positioner import Positioner + +__all__ = ["PFCUFilterBank", "PFCUFilter", "PFCUShutter"] + +log = logging.getLogger(__name__) + + +class ConfigBits(StrictEnum): + ZERO = "0000" + ONE = "0001" + TWO = "0010" + THREE = "0011" + FOUR = "0100" + FIVE = "0101" + SIX = "0110" + SEVEN = "0111" + EIGHT = "1000" + NINE = "1001" + TEN = "1010" + ELEVEN = "1011" + TWELVE = "1100" + THIRTEEN = "1101" + FOURTEEN = "1110" + FIFTEEN = "1111" + + +class FilterPosition(SubsetEnum): + OUT = "Out" + IN = "In" + SHORT_CIRCUIT = "Short Circuit" + OPEN_CIRCUIT = "Open Circuit" + + +class FilterState(IntEnum): + OUT = 0 # 0b000 + IN = 1 # 0b001 + FAULT = 3 # 0b011 + UNKNOWN = 4 # 0b100 + + +class Material(SubsetEnum): + ALUMINUM = "Al" + MOLYBDENUM = "Mo" + TITANIUM = "Ti" + GLASS = "Glass" + OTHER = "Other" + + +def normalize_readback(values, readback): + return { + FilterPosition.OUT: FilterState.OUT, + FilterPosition.IN: FilterState.IN, + FilterPosition.SHORT_CIRCUIT: FilterState.FAULT, + FilterPosition.OPEN_CIRCUIT: FilterState.FAULT, + }.get(values[readback], FilterState.UNKNOWN) + + +class PFCUFilter(Positioner): """A single filter in a PFCU filter bank. E.g. 25idc:pfcu0:filter1_mat """ - material = Cpt(EpicsSignal, "_mat", kind="config") - thickness = Cpt(EpicsSignal, "_thick", kind="config") - thickness_unit = Cpt(EpicsSignal, "_thick.EGU", kind="config") - notes = Cpt(EpicsSignal, "_other", kind="config") - - setpoint = Cpt(EpicsSignal, "", kind="normal") - readback = Cpt(EpicsSignalRO, "_RBV", kind="normal") + _ophyd_labels_ = {"filters"} + + def __init__(self, prefix: str, *, name: str = ""): + with self.add_children_as_readables("config"): + self.material = epics_signal_rw(Material, f"{prefix}_mat") + self.thick = epics_signal_rw(float, f"{prefix}_thick") + self.thick_unit = epics_signal_rw(str, f"{prefix}_thick.EGU") + self.notes = epics_signal_rw(str, f"{prefix}_other") + # We need a second "private" readback to standardize the types + self._readback = epics_signal_r(FilterPosition, f"{prefix}_RBV") + with self.add_children_as_readables(): + self.readback = derived_signal_r( + int, + derived_from={"readback": self._readback}, + inverse=normalize_readback, + ) + self.setpoint = epics_signal_rw(bool, prefix) + # Just use convenient values for positioner signals since there's no real position + self.velocity = soft_signal_rw(float, initial_value=0.5) + self.units = soft_signal_rw(str, initial_value="") + self.precision = soft_signal_rw(int, initial_value=0) + super().__init__(name=name) shutter_state_map = { # (top filter, bottom filter): state - (FilterPosition.OUT, FilterPosition.IN): ShutterState.OPEN, - (FilterPosition.IN, FilterPosition.OUT): ShutterState.CLOSED, - (FilterPosition.OUT, FilterPosition.OUT): ShutterState.FAULT, - (FilterPosition.IN, FilterPosition.IN): ShutterState.FAULT, + (ShutterState.OPEN, ShutterState.CLOSED): ShutterState.OPEN, + (ShutterState.CLOSED, ShutterState.OPEN): ShutterState.CLOSED, + (ShutterState.OPEN, ShutterState.OPEN): ShutterState.FAULT, + (ShutterState.CLOSED, ShutterState.CLOSED): ShutterState.FAULT, } -class PFCUShutterSignal(DerivedSignal): - def _mask(self, pos): - num_filters = 4 - return 1 << (num_filters - pos) +class PFCUFilterBank(StandardReadable): + """A XIA PFCU4 bank of four filters and/or shutters. - def top_mask(self): - return self._mask(self.parent._top_filter) + Filters are indexed from 0, even though the EPICS support indexes + from 1. - def bottom_mask(self): - return self._mask(self.parent._bottom_filter) + Parameters + ========== + shutters + Sets of filter numbers to use as shutters. Each entry in + *shutters* should be a tuple like (top, bottom) where the first + filter (top) is open when the filter is set to "out". - def forward(self, value): - """Convert shutter state to filter bank state.""" - # Bit masking to set both blades together - old_bits = self.derived_from.parent.readback.get(as_string=False) - if value == ShutterState.OPEN: - open_bits = self.bottom_mask() - close_bits = self.top_mask() - elif value == ShutterState.CLOSED: - close_bits = self.bottom_mask() - open_bits = self.top_mask() - else: - raise ValueError(bin(value)) - new_bits = (old_bits | open_bits) & (0b1111 - close_bits) - return new_bits + """ - def inverse(self, value): - """Convert filter bank state to shutter state.""" - # Determine which filters are open and closed - top_position = int(bool(value & self.top_mask())) - bottom_position = int(bool(value & self.bottom_mask())) - result = shutter_state_map[(top_position, bottom_position)] - return result + num_slots: int + def __init__( + self, + prefix: str, + *, + name: str = "", + num_slots: int = 4, + shutters: Sequence[tuple[int, int]] = [], + ): + all_shutters = [v for shutter in shutters for v in shutter] + is_in_bounds = [0 < shtr < num_slots for shtr in all_shutters] + if not all(is_in_bounds): + raise ValueError( + f"Shutter indices {shutters} for filterbank {name=} must be in the range (0, {num_slots})." + ) + self.num_slots = num_slots + # Positioner signals + self.setpoint = epics_signal_rw(ConfigBits, f"{prefix}config") + with self.add_children_as_readables(): + self.readback = epics_signal_r(ConfigBits, f"{prefix}config_RBV") + # Sort out filters vs shutters + filters = [idx for idx in range(num_slots) if idx not in all_shutters] + with self.add_children_as_readables(): + # Create shutters + self.shutters = DeviceVector( + { + idx: PFCUShutter( + prefix=prefix, + top_filter=top, + bottom_filter=btm, + filter_bank=self, + ) + for idx, (top, btm) in enumerate(shutters) + } + ) + # Create filters + self.filters = DeviceVector( + {idx: PFCUFilter(prefix=f"{prefix}filter{idx+1}") for idx in filters} + ) + super().__init__(name=name) -class PFCUShutter(PVPositionerIsClose): + +class PFCUShutter(Positioner): """A shutter made of two PFCU4 filters. For faster operation, both filters will be moved at the same @@ -92,103 +184,93 @@ class PFCUShutter(PVPositionerIsClose): Parameters ========== top_filter - The PV for the filter that is open when the filter is set to + The index of the filter that is open when the filter is set to "out". bottom_filter - The PV for the filter that is open when the filter is set to + The index of the filter that is open when the filter is set to "in". + filter_bank + The parent filter bank that this shutter is a part of. This + *filter_bank*'s *setpoint* and *readback* signals will be used + for actuating both shutter blades together. """ - readback = Cpt(PFCUShutterSignal, derived_from="parent.parent.readback") - setpoint = Cpt(PFCUShutterSignal, derived_from="parent.parent.setpoint") - - top_filter = FCpt(PFCUFilter, "{self.prefix}filter{self._top_filter}") - bottom_filter = FCpt(PFCUFilter, "{self.prefix}filter{self._bottom_filter}") + _ophyd_labels_ = {"shutters", "fast_shutters"} def __init__( self, - prefix: str = "", + prefix: str, *, - name: str, - top_filter: str, - bottom_filter: str, - labels={"shutters"}, + name: str = "", + top_filter: int, + bottom_filter: int, + filter_bank: PFCUFilterBank, **kwargs, ): - self._top_filter = top_filter - self._bottom_filter = bottom_filter + self._top_filter_idx = top_filter + self._bottom_filter_idx = bottom_filter + with self.add_children_as_readables(): + self.bottom_filter = PFCUFilter(prefix=f"{prefix}filter{bottom_filter+1}") + self.top_filter = PFCUFilter(prefix=f"{prefix}filter{top_filter+1}") + # Set up the positioner signals + parent_signals = { + "setpoint": filter_bank.setpoint, + "readback": filter_bank.readback, + } + self.setpoint = derived_signal_rw( + int, derived_from=parent_signals, forward=self.forward, inverse=self.inverse + ) + with self.add_children_as_readables(): + self.readback = derived_signal_r( + int, derived_from=parent_signals, inverse=self.inverse + ) + # Just use convenient values for positioner signals since there's no real position + self.velocity = soft_signal_rw(float, initial_value=0.5) + self.units = soft_signal_rw(str, initial_value="") + self.precision = soft_signal_rw(int, initial_value=0) super().__init__( - prefix=prefix, name=name, - limits=(ShutterState.OPEN, ShutterState.CLOSED), - labels=labels, + put_complete=True, **kwargs, ) + async def forward(self, value, setpoint, readback): + """Convert shutter state to filter bank state.""" + # Bit masking to set both blades together + readback_value = await readback.get_value() + num_bits = len(readback_value) + old_bits = int(readback_value, 2) + if value == ShutterState.OPEN: + open_bits = self.bottom_mask() + close_bits = self.top_mask() + elif value == ShutterState.CLOSED: + close_bits = self.bottom_mask() + open_bits = self.top_mask() + else: + raise ValueError(bin(value)) + new_bits = (old_bits | open_bits) & (0b1111 - close_bits) + log.debug(f"{old_bits=:b}, {open_bits=:b}, {close_bits=:b}, {new_bits=:b}") + return {setpoint: f"{new_bits:0b}".zfill(num_bits)} -class PFCUFilterBank(PVPositionerIsClose): - """Parameters - ========== - shutters - Sets of filter numbers to use as shutters. Each entry in - *shutters* should be a tuple like (top, bottom) where the first - filter (top) is open when the filter is set to "out". - - """ - - num_slots: int = 4 + def inverse(self, values, readback, **kwargs): + """Convert filter bank state to shutter state.""" + bits = int(values[readback], 2) + # Determine which filters are open and closed + top_position = int(bool(bits & self.top_mask())) + bottom_position = int(bool(bits & self.bottom_mask())) + result = shutter_state_map[(top_position, bottom_position)] + return result - readback = Cpt(EpicsSignalRO, "config_RBV", kind="normal") - setpoint = Cpt(EpicsSignal, "config", kind="normal") + def _mask(self, pos): + num_filters = 4 + return 1 << (num_filters - pos - 1) - def __new__(cls, prefix: str, name: str, shutters=[], **kwargs): - # Determine which filters to use as filters vs shutters - all_shutters = [v for shutter in shutters for v in shutter] - filters = [ - idx for idx in range(1, cls.num_slots + 1) if idx not in all_shutters - ] - # Create device components for filters and shutters - comps = { - "shutters": DCpt( - { - f"shutter_{idx}": ( - PFCUShutter, - "", - { - "top_filter": top, - "bottom_filter": bottom, - "labels": {"shutters"}, - }, - ) - for idx, (top, bottom) in enumerate(shutters) - } - ), - "filters": DCpt( - { - f"filter{idx}": ( - PFCUFilter, - f"filter{idx}", - {"labels": {"filters"}}, - ) - for idx in filters - } - ), - } - # Create any new child class with shutters and filters - new_cls = type(cls.__name__, (PFCUFilterBank,), comps) - return super().__new__(new_cls) + def top_mask(self): + return self._mask(self._top_filter_idx) - def __init__( - self, - prefix: str = "", - *, - name: str, - shutters: Sequence = [], - labels: str = {"filter_banks"}, - **kwargs, - ): - super().__init__(prefix=prefix, name=name, labels=labels, **kwargs) + def bottom_mask(self): + return self._mask(self._bottom_filter_idx) # ----------------------------------------------------------------------------- diff --git a/src/haven/iconfig_testing.toml b/src/haven/iconfig_testing.toml index 2b9651e1..361a9273 100644 --- a/src/haven/iconfig_testing.toml +++ b/src/haven/iconfig_testing.toml @@ -302,4 +302,4 @@ prefix = "255idc:pfcu0:" [[ pfcu4 ]] name = "filter_bank1" prefix = "255idc:pfcu1:" -shutters = [[3, 4]] +shutters = [[2, 3]] diff --git a/src/haven/instrument.py b/src/haven/instrument.py index a894e000..aa0e9a6d 100644 --- a/src/haven/instrument.py +++ b/src/haven/instrument.py @@ -85,18 +85,19 @@ def load( beamline = HavenInstrument( { # Ophyd-async devices - "ion_chamber": IonChamber, + "aerotech_stage": AerotechStage, + "camera": AravisDetector, + "energy": EnergyPositioner, "high_heat_load_mirror": HighHeatLoadMirror, + "ion_chamber": IonChamber, "kb_mirrors": KBMirrors, - "xy_stage": XYStage, - "table": Table, - "aerotech_stage": AerotechStage, "motor": Motor, - "energy": EnergyPositioner, - "sim_detector": SimDetector, - "camera": AravisDetector, + "pfcu4": PFCUFilterBank, "pss_shutter": PssShutter, + "sim_detector": SimDetector, + "table": Table, "xspress3": Xspress3Detector, + "xy_stage": XYStage, # Threaded ophyd devices "blade_slits": BladeSlits, "aperture_slits": ApertureSlits, @@ -104,10 +105,34 @@ def load( "power_supply": NHQ203MChannel, "synchrotron": ApsMachine, "robot": Robot, - "pfcu4": PFCUFilterBank, # <-- fails if mocked "dxp": make_dxp_device, "beamline_manager": BeamlineManager, "area_detector": make_area_detector, "scaler": MultiChannelScaler, }, ) + +# ----------------------------------------------------------------------------- +# :author: Mark Wolfman +# :email: wolfman@anl.gov +# :copyright: Copyright © 2024, UChicago Argonne, LLC +# +# Distributed under the terms of the 3-Clause BSD License +# +# The full license is in the file LICENSE, distributed with this software. +# +# DISCLAIMER +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# ----------------------------------------------------------------------------- diff --git a/src/haven/ipython_startup.ipy b/src/haven/ipython_startup.ipy index 071de06a..d93aec1f 100644 --- a/src/haven/ipython_startup.ipy +++ b/src/haven/ipython_startup.ipy @@ -5,11 +5,11 @@ import time import databroker # noqa: F401 import matplotlib.pyplot as plt # noqa: F401 from bluesky import plan_stubs as bps # noqa: F401 +from bluesky.plan_stubs import mv, mvr, rd # noqa: F401 from bluesky import plans as bp # noqa: F401 from bluesky import preprocessors as bpp # noqa: F401 from bluesky import suspenders # noqa: F401 from bluesky.callbacks.best_effort import BestEffortCallback # noqa: F401 -from bluesky.plan_stubs import mv, mvr, rd # noqa: F401 from bluesky.run_engine import RunEngine, call_in_bluesky_event_loop # noqa: F401 from bluesky.simulators import summarize_plan # noqa: F401 from ophyd_async.core import NotConnected @@ -22,6 +22,7 @@ from rich.panel import Panel from rich.theme import Theme import haven # noqa: F401 +from haven import plans # noqa: F401 logging.basicConfig(level=logging.WARNING) @@ -34,9 +35,6 @@ RE = haven.run_engine( use_bec=False, ) -# Add metadata to the run engine -RE.preprocessors.append(haven.preprocessors.inject_haven_md_wrapper) - # Import some ophyd-async stuff # NB: This has to be after the run engine setup # or else ipython gets stuck and vanilla ophyd @@ -83,9 +81,9 @@ motd = ( " ┃ —or—\n" " ┗━ [code]m2 = devices['sim_motor_2'][/]\n" "\n" - "[bold]Bluesky plans and plan-stubs[/bold] are available as " - "[italic]bp[/] and [italic]bps[/] respectively.\n" - " ┗━ [code]plan = bps.mv(m2, 2)[/]\n" + "[bold]Haven plans and plan-stubs[/bold] are available as " + "[italic]plans[/].\n" + " ┗━ [code]plan = plans.xafs_scan(ion_chambers, -50, 100, 1, E0=8333)[/]\n" "\n" "The [bold]RunEngine[/bold] is available as [italic]RE[/italic].\n" " ┗━ [code]RE(bps.mv(m2, 2))[/code]\n" diff --git a/src/haven/plans/__init__.py b/src/haven/plans/__init__.py index 8027dbc5..b1b47d09 100644 --- a/src/haven/plans/__init__.py +++ b/src/haven/plans/__init__.py @@ -1,4 +1,73 @@ -"""Bluesky plans specific to spectroscopy.""" +"""Bluesky plans specific to spectroscopy. + +Includes some standard bluesky plans with decorators. + +""" + +import bluesky.plans as bp + +from haven.instrument import beamline +from haven.preprocessors import ( + baseline_decorator, + open_shutters_decorator, + shutter_suspend_decorator, +) + +from ._align_motor import align_motor +from ._auto_gain import auto_gain +from ._energy_scan import energy_scan +from ._fly import fly_scan, grid_fly_scan +from ._record_dark_current import record_dark_current # noqa: F401 +from ._robot_transfer_sample import robot_transfer_sample # noqa: F401 +from ._set_energy import set_energy # noqa: F401 +from ._shutters import close_shutters, open_shutters # noqa: F401 +from ._xafs_scan import xafs_scan + + +def chain(*decorators): + """Chain several decorators together into one decorator. + + Will be applied in reverse order, so the first item in *decorators* will + be the outermost decorator. + + """ + + def decorator(f): + for d in reversed(decorators): + f = d(f) + return f + + return decorator + + +all_decorators = chain( + shutter_suspend_decorator(), open_shutters_decorator(), baseline_decorator() +) + +# Apply decorators to Haven plans +align_motor = all_decorators(align_motor) +auto_gain = all_decorators(auto_gain) +energy_scan = all_decorators(energy_scan) +fly_scan = baseline_decorator()(fly_scan) +grid_fly_scan = baseline_decorator()(grid_fly_scan) +xafs_scan = all_decorators(xafs_scan) + +# Apply all_decorators to standard Bluesky plans +count = all_decorators(bp.count) +grid_scan = all_decorators(bp.grid_scan) +list_scan = all_decorators(bp.list_scan) +rel_grid_scan = all_decorators(bp.rel_grid_scan) +rel_list_scan = all_decorators(bp.rel_list_scan) +rel_scan = all_decorators(bp.rel_scan) +scan = all_decorators(bp.scan) +scan_nd = all_decorators(bp.scan_nd) + +# Remove foreign imports +del beamline +del open_shutters_decorator +del baseline_decorator +del shutter_suspend_decorator +del bp # ----------------------------------------------------------------------------- # :author: Mark Wolfman diff --git a/src/haven/plans/align_motor.py b/src/haven/plans/_align_motor.py similarity index 100% rename from src/haven/plans/align_motor.py rename to src/haven/plans/_align_motor.py diff --git a/src/haven/plans/align_slits.py b/src/haven/plans/_align_slits.py similarity index 100% rename from src/haven/plans/align_slits.py rename to src/haven/plans/_align_slits.py diff --git a/src/haven/plans/auto_gain.py b/src/haven/plans/_auto_gain.py similarity index 100% rename from src/haven/plans/auto_gain.py rename to src/haven/plans/_auto_gain.py diff --git a/src/haven/plans/beam_properties.py b/src/haven/plans/_beam_properties.py similarity index 100% rename from src/haven/plans/beam_properties.py rename to src/haven/plans/_beam_properties.py diff --git a/src/haven/plans/energy_scan.py b/src/haven/plans/_energy_scan.py similarity index 100% rename from src/haven/plans/energy_scan.py rename to src/haven/plans/_energy_scan.py diff --git a/src/haven/plans/fly.py b/src/haven/plans/_fly.py similarity index 99% rename from src/haven/plans/fly.py rename to src/haven/plans/_fly.py index 0580ef77..38c116af 100644 --- a/src/haven/plans/fly.py +++ b/src/haven/plans/_fly.py @@ -24,8 +24,6 @@ from ophyd_async.core import TriggerInfo from ophyd_async.epics.motor import FlyMotorInfo -from ..preprocessors import baseline_decorator - __all__ = ["fly_scan", "grid_fly_scan"] @@ -157,7 +155,6 @@ def fly_line_scan(detectors: list, *args, num, dwell_time): yield from bps.collect(flyer_) -@baseline_decorator() def fly_scan( detectors: Sequence[FlyerInterface], *args, @@ -199,7 +196,6 @@ def fly_scan( motors = args[0::3] starts = args[1::3] stops = args[2::3] - devices = [*motors, *detectors] # Prepare metadata representation of the motor arguments md_args = zip([repr(m) for m in motors], starts, stops) md_args = tuple(obj for m, start, stop in md_args for obj in [m, start, stop]) @@ -231,7 +227,6 @@ def fly_scan( yield from line_scan -# @baseline_decorator() def grid_fly_scan( detectors: Sequence[FlyerInterface], *args, diff --git a/src/haven/plans/record_dark_current.py b/src/haven/plans/_record_dark_current.py similarity index 98% rename from src/haven/plans/record_dark_current.py rename to src/haven/plans/_record_dark_current.py index e790cbf5..eab04101 100644 --- a/src/haven/plans/record_dark_current.py +++ b/src/haven/plans/_record_dark_current.py @@ -7,7 +7,7 @@ from ..devices.shutter import ShutterState from ..instrument import beamline -from .shutters import close_shutters, open_shutters +from ._shutters import close_shutters, open_shutters def count_is_complete(*, old_value, value, **kwargs): diff --git a/src/haven/plans/robot_transfer_sample.py b/src/haven/plans/_robot_transfer_sample.py similarity index 100% rename from src/haven/plans/robot_transfer_sample.py rename to src/haven/plans/_robot_transfer_sample.py diff --git a/src/haven/plans/set_energy.py b/src/haven/plans/_set_energy.py similarity index 100% rename from src/haven/plans/set_energy.py rename to src/haven/plans/_set_energy.py diff --git a/src/haven/plans/shutters.py b/src/haven/plans/_shutters.py similarity index 100% rename from src/haven/plans/shutters.py rename to src/haven/plans/_shutters.py diff --git a/src/haven/plans/xafs_scan.py b/src/haven/plans/_xafs_scan.py similarity index 97% rename from src/haven/plans/xafs_scan.py rename to src/haven/plans/_xafs_scan.py index d280bdc1..40068f32 100644 --- a/src/haven/plans/xafs_scan.py +++ b/src/haven/plans/_xafs_scan.py @@ -9,12 +9,8 @@ from .. import exceptions from ..energy_ranges import ERange, KRange, energy_to_wavenumber, merge_ranges -from ..preprocessors import baseline_decorator from ..typing import DetectorList -from .energy_scan import energy_scan - -log = logging.getLogger(__name__) - +from ._energy_scan import energy_scan log = logging.getLogger(__name__) @@ -28,8 +24,6 @@ def chunks(lst, n): yield lst[i : i + n] # noqa: E203 -# @shutter_suspend_decorator() -@baseline_decorator() def xafs_scan( E_min: float, *E_params: Sequence[float], diff --git a/src/haven/positioner.py b/src/haven/positioner.py index 6d357c67..2cb69120 100644 --- a/src/haven/positioner.py +++ b/src/haven/positioner.py @@ -4,7 +4,7 @@ from functools import partial import numpy as np -from bluesky.protocols import Movable, Stoppable +from bluesky.protocols import Locatable, Location, Movable, Stoppable from ophyd_async.core import ( CALCULATE_TIMEOUT, DEFAULT_TIMEOUT, @@ -19,7 +19,7 @@ log = logging.getLogger(__name__) -class Positioner(StandardReadable, Movable, Stoppable): +class Positioner(StandardReadable, Locatable, Movable, Stoppable): """A positioner that has separate setpoint and readback signals. When set, the Positioner **monitors the state of the move** using @@ -73,6 +73,16 @@ def watch_done( log.debug("Setting done_event") done_event.set() + async def locate(self) -> Location[int]: + setpoint, readback = await asyncio.gather( + self.setpoint.get_value(), self.readback.get_value() + ) + location: Location = { + "setpoint": setpoint, + "readback": readback, + } + return location + @WatchableAsyncStatus.wrap async def set( self, diff --git a/src/haven/preprocessors/__init__.py b/src/haven/preprocessors/__init__.py new file mode 100644 index 00000000..ff8c437a --- /dev/null +++ b/src/haven/preprocessors/__init__.py @@ -0,0 +1,7 @@ +from .baseline import baseline_decorator, baseline_wrapper # noqa: F401 +from .inject_metadata import inject_haven_md_wrapper # noqa: F401 +from .open_shutters import open_shutters_decorator, open_shutters_wrapper # noqa: F401 +from .shutter_suspender import ( # noqa: F401 + shutter_suspend_decorator, + shutter_suspend_wrapper, +) diff --git a/src/haven/preprocessors/baseline.py b/src/haven/preprocessors/baseline.py new file mode 100644 index 00000000..adbc5d9b --- /dev/null +++ b/src/haven/preprocessors/baseline.py @@ -0,0 +1,55 @@ +import logging +from typing import Sequence, Union # , Iterable + +from bluesky.preprocessors import baseline_wrapper as bluesky_baseline_wrapper +from bluesky.utils import make_decorator + +from haven.instrument import beamline + +log = logging.getLogger() + + +def baseline_wrapper( + plan, + devices: Union[Sequence, str] = [ + "motors", + "power_supplies", + "xray_sources", + "APS", + "baseline", + ], + name: str = "baseline", +): + bluesky_baseline_wrapper.__doc__ + # Resolve devices + devices = beamline.devices.findall(devices, allow_none=True) + yield from bluesky_baseline_wrapper(plan=plan, devices=devices, name=name) + + +baseline_decorator = make_decorator(baseline_wrapper) + + +# ----------------------------------------------------------------------------- +# :author: Mark Wolfman +# :email: wolfman@anl.gov +# :copyright: Copyright © 2023, UChicago Argonne, LLC +# +# Distributed under the terms of the 3-Clause BSD License +# +# The full license is in the file LICENSE, distributed with this software. +# +# DISCLAIMER +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# ----------------------------------------------------------------------------- diff --git a/src/haven/preprocessors.py b/src/haven/preprocessors/inject_metadata.py similarity index 64% rename from src/haven/preprocessors.py rename to src/haven/preprocessors/inject_metadata.py index d5c3c18f..2ca49187 100644 --- a/src/haven/preprocessors.py +++ b/src/haven/preprocessors/inject_metadata.py @@ -1,46 +1,22 @@ -"""Tools for modifying plans and data streams as they are generated.""" - import getpass import logging import os import socket import warnings from collections import ChainMap -from typing import Sequence, Union # , Iterable import epics import pkg_resources -from bluesky.preprocessors import baseline_wrapper as bluesky_baseline_wrapper -from bluesky.preprocessors import finalize_wrapper, msg_mutator - -# from bluesky.suspenders import SuspendBoolLow -from bluesky.utils import Msg, make_decorator +from bluesky.preprocessors import msg_mutator -from . import __version__ as haven_version -from ._iconfig import load_config -from .exceptions import ComponentNotFound -from .instrument import beamline +from haven import __version__ as haven_version +from haven._iconfig import load_config +from haven.exceptions import ComponentNotFound +from haven.instrument import beamline log = logging.getLogger() -def baseline_wrapper( - plan, - devices: Union[Sequence, str] = [ - "motors", - "power_supplies", - "xray_sources", - "APS", - "baseline", - ], - name: str = "baseline", -): - bluesky_baseline_wrapper.__doc__ - # Resolve devices - devices = beamline.devices.findall(devices, allow_none=True) - yield from bluesky_baseline_wrapper(plan=plan, devices=devices, name=name) - - def get_version(pkg_name): return pkg_resources.get_distribution(pkg_name).version @@ -134,57 +110,6 @@ def _inject_md(msg): return (yield from msg_mutator(plan, _inject_md)) -def shutter_suspend_wrapper(plan, shutter_signals=None): - """ - Install suspenders to the RunEngine, and remove them at the end. - - Parameters - ---------- - plan : iterable or iterator - a generator, list, or similar containing `Msg` objects - suspenders : suspender or list of suspenders - Suspenders to use for the duration of the wrapper - - Yields - ------ - msg : Msg - messages from plan, with 'install_suspender' and 'remove_suspender' - messages inserted and appended - """ - if shutter_signals is None: - shutters = beamline.devices.findall("shutters", allow_none=True) - shutter_signals = [s.pss_state for s in shutters] - # Create a suspender for each shutter - suspenders = [] - - ################################################### - # Temporarily disabled for technical commissioning - ################################################### - # for sig in shutter_signals: - # suspender = SuspendBoolLow(sig, sleep=3.0) - # suspenders.append(suspender) - # if not isinstance(suspenders, Iterable): - # suspenders = [suspenders] - - def _install(): - for susp in suspenders: - yield Msg("install_suspender", None, susp) - - def _remove(): - for susp in suspenders: - yield Msg("remove_suspender", None, susp) - - def _inner_plan(): - yield from _install() - return (yield from plan) - - return (yield from finalize_wrapper(_inner_plan(), _remove())) - - -baseline_decorator = make_decorator(baseline_wrapper) -shutter_suspend_decorator = make_decorator(shutter_suspend_wrapper) - - # ----------------------------------------------------------------------------- # :author: Mark Wolfman # :email: wolfman@anl.gov diff --git a/src/haven/preprocessors/open_shutters.py b/src/haven/preprocessors/open_shutters.py new file mode 100644 index 00000000..42c531ec --- /dev/null +++ b/src/haven/preprocessors/open_shutters.py @@ -0,0 +1,131 @@ +from bluesky import plan_stubs as bps +from bluesky.preprocessors import finalize_wrapper +from bluesky.utils import make_decorator +from ophydregistry import Registry + +from haven.devices.shutter import ShutterState +from haven.instrument import beamline + +__all__ = ["open_shutters_wrapper", "open_shutters_decorator"] + + +def _can_open(shutter): + return getattr(shutter, "allow_open", True) and getattr( + shutter, "allow_close", True + ) + + +def _set_shutters(shutters, state: int): + """A plan stub that sets all shutters to the given state.""" + mv_args = [val for shutter in shutters for val in (shutter, state)] + if len(mv_args) > 0: + return (yield from bps.mv(*mv_args)) + else: + yield from bps.null() + + +def open_shutters_wrapper(plan, registry: Registry | None = None): + """Wrapper for Bluesky plans that opens and closes shutters as needed. + + Only shutters that are closed at the start of the plan are + included. + + Shutters are split into two categories. **Fast shutters** (with + the ophyd label ``"fast_shutters"``) will be **opened before a new + detector trigger**, and closed after the trigger is awaited. All + other shutters will be **opened at the start of the run**. Both + categories will be closed at the end of the run. + + Parameters + ========== + plan + The Bluesky plan instance to decorate. + registry + An ophyd-registry in which to look for shutters. + + """ + if registry is None: + registry = beamline.devices + # Get a list of shutters that could be opened and closed + all_shutters = registry.findall(label="shutters", allow_none=True) + all_shutters = [shtr for shtr in all_shutters if _can_open(shtr)] + # Check for closed shutters (open shutters just stay open) + shutters_to_open = [] + for shutter in all_shutters: + initial_state = yield from bps.rd(shutter) + if initial_state == ShutterState.CLOSED: + shutters_to_open.append(shutter) + # Organize the shutters into fast and slow + fast_shutters = registry.findall(label="fast_shutters", allow_none=True) + fast_shutters = [shtr for shtr in shutters_to_open if shtr in fast_shutters] + slow_shutters = [shtr for shtr in shutters_to_open if shtr not in fast_shutters] + # Open shutters + yield from _set_shutters(slow_shutters, ShutterState.OPEN) + # Add the wrapper for opening fast shutters at every trigger + new_plan = open_fast_shutters_wrapper(plan, fast_shutters) + # Add a wrapper to close all the shutters once the measurement is done + close_shutters = _set_shutters(shutters_to_open, ShutterState.CLOSED) + new_plan = finalize_wrapper(new_plan, close_shutters) + # Execute the wrapped plan + return_val = yield from new_plan + return return_val + + +def open_fast_shutters_wrapper(plan, shutters): + """Open and close each shutter when encountering a detector trigger.""" + response = None + open_groups = set() + while True: + # Loop through the messages and get the next one in the queue + try: + msg = plan.send(response) + except StopIteration as exc: + return_val = exc.value + break + # Check for "trigger" messages to open shutters + if msg.command == "trigger": + if len(open_groups) == 0: + yield from _set_shutters(shutters, ShutterState.OPEN) + open_groups.add(msg.kwargs.get("group", None)) + # Emit the actual intended message + response = yield msg + # Check for "wait" messages to close shutters + if msg.command == "wait": + try: + open_groups.remove(msg.kwargs.get("group", None)) + except KeyError: + # Probably waiting on a group with no triggers + pass + else: + if len(open_groups) == 0: + yield from _set_shutters(shutters, ShutterState.CLOSED) + return return_val + + +open_shutters_decorator = make_decorator(open_shutters_wrapper) + + +# ----------------------------------------------------------------------------- +# :author: Mark Wolfman +# :email: wolfman@anl.gov +# :copyright: Copyright © 2024, UChicago Argonne, LLC +# +# Distributed under the terms of the 3-Clause BSD License +# +# The full license is in the file LICENSE, distributed with this software. +# +# DISCLAIMER +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# ----------------------------------------------------------------------------- diff --git a/src/haven/preprocessors/shutter_suspender.py b/src/haven/preprocessors/shutter_suspender.py new file mode 100644 index 00000000..d02498a4 --- /dev/null +++ b/src/haven/preprocessors/shutter_suspender.py @@ -0,0 +1,86 @@ +import logging + +from bluesky.preprocessors import finalize_wrapper + +# from bluesky.suspenders import SuspendBoolLow +from bluesky.utils import Msg, make_decorator + +from haven.instrument import beamline + +log = logging.getLogger() + + +def shutter_suspend_wrapper(plan, shutter_signals=None): + """ + Install suspenders to the RunEngine, and remove them at the end. + + Parameters + ---------- + plan : iterable or iterator + a generator, list, or similar containing `Msg` objects + suspenders : suspender or list of suspenders + Suspenders to use for the duration of the wrapper + + Yields + ------ + msg : Msg + messages from plan, with 'install_suspender' and 'remove_suspender' + messages inserted and appended + """ + if shutter_signals is None: + shutters = beamline.devices.findall("shutters", allow_none=True) + shutter_signals = [s.pss_state for s in shutters] + # Create a suspender for each shutter + suspenders = [] + + ################################################### + # Temporarily disabled for technical commissioning + ################################################### + # for sig in shutter_signals: + # suspender = SuspendBoolLow(sig, sleep=3.0) + # suspenders.append(suspender) + # if not isinstance(suspenders, Iterable): + # suspenders = [suspenders] + + def _install(): + for susp in suspenders: + yield Msg("install_suspender", None, susp) + + def _remove(): + for susp in suspenders: + yield Msg("remove_suspender", None, susp) + + def _inner_plan(): + yield from _install() + return (yield from plan) + + return (yield from finalize_wrapper(_inner_plan(), _remove())) + + +shutter_suspend_decorator = make_decorator(shutter_suspend_wrapper) + + +# ----------------------------------------------------------------------------- +# :author: Mark Wolfman +# :email: wolfman@anl.gov +# :copyright: Copyright © 2023, UChicago Argonne, LLC +# +# Distributed under the terms of the 3-Clause BSD License +# +# The full license is in the file LICENSE, distributed with this software. +# +# DISCLAIMER +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# ----------------------------------------------------------------------------- diff --git a/src/haven/tests/test_align_motor.py b/src/haven/tests/test_align_motor.py index bd11e51f..75a719cb 100644 --- a/src/haven/tests/test_align_motor.py +++ b/src/haven/tests/test_align_motor.py @@ -3,7 +3,7 @@ from bluesky.callbacks.best_effort import BestEffortCallback from ophyd import sim -from haven import align_motor +from haven.plans import align_motor # from run_engine import RunEngineStub diff --git a/src/haven/tests/test_plans.py b/src/haven/tests/test_align_slits.py similarity index 98% rename from src/haven/tests/test_plans.py rename to src/haven/tests/test_align_slits.py index 5b3b607b..91e7dd9f 100644 --- a/src/haven/tests/test_plans.py +++ b/src/haven/tests/test_align_slits.py @@ -9,7 +9,7 @@ import pytest from ophyd import sim -from haven import align_slits +from haven.plans._align_slits import align_slits def test_align_slits(RE): diff --git a/src/haven/tests/test_auto_gain_plan.py b/src/haven/tests/test_auto_gain_plan.py index 7ebfb348..dfec7eee 100644 --- a/src/haven/tests/test_auto_gain_plan.py +++ b/src/haven/tests/test_auto_gain_plan.py @@ -5,8 +5,7 @@ import pytest from bluesky_adaptive.recommendations import NoRecommendation -from haven import GainRecommender, auto_gain -from haven.plans import auto_gain as auto_gain_module +from haven.plans import _auto_gain, auto_gain def test_plan_recommendations(ion_chamber): @@ -36,17 +35,17 @@ async def test_plan_prefer_arg(ion_chamber, monkeypatch, prefer, target_volts): queue = Queue() queue.put({ion_chamber.preamp.gain_level.name: 12}) queue.put(None) - monkeypatch.setattr(auto_gain_module, "GainRecommender", MagicMock()) + monkeypatch.setattr(_auto_gain, "GainRecommender", MagicMock()) plan = auto_gain(ion_chambers=[ion_chamber], queue=queue, prefer=prefer) msgs = list(plan) - auto_gain_module.GainRecommender.assert_called_with( + _auto_gain.GainRecommender.assert_called_with( volts_min=0.5, volts_max=4.5, target_volts=target_volts ) @pytest.fixture() def recommender(): - recc = GainRecommender() + recc = _auto_gain.GainRecommender() return recc @@ -183,7 +182,7 @@ def test_recommender_no_solution(recommender): @pytest.mark.parametrize("target_volts,gain", [(0.5, 10), (2.5, 9), (4.5, 8)]) def test_recommender_correct_solution(target_volts, gain): """If the gain profile goes from too low to too high in one step, what should we report?""" - recommender = GainRecommender(target_volts=target_volts) + recommender = _auto_gain.GainRecommender(target_volts=target_volts) gains = [[7], [8], [9], [10], [11]] volts = [[5.2], [4.1], [2.7], [1.25], [0.4]] recommender.tell_many(gains, volts) diff --git a/src/haven/tests/test_beam_properties.py b/src/haven/tests/test_beam_properties.py index 6095a485..46c3b637 100644 --- a/src/haven/tests/test_beam_properties.py +++ b/src/haven/tests/test_beam_properties.py @@ -2,7 +2,7 @@ import pytest from ophyd.sim import det1, det2, motor1 -from haven.plans.beam_properties import fit_step, knife_scan +from haven.plans._beam_properties import fit_step, knife_scan # @pytest.mark.xfail() diff --git a/src/haven/tests/test_energy_xafs_scan.py b/src/haven/tests/test_energy_xafs_scan.py index 3f011736..4b204125 100644 --- a/src/haven/tests/test_energy_xafs_scan.py +++ b/src/haven/tests/test_energy_xafs_scan.py @@ -2,7 +2,8 @@ import pytest from ophyd import sim -from haven import KRange, energy_scan, xafs_scan +from haven.energy_ranges import KRange +from haven.plans import energy_scan, xafs_scan @pytest.fixture() diff --git a/src/haven/tests/test_fly_plans.py b/src/haven/tests/test_fly_plans.py index 7e59b448..1e30693d 100644 --- a/src/haven/tests/test_fly_plans.py +++ b/src/haven/tests/test_fly_plans.py @@ -6,7 +6,9 @@ from ophyd import sim from ophyd_async.epics.motor import Motor -from haven.plans.fly import FlyerCollector, fly_scan, grid_fly_scan +from haven.plans import fly_scan, grid_fly_scan +from haven.plans._fly import FlyerCollector +from haven.preprocessors import baseline_decorator @pytest.fixture() @@ -31,6 +33,7 @@ def test_set_fly_params(flyer): def test_fly_scan_metadata(flyer, ion_chamber): """Does the plan set the parameters of the flyer motor.""" md = {"spam": "eggs"} + print(baseline_decorator) plan = fly_scan([ion_chamber], flyer, -20, 30, num=6, dwell_time=1, md=md) messages = list(plan) open_msg = messages[1] diff --git a/src/haven/tests/test_open_shutters.py b/src/haven/tests/test_open_shutters.py new file mode 100644 index 00000000..4a74e433 --- /dev/null +++ b/src/haven/tests/test_open_shutters.py @@ -0,0 +1,105 @@ +import pytest +from bluesky.plan_stubs import trigger_and_read +from bluesky.plans import count +from bluesky.protocols import Triggerable +from ophyd_async.core import Device + +from haven import open_shutters_wrapper +from haven.preprocessors.open_shutters import open_fast_shutters_wrapper + + +@pytest.fixture() +def detector(sim_registry): + class Detector(Triggerable, Device): + """A stubbed detector that can be triggered.""" + + def trigger(self): + pass + + det = Detector(name="detector") + return det + + +@pytest.fixture() +def fast_shutter(sim_registry): + class FastShutter(Device): + _ophyd_labels_ = {"shutters", "fast_shutters"} + + shutter = FastShutter(name="fast_shutter") + sim_registry.register(shutter) + return shutter + + +@pytest.fixture() +def bad_shutter(sim_registry): + class BadShutter(Device): + _ophyd_labels_ = {"shutters"} + allow_close = False + + shutter = BadShutter(name="bad_shutter") + sim_registry.register(shutter) + return shutter + + +@pytest.fixture() +def slow_shutter(sim_registry): + class SlowShutter(Device): + _ophyd_labels_ = {"shutters"} + + shutter = SlowShutter(name="slow_shutter") + sim_registry.register(shutter) + return shutter + + +def test_slow_shutter_wrapper( + sim_registry, detector, bad_shutter, slow_shutter, fast_shutter +): + # Build the wrapped plan + plan = trigger_and_read([detector]) + plan = open_shutters_wrapper(plan, registry=sim_registry) + # Check that the current shutter position was read + read_msg = next(plan) + assert read_msg.command == "read" + assert read_msg.obj in [slow_shutter, fast_shutter] + read_msg = plan.send({read_msg.obj.name: {"value": 1}}) + assert read_msg.command == "read" + assert read_msg.obj in [slow_shutter, fast_shutter] + # Check that the shutter was opened + set_msg = plan.send({read_msg.obj.name: {"value": 1}}) + assert set_msg.command == "set" + assert set_msg.obj is slow_shutter + assert set_msg.args[0] == 0 + wait_msg = next(plan) + assert wait_msg.kwargs["group"] == set_msg.kwargs["group"] + # Were the shutters closed again? + msgs = list(plan) + set_msg = msgs[-3] + assert set_msg.command == "set" + assert set_msg.obj in [slow_shutter, fast_shutter] + assert set_msg.args[0] == 1 + set_msg = msgs[-2] + assert set_msg.command == "set" + assert set_msg.obj in [slow_shutter, fast_shutter] + assert set_msg.args[0] == 1 + wait_msg = msgs[-1] + assert wait_msg.kwargs["group"] == set_msg.kwargs["group"] + + +def test_fast_shutter_wrapper(sim_registry, detector, fast_shutter): + # Build the wrapped plan + plan = count([detector], num=2) + plan = open_fast_shutters_wrapper(plan, shutters=[fast_shutter]) + msgs = list(plan) + # Check that the shutter opens before triggering + trig_idxs = [idx for idx, msg in enumerate(msgs) if msg.command == "trigger"] + for trig_idx in trig_idxs: + set_msg = msgs[trig_idx - 2] + assert set_msg.command == "set" + assert set_msg.obj == fast_shutter + assert set_msg.args == (0,) + # Check that the shutter closes after waiting + for idx in trig_idxs: + set_msg = msgs[idx + 2] + assert set_msg.command == "set" + assert set_msg.obj == fast_shutter + assert set_msg.args == (1,) diff --git a/src/haven/tests/test_record_dark_current_plan.py b/src/haven/tests/test_record_dark_current_plan.py index df7b0605..105d5135 100644 --- a/src/haven/tests/test_record_dark_current_plan.py +++ b/src/haven/tests/test_record_dark_current_plan.py @@ -1,5 +1,5 @@ from haven.devices.shutter import ShutterState -from haven.plans.record_dark_current import record_dark_current +from haven.plans import record_dark_current def test_shutters_get_reset(shutters, ion_chamber): diff --git a/src/haven/tests/test_robot_transfer_sample.py b/src/haven/tests/test_robot_transfer_sample.py index 241f657c..eb253ddd 100644 --- a/src/haven/tests/test_robot_transfer_sample.py +++ b/src/haven/tests/test_robot_transfer_sample.py @@ -1,6 +1,6 @@ from ophyd.sim import motor1, motor2, motor3 -from haven import robot_transfer_sample +from haven.plans import robot_transfer_sample def test_robot_sample(robot): diff --git a/src/haven/tests/test_set_energy.py b/src/haven/tests/test_set_energy.py index ead2283c..307f80f6 100644 --- a/src/haven/tests/test_set_energy.py +++ b/src/haven/tests/test_set_energy.py @@ -1,7 +1,8 @@ import pytest from ophyd.sim import motor, motor1, motor2 -from haven import exceptions, set_energy +from haven import exceptions +from haven.plans import set_energy def test_plan_messages(): diff --git a/src/haven/tests/test_shutter.py b/src/haven/tests/test_shutter.py index 520fb7cf..d91393e6 100644 --- a/src/haven/tests/test_shutter.py +++ b/src/haven/tests/test_shutter.py @@ -19,6 +19,16 @@ async def shutter(sim_registry): return shutter +async def test_read_shutter(shutter): + """The current state of the shutter should be readable. + + This makes it compatible with the ``open_shutters_wrapper``. + + """ + reading = await shutter.read() + assert shutter.name in reading + + async def test_shutter_setpoint(shutter): """When we open and close the shutter, do the right EPICS signals get set? diff --git a/src/haven/tests/test_xia_pfcu.py b/src/haven/tests/test_xia_pfcu.py index 953c572f..5cd5ca23 100644 --- a/src/haven/tests/test_xia_pfcu.py +++ b/src/haven/tests/test_xia_pfcu.py @@ -1,75 +1,120 @@ +import asyncio + import pytest +from ophyd_async.testing import set_mock_value + +from haven.devices.xia_pfcu import ( + FilterState, + PFCUFilter, + PFCUFilterBank, + PFCUShutter, + ShutterState, +) + -from haven.devices.xia_pfcu import PFCUFilter, PFCUFilterBank, PFCUShutter, ShutterState +@pytest.fixture() +async def filter_bank(sim_registry): + bank = PFCUFilterBank( + prefix="255id:pfcu4:", name="xia_filter_bank", shutters=[(1, 2)] + ) + await bank.connect(mock=True) + sim_registry.register(bank) + yield bank @pytest.fixture() -def shutter(xia_shutter): - yield xia_shutter +async def shutter(filter_bank): + shutter = filter_bank.shutters[0] + await asyncio.gather( + shutter.setpoint.connect(mock=False), + shutter.readback.connect(mock=False), + shutter.top_filter.readback.connect(mock=False), + shutter.bottom_filter.readback.connect(mock=False), + ) + yield shutter @pytest.fixture() -def shutter_bank(xia_shutter_bank): - yield xia_shutter_bank - - -def test_shutter_factory(): - """Check that a shutter device is created if requested.""" - filterbank = PFCUFilterBank( - prefix="255id:pfcu0:", name="filter_bank_0", shutters=[(2, 3)] - ) # - assert filterbank.prefix == "255id:pfcu0:" - assert filterbank.name == "filter_bank_0" - assert hasattr(filterbank, "shutters") - assert isinstance(filterbank.shutters.shutter_0, PFCUShutter) - assert hasattr(filterbank, "shutters") - assert isinstance(filterbank.filters.filter1, PFCUFilter) - assert filterbank.filters.filter1.prefix == "255id:pfcu0:filter1" - assert isinstance(filterbank.filters.filter4, PFCUFilter) - assert not hasattr(filterbank.filters, "filter2") - assert not hasattr(filterbank.filters, "filter3") - - -def test_pfcu_shutter_signals(shutter): +async def filter(filter_bank): + filter = filter_bank.filters[0] + await filter.readback.connect(mock=False) + yield filter + + +def test_shutter_devices(filter_bank): + """Check that a shutter device is created if + requested.""" + assert hasattr(filter_bank, "shutters") + assert isinstance(filter_bank.shutters[0], PFCUShutter) + assert "fast_shutters" in filter_bank.shutters[0]._ophyd_labels_ + assert ( + filter_bank.shutters[0].top_filter.material.source + == "mock+ca://255id:pfcu4:filter2_mat" + ) + assert hasattr(filter_bank, "filters") + assert isinstance(filter_bank.filters[0], PFCUFilter) + assert filter_bank.filters[0].material.source == "mock+ca://255id:pfcu4:filter1_mat" + assert isinstance(filter_bank.filters[3], PFCUFilter) + # Make sure the shutter blades are not listed as filters + assert 1 not in filter_bank.filters.keys() + assert 2 not in filter_bank.filters.keys() + + +async def test_shutter_signals(shutter): # Check initial state - assert shutter.top_filter.setpoint.get() == 0 - assert shutter.bottom_filter.setpoint.get() == 0 + assert await shutter.top_filter.setpoint.get_value() == False + assert await shutter.bottom_filter.setpoint.get_value() == False -def test_pfcu_shutter_readback(shutter): +async def test_shutter_readback(filter_bank, shutter): + # Set the shutter position + set_mock_value(filter_bank.readback, "0010") + # Check that the readback signal gets updated + assert await shutter.readback.get_value() == ShutterState.OPEN # Set the shutter position - readback = shutter.parent.parent.readback - readback._readback = 0b0010 - readback._run_subs(sub_type=readback._default_sub) + set_mock_value(filter_bank.readback, "0100") # Check that the readback signal gets updated - assert shutter.readback.get() == ShutterState.OPEN + assert await shutter.readback.get_value() == ShutterState.CLOSED -def test_pfcu_shutter_bank_mask(shutter_bank): +async def test_shutter_reading(shutter): + """Ensure the shutter can be read. + + Needed for compatibility with the ``open_shutters_wrapper``. + + """ + assert shutter.readback.name == shutter.name + reading = await shutter.read() + assert shutter.name in reading + + +def test_pfcu_shutter_mask(shutter): """A bit-mask used for determining how to set the filter bank.""" - shutter = shutter_bank.shutters.shutter_0 - assert shutter.setpoint.top_mask() == 0b0001 - assert shutter.setpoint.bottom_mask() == 0b0010 + assert shutter.top_mask() == 0b0100 + assert shutter.bottom_mask() == 0b0010 -def test_pfcu_shutter_open(shutter_bank): +async def test_shutter_open(filter_bank, shutter): """If the PFCU filter bank is available, open both blades simultaneously.""" - shutter = shutter_bank.shutters.shutter_0 # Set the other filters on the filter bank - shutter_bank.readback._readback = 0b0100 + set_mock_value(filter_bank.readback, "1001") # Open the shutter, and check that the filterbank was set - shutter.setpoint.set(ShutterState.OPEN).wait(timeout=1) - assert shutter_bank.setpoint.get() == 0b0110 + await shutter.setpoint.set(ShutterState.OPEN) + assert await filter_bank.setpoint.get_value() == "1011" -def test_pfcu_shutter_close(shutter_bank): +async def test_shutter_close(filter_bank, shutter): """If the PFCU filter bank is available, open both blades simultaneously.""" - shutter = shutter_bank.shutters.shutter_0 # Set the other filters on the filter bank - shutter_bank.readback._readback = 0b0100 + set_mock_value(filter_bank.readback, "1001") # Open the shutter, and check that the filterbank was set - shutter.setpoint.set(ShutterState.CLOSED).wait(timeout=1) - assert shutter_bank.setpoint.get() == 0b0101 + await shutter.setpoint.set(ShutterState.CLOSED) + assert await filter_bank.setpoint.get_value() == "1101" + + +async def test_filter_readback(filter): + set_mock_value(filter._readback, "In") + assert await filter.readback.get_value() == FilterState.IN # ----------------------------------------------------------------------------- diff --git a/src/queueserver/queueserver_startup.py b/src/queueserver/queueserver_startup.py index ef6e912a..4bd02c55 100755 --- a/src/queueserver/queueserver_startup.py +++ b/src/queueserver/queueserver_startup.py @@ -1,35 +1,27 @@ import logging import re # noqa: F401 -import bluesky.preprocessors as bpp # noqa: F401 import databroker # noqa: F401 from bluesky.plan_stubs import abs_set # noqa: F401 from bluesky.plan_stubs import mv as _mv # noqa: F401 from bluesky.plan_stubs import mvr, null, pause, rel_set, sleep, stop # noqa: F401 -from bluesky.plans import ( # noqa: F401 +from bluesky.run_engine import call_in_bluesky_event_loop +from ophyd_async.core import NotConnected + +# Import plans +from haven import beamline, recall_motor_position, sanitize_name # noqa: F401 +from haven.plans import ( # noqa: F401 + auto_gain, count, + energy_scan, grid_scan, list_scan, + record_dark_current, rel_grid_scan, rel_list_scan, rel_scan, scan, scan_nd, -) -from bluesky.run_engine import call_in_bluesky_event_loop -from ophyd_async.core import NotConnected - -# Import plans -from haven import beamline # noqa: F401 -from haven import ( # noqa: F401 - align_pitch2, - align_slits, - auto_gain, - energy_scan, - knife_scan, - recall_motor_position, - record_dark_current, - sanitize_name, set_energy, xafs_scan, )