From 925f8626690d36daca299864a1cac6c935925753 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Sat, 28 Dec 2024 16:11:48 -0600 Subject: [PATCH 01/15] Moved Bluesky preprocessors to their own module. --- src/haven/__init__.py | 2 + src/haven/preprocessors/__init__.py | 4 + src/haven/preprocessors/auto_shutter.py | 34 +++++++ src/haven/preprocessors/baseline.py | 68 +++++++++++++ .../inject_metadata.py} | 76 +-------------- src/haven/preprocessors/shutter_suspender.py | 96 +++++++++++++++++++ src/haven/tests/test_auto_shutter.py | 13 +++ 7 files changed, 221 insertions(+), 72 deletions(-) create mode 100644 src/haven/preprocessors/__init__.py create mode 100644 src/haven/preprocessors/auto_shutter.py create mode 100644 src/haven/preprocessors/baseline.py rename src/haven/{preprocessors.py => preprocessors/inject_metadata.py} (68%) create mode 100644 src/haven/preprocessors/shutter_suspender.py create mode 100644 src/haven/tests/test_auto_shutter.py diff --git a/src/haven/__init__.py b/src/haven/__init__.py index a3065ac4..d3e3ac9b 100644 --- a/src/haven/__init__.py +++ b/src/haven/__init__.py @@ -63,6 +63,8 @@ from .plans.shutters import close_shutters, open_shutters # noqa: F401 from .plans.xafs_scan import xafs_scan # noqa: F401 from .preprocessors import ( # noqa: F401 + auto_shutter_wrapper, + auto_shutter_decorator, baseline_decorator, baseline_wrapper, shutter_suspend_decorator, diff --git a/src/haven/preprocessors/__init__.py b/src/haven/preprocessors/__init__.py new file mode 100644 index 00000000..6c857f41 --- /dev/null +++ b/src/haven/preprocessors/__init__.py @@ -0,0 +1,4 @@ +from .shutter_suspender import shutter_suspend_decorator, shutter_suspend_wrapper +from .baseline import baseline_wrapper, baseline_decorator +from .inject_metadata import inject_haven_md_wrapper +from .auto_shutter import auto_shutter_wrapper, auto_shutter_decorator diff --git a/src/haven/preprocessors/auto_shutter.py b/src/haven/preprocessors/auto_shutter.py new file mode 100644 index 00000000..f2521172 --- /dev/null +++ b/src/haven/preprocessors/auto_shutter.py @@ -0,0 +1,34 @@ +from bluesky.utils import make_decorator + + +def auto_shutter_wrapper(plan): + return (yield from plan) + + + +auto_shutter_decorator = make_decorator(auto_shutter_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/baseline.py b/src/haven/preprocessors/baseline.py new file mode 100644 index 00000000..48c51667 --- /dev/null +++ b/src/haven/preprocessors/baseline.py @@ -0,0 +1,68 @@ +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 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) + + +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 68% rename from src/haven/preprocessors.py rename to src/haven/preprocessors/inject_metadata.py index f3eff498..4f5d497c 100644 --- a/src/haven/preprocessors.py +++ b/src/haven/preprocessors/inject_metadata.py @@ -1,5 +1,3 @@ -"""Tools for modifying plans and data streams as they are generated.""" - import getpass import logging import os @@ -16,29 +14,14 @@ # from bluesky.suspenders import SuspendBoolLow from bluesky.utils import Msg, make_decorator -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): @@ -132,57 +115,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/shutter_suspender.py b/src/haven/preprocessors/shutter_suspender.py new file mode 100644 index 00000000..efaeb28b --- /dev/null +++ b/src/haven/preprocessors/shutter_suspender.py @@ -0,0 +1,96 @@ +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 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_auto_shutter.py b/src/haven/tests/test_auto_shutter.py new file mode 100644 index 00000000..e89e0ea1 --- /dev/null +++ b/src/haven/tests/test_auto_shutter.py @@ -0,0 +1,13 @@ +from bluesky.plans import scan +from ophyd_async.core import Device + +from haven import auto_shutter_wrapper + + +def test_auto_shutter_wrapper(): + # Prepare fake devices + detector = Device() + motor = Device() + # Build the wrapped plan + plan = scan([detector], motor, 0, 10, num=1) + plan = auto_shutter_wrapper(plan) From 29d42c598c98c2ac59fac17bac0bb955d347e2b5 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Sat, 28 Dec 2024 21:58:09 -0600 Subject: [PATCH 02/15] Implemented the ``open_shutters`` wrapper and decorator. --- src/haven/__init__.py | 4 +- src/haven/preprocessors/__init__.py | 2 +- src/haven/preprocessors/auto_shutter.py | 34 ------- src/haven/preprocessors/open_shutters.py | 118 +++++++++++++++++++++++ src/haven/tests/test_auto_shutter.py | 13 --- src/haven/tests/test_open_shutters.py | 107 ++++++++++++++++++++ 6 files changed, 228 insertions(+), 50 deletions(-) delete mode 100644 src/haven/preprocessors/auto_shutter.py create mode 100644 src/haven/preprocessors/open_shutters.py delete mode 100644 src/haven/tests/test_auto_shutter.py create mode 100644 src/haven/tests/test_open_shutters.py diff --git a/src/haven/__init__.py b/src/haven/__init__.py index d3e3ac9b..950d4933 100644 --- a/src/haven/__init__.py +++ b/src/haven/__init__.py @@ -63,8 +63,8 @@ from .plans.shutters import close_shutters, open_shutters # noqa: F401 from .plans.xafs_scan import xafs_scan # noqa: F401 from .preprocessors import ( # noqa: F401 - auto_shutter_wrapper, - auto_shutter_decorator, + open_shutters_wrapper, + open_shutters_decorator, baseline_decorator, baseline_wrapper, shutter_suspend_decorator, diff --git a/src/haven/preprocessors/__init__.py b/src/haven/preprocessors/__init__.py index 6c857f41..80c75906 100644 --- a/src/haven/preprocessors/__init__.py +++ b/src/haven/preprocessors/__init__.py @@ -1,4 +1,4 @@ from .shutter_suspender import shutter_suspend_decorator, shutter_suspend_wrapper from .baseline import baseline_wrapper, baseline_decorator from .inject_metadata import inject_haven_md_wrapper -from .auto_shutter import auto_shutter_wrapper, auto_shutter_decorator +from .open_shutters import open_shutters_wrapper, open_shutters_decorator diff --git a/src/haven/preprocessors/auto_shutter.py b/src/haven/preprocessors/auto_shutter.py deleted file mode 100644 index f2521172..00000000 --- a/src/haven/preprocessors/auto_shutter.py +++ /dev/null @@ -1,34 +0,0 @@ -from bluesky.utils import make_decorator - - -def auto_shutter_wrapper(plan): - return (yield from plan) - - - -auto_shutter_decorator = make_decorator(auto_shutter_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/open_shutters.py b/src/haven/preprocessors/open_shutters.py new file mode 100644 index 00000000..a7964be3 --- /dev/null +++ b/src/haven/preprocessors/open_shutters.py @@ -0,0 +1,118 @@ +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 + + +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)] + return (yield from bps.mv(*mv_args)) + + +def open_shutters_wrapper(plan, registry: Registry): + """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. + + """ + # Get a list of shutters that could be opened and closed + all_shutters = registry.findall(label="shutters") + 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: + reading = yield from bps.read(shutter) + initial_state = reading[shutter.name]["value"] + if initial_state == ShutterState.CLOSED: + shutters_to_open.append(shutter) + # Organize the shutters into fast and slow + fast_shutters = registry.findall(label="fast_shutters") + 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 + print(slow_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/tests/test_auto_shutter.py b/src/haven/tests/test_auto_shutter.py deleted file mode 100644 index e89e0ea1..00000000 --- a/src/haven/tests/test_auto_shutter.py +++ /dev/null @@ -1,13 +0,0 @@ -from bluesky.plans import scan -from ophyd_async.core import Device - -from haven import auto_shutter_wrapper - - -def test_auto_shutter_wrapper(): - # Prepare fake devices - detector = Device() - motor = Device() - # Build the wrapped plan - plan = scan([detector], motor, 0, 10, num=1) - plan = auto_shutter_wrapper(plan) diff --git a/src/haven/tests/test_open_shutters.py b/src/haven/tests/test_open_shutters.py new file mode 100644 index 00000000..8302afcb --- /dev/null +++ b/src/haven/tests/test_open_shutters.py @@ -0,0 +1,107 @@ +from pprint import pprint + +import pytest +from bluesky.plan_stubs import mv, repeat, 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,) From 5b82b143c7cf66a9061256e0e2d1689c1de85eb1 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Sat, 28 Dec 2024 22:12:26 -0600 Subject: [PATCH 03/15] Updated shutter devices so their readings have the right format for ``open_shutters_wrapper``. --- src/haven/devices/shutter.py | 3 ++- src/haven/devices/xia_pfcu.py | 3 +++ src/haven/tests/test_shutter.py | 10 ++++++++++ src/haven/tests/test_xia_pfcu.py | 11 +++++++++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/haven/devices/shutter.py b/src/haven/devices/shutter.py index 8a2cc29c..46668613 100644 --- a/src/haven/devices/shutter.py +++ b/src/haven/devices/shutter.py @@ -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..afeea7c9 100644 --- a/src/haven/devices/xia_pfcu.py +++ b/src/haven/devices/xia_pfcu.py @@ -125,6 +125,9 @@ def __init__( labels=labels, **kwargs, ) + # Make the default alias for the readback the name of the + # shutter itself. + self.readback.name = self.name class PFCUFilterBank(PVPositionerIsClose): 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..610398b3 100644 --- a/src/haven/tests/test_xia_pfcu.py +++ b/src/haven/tests/test_xia_pfcu.py @@ -45,6 +45,17 @@ def test_pfcu_shutter_readback(shutter): assert shutter.readback.get() == ShutterState.OPEN +def test_pfcu_shutter_reading(shutter): + """Ensure the shutter can be read. + + Needed for compatibility with the ``open_shutters_wrapper``. + + """ + # Set the shutter position + reading = shutter.read() + assert shutter.name in reading + + def test_pfcu_shutter_bank_mask(shutter_bank): """A bit-mask used for determining how to set the filter bank.""" shutter = shutter_bank.shutters.shutter_0 From 20e2867258a02cd297ee2e3cfc34c42bef5b9e52 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Mon, 30 Dec 2024 11:05:26 -0600 Subject: [PATCH 04/15] Open shutters preprocessor now uses the bps.rd plan instead of bps.read. --- src/haven/preprocessors/open_shutters.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/haven/preprocessors/open_shutters.py b/src/haven/preprocessors/open_shutters.py index a7964be3..63ab5096 100644 --- a/src/haven/preprocessors/open_shutters.py +++ b/src/haven/preprocessors/open_shutters.py @@ -3,6 +3,7 @@ from bluesky.utils import make_decorator from ophydregistry import Registry +from haven.instrument import beamline from haven.devices.shutter import ShutterState @@ -15,10 +16,13 @@ def _can_open(shutter): 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)] - return (yield from bps.mv(*mv_args)) + if len(mv_args) > 0: + return (yield from bps.mv(*mv_args)) + else: + yield from bps.null() -def open_shutters_wrapper(plan, registry: Registry): +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 @@ -31,22 +35,22 @@ def open_shutters_wrapper(plan, registry: Registry): categories will be closed at the end of the run. """ + if registry is None: + registry = beamline.devices # Get a list of shutters that could be opened and closed - all_shutters = registry.findall(label="shutters") + 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: - reading = yield from bps.read(shutter) - initial_state = reading[shutter.name]["value"] + 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") + 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 - print(slow_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) From 1428acd4f4ba04ff79224bfcbc43fe35325a2988 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Mon, 30 Dec 2024 11:07:57 -0600 Subject: [PATCH 05/15] ``plans`` module now has decorated plans for general use. --- src/haven/__init__.py | 12 ---- src/haven/devices/detectors/xspress.py | 4 ++ src/haven/ipython_startup.ipy | 12 ++-- src/haven/plans/__init__.py | 65 ++++++++++++++++++- .../plans/{align_motor.py => _align_motor.py} | 0 .../plans/{align_slits.py => _align_slits.py} | 0 .../plans/{auto_gain.py => _auto_gain.py} | 0 ...beam_properties.py => _beam_properties.py} | 0 .../plans/{energy_scan.py => _energy_scan.py} | 0 src/haven/plans/{fly.py => _fly.py} | 3 - ...ark_current.py => _record_dark_current.py} | 2 +- ...er_sample.py => _robot_transfer_sample.py} | 0 .../plans/{set_energy.py => _set_energy.py} | 0 src/haven/plans/{shutters.py => _shutters.py} | 0 .../plans/{xafs_scan.py => _xafs_scan.py} | 4 +- src/haven/preprocessors/baseline.py | 1 - src/haven/tests/test_align_motor.py | 2 +- .../{test_plans.py => test_align_slits.py} | 2 +- src/haven/tests/test_auto_gain_plan.py | 11 ++-- src/haven/tests/test_beam_properties.py | 2 +- src/haven/tests/test_energy_xafs_scan.py | 3 +- src/haven/tests/test_fly_plans.py | 5 +- .../tests/test_record_dark_current_plan.py | 2 +- src/haven/tests/test_robot_transfer_sample.py | 2 +- src/haven/tests/test_set_energy.py | 3 +- src/queueserver/queueserver_startup.py | 29 ++++----- 26 files changed, 105 insertions(+), 59 deletions(-) rename src/haven/plans/{align_motor.py => _align_motor.py} (100%) rename src/haven/plans/{align_slits.py => _align_slits.py} (100%) rename src/haven/plans/{auto_gain.py => _auto_gain.py} (100%) rename src/haven/plans/{beam_properties.py => _beam_properties.py} (100%) rename src/haven/plans/{energy_scan.py => _energy_scan.py} (100%) rename src/haven/plans/{fly.py => _fly.py} (99%) rename src/haven/plans/{record_dark_current.py => _record_dark_current.py} (98%) rename src/haven/plans/{robot_transfer_sample.py => _robot_transfer_sample.py} (100%) rename src/haven/plans/{set_energy.py => _set_energy.py} (100%) rename src/haven/plans/{shutters.py => _shutters.py} (100%) rename src/haven/plans/{xafs_scan.py => _xafs_scan.py} (98%) rename src/haven/tests/{test_plans.py => test_align_slits.py} (98%) diff --git a/src/haven/__init__.py b/src/haven/__init__.py index 950d4933..f9f9b247 100644 --- a/src/haven/__init__.py +++ b/src/haven/__init__.py @@ -50,18 +50,6 @@ 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 open_shutters_wrapper, open_shutters_decorator, 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/ipython_startup.ipy b/src/haven/ipython_startup.ipy index 080ed397..915b16e3 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=True, ) -# 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..4babc7c3 100644 --- a/src/haven/plans/__init__.py +++ b/src/haven/plans/__init__.py @@ -1,4 +1,67 @@ -"""Bluesky plans specific to spectroscopy.""" +"""Bluesky plans specific to spectroscopy. + +Includes some standard bluesky plans with decorators. + +""" + +from haven.preprocessors import ( + open_shutters_decorator, + baseline_decorator, + shutter_suspend_decorator, +) +import bluesky.plans as bp + +from haven.instrument import beamline +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 open_shutters, close_shutters # noqa: F401 +from ._xafs_scan import xafs_scan + +# open_shutters_decorator = open_shutters_decorator() + +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 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 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 6a3a7b20..e86ccf97 100644 --- a/src/haven/plans/fly.py +++ b/src/haven/plans/_fly.py @@ -158,7 +158,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, @@ -200,7 +199,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]) @@ -232,7 +230,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 06cffdb1..5acb45dc 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 98% rename from src/haven/plans/xafs_scan.py rename to src/haven/plans/_xafs_scan.py index d280bdc1..d39f197c 100644 --- a/src/haven/plans/xafs_scan.py +++ b/src/haven/plans/_xafs_scan.py @@ -11,7 +11,7 @@ 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 +from ._energy_scan import energy_scan log = logging.getLogger(__name__) @@ -28,8 +28,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/preprocessors/baseline.py b/src/haven/preprocessors/baseline.py index 48c51667..3f88e67c 100644 --- a/src/haven/preprocessors/baseline.py +++ b/src/haven/preprocessors/baseline.py @@ -21,7 +21,6 @@ log = logging.getLogger() - def baseline_wrapper( plan, devices: Union[Sequence, str] = [ 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..7ce5b914 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 d9a2e8af..592ffc47 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_record_dark_current_plan.py b/src/haven/tests/test_record_dark_current_plan.py index 7e67f2c9..cb5981a4 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/queueserver/queueserver_startup.py b/src/queueserver/queueserver_startup.py index ef6e912a..b8965611 100755 --- a/src/queueserver/queueserver_startup.py +++ b/src/queueserver/queueserver_startup.py @@ -1,37 +1,32 @@ 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 - count, - grid_scan, - list_scan, - 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, + beamline, recall_motor_position, sanitize_name, + +) +from haven.plans import ( # noqa: F401 auto_gain, energy_scan, - knife_scan, - recall_motor_position, record_dark_current, - sanitize_name, set_energy, xafs_scan, + count, + grid_scan, + list_scan, + rel_grid_scan, + rel_list_scan, + rel_scan, + scan, + scan_nd, ) from haven.run_engine import run_engine # noqa: F401 From b156728fe070b7d76f471418a5bac3287d4230c8 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Wed, 1 Jan 2025 14:23:22 -0600 Subject: [PATCH 06/15] Ophyd-async device for XIA PFCU4 filter bank. --- src/conftest.py | 34 +-- src/firefly/filters.py | 4 +- src/firefly/voltmeters.py | 2 +- src/haven/devices/xia_pfcu.py | 367 ++++++++++++++++++++----------- src/haven/instrument.py | 42 +++- src/haven/positioner.py | 1 + src/haven/tests/test_xia_pfcu.py | 100 +++++---- 7 files changed, 339 insertions(+), 211 deletions(-) diff --git a/src/conftest.py b/src/conftest.py index 11769fb6..e31ccc23 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -1,3 +1,4 @@ +import asyncio import os from pathlib import Path @@ -199,30 +200,16 @@ 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=[[3, 4]]) + 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 +229,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/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/devices/xia_pfcu.py b/src/haven/devices/xia_pfcu.py index afeea7c9..521025e9 100644 --- a/src/haven/devices/xia_pfcu.py +++ b/src/haven/devices/xia_pfcu.py @@ -7,6 +7,10 @@ from enum import IntEnum from typing import Sequence +import logging + +from ophyd_async.core import StrictEnum, StandardReadable, StandardReadableFormat, DeviceVector, SubsetEnum, SignalR, SignalRW, T +from ophyd_async.epics.core import epics_signal_r, epics_signal_rw from ophyd import Component as Cpt from ophyd import DynamicDeviceComponent as DCpt @@ -15,74 +19,205 @@ from ophyd import PVPositionerIsClose from ophyd.signal import DerivedSignal -from .shutter import ShutterState +from haven.devices.shutter import ShutterState +from haven.positioner import Positioner +from haven.devices.signal import DerivedSignalBackend, derived_signal_r, derived_signal_rw + + +log = logging.getLogger(__name__) + + +class FilterPosition(StrictEnum): + OUT = "Out" + IN = "In" + SHORT_CIRCUIT = "Short Circuit" + OPEN_CIRCUIT = "Open Circuit" -class FilterPosition(IntEnum): - OUT = 0 - IN = 1 +class Material(SubsetEnum): + ALUMINUM = "Al" + MOLYBDENUM = "Mo" + TITANIUM = "Ti" + GLASS = "Glass" + OTHER = "Other" -class PFCUFilter(PVPositionerIsClose): +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(str, f"{prefix}_think") + self.thick_unit = epics_signal_rw(str, f"{prefix}_think.EGU") + self.notes = epics_signal_rw(str, f"{prefix}_other") + with self.add_children_as_readables(): + self.readback = epics_signal_r(FilterPosition, f"{prefix}_RBV") + self.setpoint = epics_signal_rw(FilterPosition, prefix) + 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) - - def bottom_mask(self): - return self._mask(self.parent._bottom_filter) - - 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 + Filters are indexed from 0, even though the EPICS support indexes + from 1. + 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". -class PFCUShutter(PVPositionerIsClose): + """ + num_slots: int + + def __init__(self, prefix: str, *, name: str = "", num_slots: int = 4, shutters: Sequence[tuple[int, int]] = []): + self.num_slots = num_slots + # Positioner signals + self.setpoint = epics_signal_rw(int, f"{prefix}config") + self.readback = epics_signal_r(int, f"{prefix}config_RBV") + # Sort out filters vs shutters + all_shutters = [v for shutter in shutters for v in shutter] + filters = [ + idx for idx in range(num_slots) if idx not in all_shutters + ] + # 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) + + # readback = Cpt(EpicsSignalRO, "config_RBV", kind="normal") + # setpoint = Cpt(EpicsSignal, "config", kind="normal") + + # 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 __init__( + # self, + # prefix: str = "", + # *, + # name: str, + # shutters: Sequence = [], + # labels: str = {"filter_banks"}, + # **kwargs, + # ): + # super().__init__(prefix=prefix, name=name, labels=labels, **kwargs) + + +# class PFCUShutterBackend(DerivedSignalBackend): +# def _mask(self, pos): +# num_filters = 4 +# return 1 << (num_filters - pos) + +# def top_mask(self): +# return self._mask(self.parent._top_filter) + +# def bottom_mask(self): +# return self._mask(self.parent._bottom_filter) + +# 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, signal: SignalR): +# """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 + + +# def pfcu_shutter_signal_rw( +# *, +# name: str = "", +# derived_from: Sequence, +# ) -> SignalRW[T]: +# backend = PFCUShutterBackend( +# datatype=int, +# derived_from=derived_from, +# ) +# signal = SignalRW(backend, name=name) +# return signal + + +# def pfcu_shutter_signal_r( +# *, +# name: str = "", +# derived_from: Sequence, +# ) -> SignalR[T]: +# backend = PFCUShutterBackend( +# datatype=int, +# derived_from=derived_from, +# ) +# signal = SignalR(backend, name=name) +# return signal + + +class PFCUShutter(Positioner): """A shutter made of two PFCU4 filters. For faster operation, both filters will be moved at the same @@ -92,106 +227,86 @@ 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. """ + _ophyd_labels_ = {"shutters", "fast_shutters"} - 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}") + # readback = Cpt(PFCUShutterSignal, derived_from="parent.parent.readback") + # setpoint = Cpt(PFCUShutterSignal, derived_from="parent.parent.setpoint") 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.top_filter = PFCUFilter(prefix=f"{prefix}filter{top_filter+1}") + self._bottom_filter_idx = bottom_filter + self.bottom_filter = PFCUFilter(prefix=f"{prefix}filter{bottom_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) super().__init__( - prefix=prefix, name=name, - limits=(ShutterState.OPEN, ShutterState.CLOSED), - labels=labels, **kwargs, ) # Make the default alias for the readback the name of the # shutter itself. - self.readback.name = self.name + # self.readback.name = self.name + async def forward(self, value, setpoint, readback): + """Convert shutter state to filter bank state.""" + # Bit masking to set both blades together + old_bits = await readback.get_value() + 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: new_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". - - """ + def inverse(self, values, readback, **kwargs): + """Convert filter bank state to shutter state.""" + bits = values[readback] + # 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 - num_slots: int = 4 + def _mask(self, pos): + num_filters = 4 + return 1 << (num_filters - pos - 1) - readback = Cpt(EpicsSignalRO, "config_RBV", kind="normal") - setpoint = Cpt(EpicsSignal, "config", kind="normal") + def top_mask(self): + return self._mask(self._top_filter_idx) - 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 bottom_mask(self): + return self._mask(self._bottom_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) # ----------------------------------------------------------------------------- diff --git a/src/haven/instrument.py b/src/haven/instrument.py index a894e000..442775d7 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,35 @@ 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/positioner.py b/src/haven/positioner.py index 6d357c67..e075edca 100644 --- a/src/haven/positioner.py +++ b/src/haven/positioner.py @@ -16,6 +16,7 @@ observe_value, ) + log = logging.getLogger(__name__) diff --git a/src/haven/tests/test_xia_pfcu.py b/src/haven/tests/test_xia_pfcu.py index 610398b3..049fe868 100644 --- a/src/haven/tests/test_xia_pfcu.py +++ b/src/haven/tests/test_xia_pfcu.py @@ -1,86 +1,90 @@ import pytest +from ophyd_async.testing import set_mock_value -from haven.devices.xia_pfcu import PFCUFilter, PFCUFilterBank, PFCUShutter, ShutterState +from haven.devices.xia_pfcu import PFCUFilter, PFCUFilterBank, PFCUShutter, ShutterState, FilterPosition @pytest.fixture() -def shutter(xia_shutter): - yield xia_shutter +async def filter_bank(sim_registry): + bank = PFCUFilterBank(prefix="255id:pfcu4:", name="xia_filter_bank", shutters=[(1, 2)]) + await bank.connect(mock=True) + await bank.shutters[0].setpoint.connect(mock=False) + await bank.shutters[0].readback.connect(mock=False) + sim_registry.register(bank) + yield bank @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): +def shutter(filter_bank): + yield filter_bank.shutters[0] + + +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_pfcu_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() == FilterPosition.OUT + assert await shutter.bottom_filter.setpoint.get_value() == FilterPosition.OUT -def test_pfcu_shutter_readback(shutter): +async def test_pfcu_shutter_readback(filter_bank, shutter): # 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, 0b0010) # Check that the readback signal gets updated - assert shutter.readback.get() == ShutterState.OPEN + assert await shutter.readback.get_value() == ShutterState.OPEN + # Set the shutter position + set_mock_value(filter_bank.readback, 0b0100) + # Check that the readback signal gets updated + assert await shutter.readback.get_value() == ShutterState.CLOSED -def test_pfcu_shutter_reading(shutter): +async def test_pfcu_shutter_reading(shutter): """Ensure the shutter can be read. Needed for compatibility with the ``open_shutters_wrapper``. """ # Set the shutter position - reading = shutter.read() + reading = await shutter.read() assert shutter.name in reading -def test_pfcu_shutter_bank_mask(shutter_bank): +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_pfcu_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, 0b1001) # 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() == 0b1011 -def test_pfcu_shutter_close(shutter_bank): +async def test_pfcu_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, 0b1001) # 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() == 0b1101 # ----------------------------------------------------------------------------- From 29b05da7678df6ef54e5a07cdfb63a3e32583ea0 Mon Sep 17 00:00:00 2001 From: yannachen Date: Wed, 1 Jan 2025 16:05:45 -0600 Subject: [PATCH 07/15] Updated PFCU signals with correct PVs and types. --- src/haven/devices/xia_pfcu.py | 39 ++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/haven/devices/xia_pfcu.py b/src/haven/devices/xia_pfcu.py index 521025e9..2dd0da63 100644 --- a/src/haven/devices/xia_pfcu.py +++ b/src/haven/devices/xia_pfcu.py @@ -9,7 +9,7 @@ from typing import Sequence import logging -from ophyd_async.core import StrictEnum, StandardReadable, StandardReadableFormat, DeviceVector, SubsetEnum, SignalR, SignalRW, T +from ophyd_async.core import StrictEnum, StandardReadable, StandardReadableFormat, DeviceVector, SubsetEnum, SignalR, SignalRW, T, soft_signal_rw from ophyd_async.epics.core import epics_signal_r, epics_signal_rw from ophyd import Component as Cpt @@ -27,13 +27,31 @@ log = logging.getLogger(__name__) -class FilterPosition(StrictEnum): +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 Material(SubsetEnum): ALUMINUM = "Al" MOLYBDENUM = "Mo" @@ -51,12 +69,17 @@ class PFCUFilter(Positioner): 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(str, f"{prefix}_think") - self.thick_unit = epics_signal_rw(str, f"{prefix}_think.EGU") + 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") with self.add_children_as_readables(): self.readback = epics_signal_r(FilterPosition, f"{prefix}_RBV") - self.setpoint = epics_signal_rw(FilterPosition, prefix) + 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) @@ -88,8 +111,8 @@ class PFCUFilterBank(StandardReadable): def __init__(self, prefix: str, *, name: str = "", num_slots: int = 4, shutters: Sequence[tuple[int, int]] = []): self.num_slots = num_slots # Positioner signals - self.setpoint = epics_signal_rw(int, f"{prefix}config") - self.readback = epics_signal_r(int, f"{prefix}config_RBV") + self.setpoint = epics_signal_rw(ConfigBits, f"{prefix}config") + self.readback = epics_signal_r(ConfigBits, f"{prefix}config_RBV") # Sort out filters vs shutters all_shutters = [v for shutter in shutters for v in shutter] filters = [ From d91a5e6c7375657d0e1bf1adef981d849d208e84 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Thu, 2 Jan 2025 09:28:33 -0600 Subject: [PATCH 08/15] Updated XIA PFCU device to use correct data types. --- src/haven/devices/xia_pfcu.py | 8 +++++--- src/haven/tests/test_xia_pfcu.py | 16 ++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/haven/devices/xia_pfcu.py b/src/haven/devices/xia_pfcu.py index 2dd0da63..28f2d9f0 100644 --- a/src/haven/devices/xia_pfcu.py +++ b/src/haven/devices/xia_pfcu.py @@ -297,7 +297,9 @@ def __init__( async def forward(self, value, setpoint, readback): """Convert shutter state to filter bank state.""" # Bit masking to set both blades together - old_bits = await readback.get_value() + 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() @@ -308,11 +310,11 @@ async def forward(self, value, setpoint, readback): 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: new_bits} + return {setpoint: f"{new_bits:0b}".zfill(num_bits)} def inverse(self, values, readback, **kwargs): """Convert filter bank state to shutter state.""" - bits = values[readback] + 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())) diff --git a/src/haven/tests/test_xia_pfcu.py b/src/haven/tests/test_xia_pfcu.py index 049fe868..42598371 100644 --- a/src/haven/tests/test_xia_pfcu.py +++ b/src/haven/tests/test_xia_pfcu.py @@ -37,17 +37,17 @@ def test_shutter_devices(filter_bank): async def test_pfcu_shutter_signals(shutter): # Check initial state - assert await shutter.top_filter.setpoint.get_value() == FilterPosition.OUT - assert await shutter.bottom_filter.setpoint.get_value() == FilterPosition.OUT + assert await shutter.top_filter.setpoint.get_value() == False + assert await shutter.bottom_filter.setpoint.get_value() == False async def test_pfcu_shutter_readback(filter_bank, shutter): # Set the shutter position - set_mock_value(filter_bank.readback, 0b0010) + 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 - set_mock_value(filter_bank.readback, 0b0100) + set_mock_value(filter_bank.readback, "0100") # Check that the readback signal gets updated assert await shutter.readback.get_value() == ShutterState.CLOSED @@ -72,19 +72,19 @@ def test_pfcu_shutter_mask(shutter): async def test_pfcu_shutter_open(filter_bank, shutter): """If the PFCU filter bank is available, open both blades simultaneously.""" # Set the other filters on the filter bank - set_mock_value(filter_bank.readback, 0b1001) + set_mock_value(filter_bank.readback, "1001") # Open the shutter, and check that the filterbank was set await shutter.setpoint.set(ShutterState.OPEN) - assert await filter_bank.setpoint.get_value() == 0b1011 + assert await filter_bank.setpoint.get_value() == "1011" async def test_pfcu_shutter_close(filter_bank, shutter): """If the PFCU filter bank is available, open both blades simultaneously.""" # Set the other filters on the filter bank - set_mock_value(filter_bank.readback, 0b1001) + set_mock_value(filter_bank.readback, "1001") # Open the shutter, and check that the filterbank was set await shutter.setpoint.set(ShutterState.CLOSED) - assert await filter_bank.setpoint.get_value() == 0b1101 + assert await filter_bank.setpoint.get_value() == "1101" # ----------------------------------------------------------------------------- From 1e8d6fc80bba0f52c2fd974d5de2ca084e619f60 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Thu, 2 Jan 2025 15:21:12 -0600 Subject: [PATCH 09/15] Standardized the readback and setpoint data types for an XIA PFCU filter. --- src/haven/devices/xia_pfcu.py | 46 +++++++++++++++++++++++--------- src/haven/tests/test_xia_pfcu.py | 40 ++++++++++++++++++++------- 2 files changed, 63 insertions(+), 23 deletions(-) diff --git a/src/haven/devices/xia_pfcu.py b/src/haven/devices/xia_pfcu.py index 28f2d9f0..7dc817f5 100644 --- a/src/haven/devices/xia_pfcu.py +++ b/src/haven/devices/xia_pfcu.py @@ -52,6 +52,13 @@ class FilterPosition(SubsetEnum): 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" @@ -60,6 +67,15 @@ class Material(SubsetEnum): 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. @@ -72,14 +88,15 @@ def __init__(self, prefix: str, *, name: str = ""): 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 = epics_signal_r(FilterPosition, f"{prefix}_RBV") - self.setpoint = epics_signal_rw(bool, prefix) + 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) @@ -112,19 +129,21 @@ def __init__(self, prefix: str, *, name: str = "", num_slots: int = 4, shutters: self.num_slots = num_slots # Positioner signals self.setpoint = epics_signal_rw(ConfigBits, f"{prefix}config") - self.readback = epics_signal_r(ConfigBits, f"{prefix}config_RBV") + with self.add_children_as_readables(): + self.readback = epics_signal_r(ConfigBits, f"{prefix}config_RBV") # Sort out filters vs shutters all_shutters = [v for shutter in shutters for v in shutter] filters = [ idx for idx in range(num_slots) if idx not in all_shutters ] - # 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}) + 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) # readback = Cpt(EpicsSignalRO, "config_RBV", kind="normal") @@ -277,9 +296,10 @@ def __init__( **kwargs, ): self._top_filter_idx = top_filter - self.top_filter = PFCUFilter(prefix=f"{prefix}filter{top_filter+1}") self._bottom_filter_idx = bottom_filter - self.bottom_filter = PFCUFilter(prefix=f"{prefix}filter{bottom_filter+1}") + 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} diff --git a/src/haven/tests/test_xia_pfcu.py b/src/haven/tests/test_xia_pfcu.py index 42598371..bd4b74aa 100644 --- a/src/haven/tests/test_xia_pfcu.py +++ b/src/haven/tests/test_xia_pfcu.py @@ -1,22 +1,36 @@ +import asyncio + import pytest from ophyd_async.testing import set_mock_value -from haven.devices.xia_pfcu import PFCUFilter, PFCUFilterBank, PFCUShutter, ShutterState, FilterPosition +from haven.devices.xia_pfcu import PFCUFilter, PFCUFilterBank, PFCUShutter, ShutterState, FilterPosition, FilterState @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) - await bank.shutters[0].setpoint.connect(mock=False) - await bank.shutters[0].readback.connect(mock=False) sim_registry.register(bank) yield bank @pytest.fixture() -def shutter(filter_bank): - yield filter_bank.shutters[0] +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() +async def filter(filter_bank): + filter = filter_bank.filters[0] + await filter.readback.connect(mock=False) + yield filter def test_shutter_devices(filter_bank): @@ -35,13 +49,13 @@ def test_shutter_devices(filter_bank): assert 2 not in filter_bank.filters.keys() -async def test_pfcu_shutter_signals(shutter): +async def test_shutter_signals(shutter): # Check initial state assert await shutter.top_filter.setpoint.get_value() == False assert await shutter.bottom_filter.setpoint.get_value() == False -async def test_pfcu_shutter_readback(filter_bank, 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 @@ -52,7 +66,7 @@ async def test_pfcu_shutter_readback(filter_bank, shutter): assert await shutter.readback.get_value() == ShutterState.CLOSED -async def test_pfcu_shutter_reading(shutter): +async def test_shutter_reading(shutter): """Ensure the shutter can be read. Needed for compatibility with the ``open_shutters_wrapper``. @@ -69,7 +83,7 @@ def test_pfcu_shutter_mask(shutter): assert shutter.bottom_mask() == 0b0010 -async def test_pfcu_shutter_open(filter_bank, shutter): +async def test_shutter_open(filter_bank, shutter): """If the PFCU filter bank is available, open both blades simultaneously.""" # Set the other filters on the filter bank set_mock_value(filter_bank.readback, "1001") @@ -78,7 +92,7 @@ async def test_pfcu_shutter_open(filter_bank, shutter): assert await filter_bank.setpoint.get_value() == "1011" -async def test_pfcu_shutter_close(filter_bank, shutter): +async def test_shutter_close(filter_bank, shutter): """If the PFCU filter bank is available, open both blades simultaneously.""" # Set the other filters on the filter bank set_mock_value(filter_bank.readback, "1001") @@ -87,6 +101,12 @@ async def test_pfcu_shutter_close(filter_bank, shutter): 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 + + + # ----------------------------------------------------------------------------- # :author: Mark Wolfman # :email: wolfman@anl.gov From 7f94472bf5b34bdd94ac37b0b50cf9b8445b4e05 Mon Sep 17 00:00:00 2001 From: yannachen Date: Thu, 2 Jan 2025 22:32:47 -0600 Subject: [PATCH 10/15] Fixed XIA derived signals and added a check for out-of-bounds shutters. --- src/haven/devices/xia_pfcu.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/haven/devices/xia_pfcu.py b/src/haven/devices/xia_pfcu.py index 7dc817f5..53240dca 100644 --- a/src/haven/devices/xia_pfcu.py +++ b/src/haven/devices/xia_pfcu.py @@ -126,13 +126,16 @@ class PFCUFilterBank(StandardReadable): 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 - all_shutters = [v for shutter in shutters for v in shutter] filters = [ idx for idx in range(num_slots) if idx not in all_shutters ] @@ -306,8 +309,13 @@ def __init__( 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__( name=name, + put_complete=True, **kwargs, ) # Make the default alias for the readback the name of the From bc3dd6e0627b340f4822e5745f1622597c584ef9 Mon Sep 17 00:00:00 2001 From: yannachen Date: Fri, 3 Jan 2025 09:58:21 -0600 Subject: [PATCH 11/15] Positioner is now locatable. --- src/haven/positioner.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/haven/positioner.py b/src/haven/positioner.py index e075edca..c0656602 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 Movable, Stoppable, Locatable, Location from ophyd_async.core import ( CALCULATE_TIMEOUT, DEFAULT_TIMEOUT, @@ -20,7 +20,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 @@ -74,6 +74,14 @@ 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, From 353bcb3b74623139cfd1ff4424730c11af44a890 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Fri, 3 Jan 2025 14:49:39 -0600 Subject: [PATCH 12/15] Fixed a test, plus linting and de-cluttering old code. --- src/conftest.py | 6 +- src/haven/__init__.py | 4 +- src/haven/devices/xia_pfcu.py | 201 +++++-------------- src/haven/instrument.py | 1 - src/haven/plans/__init__.py | 18 +- src/haven/plans/_fly.py | 2 - src/haven/plans/_xafs_scan.py | 4 - src/haven/positioner.py | 7 +- src/haven/preprocessors/__init__.py | 11 +- src/haven/preprocessors/baseline.py | 14 +- src/haven/preprocessors/inject_metadata.py | 9 +- src/haven/preprocessors/open_shutters.py | 2 +- src/haven/preprocessors/shutter_suspender.py | 12 +- src/haven/tests/test_auto_gain_plan.py | 2 +- src/haven/tests/test_open_shutters.py | 4 +- src/haven/tests/test_xia_pfcu.py | 22 +- src/queueserver/queueserver_startup.py | 13 +- 17 files changed, 112 insertions(+), 220 deletions(-) diff --git a/src/conftest.py b/src/conftest.py index e31ccc23..de0935b6 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -27,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 @@ -201,7 +201,9 @@ def aps(sim_registry): @pytest.fixture() async def xia_shutter_bank(sim_registry): - bank = PFCUFilterBank(prefix="255id:pfcu4:", name="xia_filter_bank", shutters=[[3, 4]]) + bank = PFCUFilterBank( + prefix="255id:pfcu4:", name="xia_filter_bank", shutters=[[2, 3]] + ) await bank.connect(mock=True) sim_registry.register(bank) yield bank diff --git a/src/haven/__init__.py b/src/haven/__init__.py index f9f9b247..1b65a39d 100644 --- a/src/haven/__init__.py +++ b/src/haven/__init__.py @@ -51,10 +51,10 @@ save_motor_position, ) from .preprocessors import ( # noqa: F401 - open_shutters_wrapper, - open_shutters_decorator, baseline_decorator, baseline_wrapper, + open_shutters_decorator, + open_shutters_wrapper, shutter_suspend_decorator, shutter_suspend_wrapper, ) diff --git a/src/haven/devices/xia_pfcu.py b/src/haven/devices/xia_pfcu.py index 53240dca..8545e9da 100644 --- a/src/haven/devices/xia_pfcu.py +++ b/src/haven/devices/xia_pfcu.py @@ -5,24 +5,22 @@ """ +import logging from enum import IntEnum from typing import Sequence -import logging -from ophyd_async.core import StrictEnum, StandardReadable, StandardReadableFormat, DeviceVector, SubsetEnum, SignalR, SignalRW, T, soft_signal_rw +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 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 haven.devices.shutter import ShutterState +from haven.devices.signal import derived_signal_r, derived_signal_rw from haven.positioner import Positioner -from haven.devices.signal import DerivedSignalBackend, derived_signal_r, derived_signal_rw - log = logging.getLogger(__name__) @@ -59,6 +57,7 @@ class FilterState(IntEnum): FAULT = 3 # 0b011 UNKNOWN = 4 # 0b100 + class Material(SubsetEnum): ALUMINUM = "Al" MOLYBDENUM = "Mo" @@ -81,7 +80,9 @@ class PFCUFilter(Positioner): E.g. 25idc:pfcu0:filter1_mat """ + _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") @@ -91,7 +92,11 @@ def __init__(self, prefix: str, *, name: str = ""): # 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.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) @@ -123,144 +128,49 @@ class PFCUFilterBank(StandardReadable): filter (top) is open when the filter is set to "out". """ + num_slots: int - def __init__(self, prefix: str, *, name: str = "", num_slots: int = 4, shutters: Sequence[tuple[int, 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}).") + 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 - ] + 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) - }) + 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}) + self.filters = DeviceVector( + {idx: PFCUFilter(prefix=f"{prefix}filter{idx+1}") for idx in filters} + ) super().__init__(name=name) - # readback = Cpt(EpicsSignalRO, "config_RBV", kind="normal") - # setpoint = Cpt(EpicsSignal, "config", kind="normal") - - # 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 __init__( - # self, - # prefix: str = "", - # *, - # name: str, - # shutters: Sequence = [], - # labels: str = {"filter_banks"}, - # **kwargs, - # ): - # super().__init__(prefix=prefix, name=name, labels=labels, **kwargs) - - -# class PFCUShutterBackend(DerivedSignalBackend): -# def _mask(self, pos): -# num_filters = 4 -# return 1 << (num_filters - pos) - -# def top_mask(self): -# return self._mask(self.parent._top_filter) - -# def bottom_mask(self): -# return self._mask(self.parent._bottom_filter) - -# 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, signal: SignalR): -# """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 - - -# def pfcu_shutter_signal_rw( -# *, -# name: str = "", -# derived_from: Sequence, -# ) -> SignalRW[T]: -# backend = PFCUShutterBackend( -# datatype=int, -# derived_from=derived_from, -# ) -# signal = SignalRW(backend, name=name) -# return signal - - -# def pfcu_shutter_signal_r( -# *, -# name: str = "", -# derived_from: Sequence, -# ) -> SignalR[T]: -# backend = PFCUShutterBackend( -# datatype=int, -# derived_from=derived_from, -# ) -# signal = SignalR(backend, name=name) -# return signal - class PFCUShutter(Positioner): """A shutter made of two PFCU4 filters. @@ -283,10 +193,8 @@ class PFCUShutter(Positioner): for actuating both shutter blades together. """ - _ophyd_labels_ = {"shutters", "fast_shutters"} - # readback = Cpt(PFCUShutterSignal, derived_from="parent.parent.readback") - # setpoint = Cpt(PFCUShutterSignal, derived_from="parent.parent.setpoint") + _ophyd_labels_ = {"shutters", "fast_shutters"} def __init__( self, @@ -304,11 +212,17 @@ def __init__( 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) + 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) + 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="") @@ -318,9 +232,6 @@ def __init__( put_complete=True, **kwargs, ) - # Make the default alias for the readback the name of the - # shutter itself. - # self.readback.name = self.name async def forward(self, value, setpoint, readback): """Convert shutter state to filter bank state.""" @@ -358,8 +269,6 @@ def top_mask(self): def bottom_mask(self): return self._mask(self._bottom_filter_idx) - - # ----------------------------------------------------------------------------- diff --git a/src/haven/instrument.py b/src/haven/instrument.py index 442775d7..aa0e9a6d 100644 --- a/src/haven/instrument.py +++ b/src/haven/instrument.py @@ -136,4 +136,3 @@ def load( # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # # ----------------------------------------------------------------------------- - diff --git a/src/haven/plans/__init__.py b/src/haven/plans/__init__.py index 4babc7c3..a83c39ba 100644 --- a/src/haven/plans/__init__.py +++ b/src/haven/plans/__init__.py @@ -4,14 +4,15 @@ """ +import bluesky.plans as bp + +from haven.instrument import beamline from haven.preprocessors import ( - open_shutters_decorator, baseline_decorator, + open_shutters_decorator, shutter_suspend_decorator, ) -import bluesky.plans as bp -from haven.instrument import beamline from ._align_motor import align_motor from ._auto_gain import auto_gain from ._energy_scan import energy_scan @@ -19,11 +20,12 @@ 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 open_shutters, close_shutters # noqa: F401 +from ._shutters import close_shutters, open_shutters # noqa: F401 from ._xafs_scan import xafs_scan # open_shutters_decorator = open_shutters_decorator() + def chain(*decorators): """Chain several decorators together into one decorator. @@ -31,13 +33,18 @@ def chain(*decorators): be the outermost decorator. """ + def decorator(f): for d in decorators: f = d(f) return f + return decorator -all_decorators = chain(shutter_suspend_decorator(), open_shutters_decorator(), baseline_decorator()) + +all_decorators = chain( + shutter_suspend_decorator(), open_shutters_decorator(), baseline_decorator() +) # Apply decorators to Haven plans align_motor = all_decorators(align_motor) @@ -58,6 +65,7 @@ def decorator(f): scan_nd = all_decorators(bp.scan_nd) # Remove foreign imports +del beamline del open_shutters_decorator del baseline_decorator del shutter_suspend_decorator diff --git a/src/haven/plans/_fly.py b/src/haven/plans/_fly.py index e86ccf97..8547d19f 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"] diff --git a/src/haven/plans/_xafs_scan.py b/src/haven/plans/_xafs_scan.py index d39f197c..40068f32 100644 --- a/src/haven/plans/_xafs_scan.py +++ b/src/haven/plans/_xafs_scan.py @@ -9,16 +9,12 @@ 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__) -log = logging.getLogger(__name__) - - __all__ = ["xafs_scan"] diff --git a/src/haven/positioner.py b/src/haven/positioner.py index c0656602..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, Locatable, Location +from bluesky.protocols import Locatable, Location, Movable, Stoppable from ophyd_async.core import ( CALCULATE_TIMEOUT, DEFAULT_TIMEOUT, @@ -16,7 +16,6 @@ observe_value, ) - log = logging.getLogger(__name__) @@ -75,7 +74,9 @@ def watch_done( done_event.set() async def locate(self) -> Location[int]: - setpoint, readback = await asyncio.gather(self.setpoint.get_value(), self.readback.get_value()) + setpoint, readback = await asyncio.gather( + self.setpoint.get_value(), self.readback.get_value() + ) location: Location = { "setpoint": setpoint, "readback": readback, diff --git a/src/haven/preprocessors/__init__.py b/src/haven/preprocessors/__init__.py index 80c75906..ff8c437a 100644 --- a/src/haven/preprocessors/__init__.py +++ b/src/haven/preprocessors/__init__.py @@ -1,4 +1,7 @@ -from .shutter_suspender import shutter_suspend_decorator, shutter_suspend_wrapper -from .baseline import baseline_wrapper, baseline_decorator -from .inject_metadata import inject_haven_md_wrapper -from .open_shutters import open_shutters_wrapper, open_shutters_decorator +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 index 3f88e67c..adbc5d9b 100644 --- a/src/haven/preprocessors/baseline.py +++ b/src/haven/preprocessors/baseline.py @@ -1,21 +1,9 @@ -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.utils import make_decorator -# from bluesky.suspenders import SuspendBoolLow -from bluesky.utils import Msg, make_decorator - -from haven._iconfig import load_config -from haven.exceptions import ComponentNotFound from haven.instrument import beamline log = logging.getLogger() diff --git a/src/haven/preprocessors/inject_metadata.py b/src/haven/preprocessors/inject_metadata.py index 4f5d497c..014fed23 100644 --- a/src/haven/preprocessors/inject_metadata.py +++ b/src/haven/preprocessors/inject_metadata.py @@ -4,15 +4,10 @@ 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 haven import __version__ as haven_version from haven._iconfig import load_config @@ -22,8 +17,6 @@ log = logging.getLogger() - - def get_version(pkg_name): return pkg_resources.get_distribution(pkg_name).version diff --git a/src/haven/preprocessors/open_shutters.py b/src/haven/preprocessors/open_shutters.py index 63ab5096..4d40b6af 100644 --- a/src/haven/preprocessors/open_shutters.py +++ b/src/haven/preprocessors/open_shutters.py @@ -3,8 +3,8 @@ from bluesky.utils import make_decorator from ophydregistry import Registry -from haven.instrument import beamline from haven.devices.shutter import ShutterState +from haven.instrument import beamline def _can_open(shutter): diff --git a/src/haven/preprocessors/shutter_suspender.py b/src/haven/preprocessors/shutter_suspender.py index efaeb28b..d02498a4 100644 --- a/src/haven/preprocessors/shutter_suspender.py +++ b/src/haven/preprocessors/shutter_suspender.py @@ -1,15 +1,6 @@ -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.preprocessors import finalize_wrapper # from bluesky.suspenders import SuspendBoolLow from bluesky.utils import Msg, make_decorator @@ -19,7 +10,6 @@ log = logging.getLogger() - def shutter_suspend_wrapper(plan, shutter_signals=None): """ Install suspenders to the RunEngine, and remove them at the end. diff --git a/src/haven/tests/test_auto_gain_plan.py b/src/haven/tests/test_auto_gain_plan.py index 7ce5b914..dfec7eee 100644 --- a/src/haven/tests/test_auto_gain_plan.py +++ b/src/haven/tests/test_auto_gain_plan.py @@ -5,7 +5,7 @@ import pytest from bluesky_adaptive.recommendations import NoRecommendation -from haven.plans import auto_gain, _auto_gain +from haven.plans import _auto_gain, auto_gain def test_plan_recommendations(ion_chamber): diff --git a/src/haven/tests/test_open_shutters.py b/src/haven/tests/test_open_shutters.py index 8302afcb..4a74e433 100644 --- a/src/haven/tests/test_open_shutters.py +++ b/src/haven/tests/test_open_shutters.py @@ -1,7 +1,5 @@ -from pprint import pprint - import pytest -from bluesky.plan_stubs import mv, repeat, trigger_and_read +from bluesky.plan_stubs import trigger_and_read from bluesky.plans import count from bluesky.protocols import Triggerable from ophyd_async.core import Device diff --git a/src/haven/tests/test_xia_pfcu.py b/src/haven/tests/test_xia_pfcu.py index bd4b74aa..5cd5ca23 100644 --- a/src/haven/tests/test_xia_pfcu.py +++ b/src/haven/tests/test_xia_pfcu.py @@ -3,12 +3,20 @@ import pytest from ophyd_async.testing import set_mock_value -from haven.devices.xia_pfcu import PFCUFilter, PFCUFilterBank, PFCUShutter, ShutterState, FilterPosition, FilterState +from haven.devices.xia_pfcu import ( + FilterState, + PFCUFilter, + PFCUFilterBank, + PFCUShutter, + ShutterState, +) @pytest.fixture() async def filter_bank(sim_registry): - bank = PFCUFilterBank(prefix="255id:pfcu4:", name="xia_filter_bank", shutters=[(1, 2)]) + bank = PFCUFilterBank( + prefix="255id:pfcu4:", name="xia_filter_bank", shutters=[(1, 2)] + ) await bank.connect(mock=True) sim_registry.register(bank) yield bank @@ -16,7 +24,7 @@ async def filter_bank(sim_registry): @pytest.fixture() async def shutter(filter_bank): - shutter =filter_bank.shutters[0] + shutter = filter_bank.shutters[0] await asyncio.gather( shutter.setpoint.connect(mock=False), shutter.readback.connect(mock=False), @@ -39,7 +47,10 @@ def test_shutter_devices(filter_bank): 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 ( + 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" @@ -72,7 +83,7 @@ async def test_shutter_reading(shutter): Needed for compatibility with the ``open_shutters_wrapper``. """ - # Set the shutter position + assert shutter.readback.name == shutter.name reading = await shutter.read() assert shutter.name in reading @@ -104,7 +115,6 @@ async def test_shutter_close(filter_bank, shutter): 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 b8965611..4bd02c55 100755 --- a/src/queueserver/queueserver_startup.py +++ b/src/queueserver/queueserver_startup.py @@ -9,24 +9,21 @@ from ophyd_async.core import NotConnected # Import plans -from haven import ( # noqa: F401 - beamline, recall_motor_position, sanitize_name, - -) +from haven import beamline, recall_motor_position, sanitize_name # noqa: F401 from haven.plans import ( # noqa: F401 auto_gain, - energy_scan, - record_dark_current, - set_energy, - xafs_scan, count, + energy_scan, grid_scan, list_scan, + record_dark_current, rel_grid_scan, rel_list_scan, rel_scan, scan, scan_nd, + set_energy, + xafs_scan, ) from haven.run_engine import run_engine # noqa: F401 From 54125055a97600358f5a106cb51ab0e355085f29 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Sat, 4 Jan 2025 12:25:58 -0600 Subject: [PATCH 13/15] Added documentation for shutters and filters. --- docs/topic_guides/index.rst | 11 +- docs/topic_guides/shutters_and_filters.rst | 197 +++++++++++++++++++++ src/haven/devices/__init__.py | 2 + src/haven/devices/shutter.py | 2 +- src/haven/devices/xia_pfcu.py | 2 + src/haven/iconfig_testing.toml | 2 +- src/haven/plans/__init__.py | 4 +- src/haven/preprocessors/open_shutters.py | 17 +- 8 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 docs/topic_guides/shutters_and_filters.rst 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/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/shutter.py b/src/haven/devices/shutter.py index 46668613..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__) diff --git a/src/haven/devices/xia_pfcu.py b/src/haven/devices/xia_pfcu.py index 8545e9da..2b9413ab 100644 --- a/src/haven/devices/xia_pfcu.py +++ b/src/haven/devices/xia_pfcu.py @@ -22,6 +22,8 @@ from haven.devices.signal import derived_signal_r, derived_signal_rw from haven.positioner import Positioner +__all__ = ["PFCUFilterBank", "PFCUFilter", "PFCUShutter"] + log = logging.getLogger(__name__) diff --git a/src/haven/iconfig_testing.toml b/src/haven/iconfig_testing.toml index b7f40879..a9e3b6a4 100644 --- a/src/haven/iconfig_testing.toml +++ b/src/haven/iconfig_testing.toml @@ -301,4 +301,4 @@ prefix = "255idc:pfcu0:" [[ pfcu4 ]] name = "filter_bank1" prefix = "255idc:pfcu1:" -shutters = [[3, 4]] +shutters = [[2, 3]] diff --git a/src/haven/plans/__init__.py b/src/haven/plans/__init__.py index a83c39ba..b1b47d09 100644 --- a/src/haven/plans/__init__.py +++ b/src/haven/plans/__init__.py @@ -23,8 +23,6 @@ from ._shutters import close_shutters, open_shutters # noqa: F401 from ._xafs_scan import xafs_scan -# open_shutters_decorator = open_shutters_decorator() - def chain(*decorators): """Chain several decorators together into one decorator. @@ -35,7 +33,7 @@ def chain(*decorators): """ def decorator(f): - for d in decorators: + for d in reversed(decorators): f = d(f) return f diff --git a/src/haven/preprocessors/open_shutters.py b/src/haven/preprocessors/open_shutters.py index 4d40b6af..42c531ec 100644 --- a/src/haven/preprocessors/open_shutters.py +++ b/src/haven/preprocessors/open_shutters.py @@ -6,6 +6,8 @@ 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( @@ -28,12 +30,19 @@ def open_shutters_wrapper(plan, registry: Registry | None = None): 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 + 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 From 997cdd44bea2b414b4439607d1f025d4f0d9ce6e Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Sun, 5 Jan 2025 00:31:37 -0600 Subject: [PATCH 14/15] Fixed a numpy precision issue in a test. --- src/haven/tests/test_energy_xafs_scan.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/haven/tests/test_energy_xafs_scan.py b/src/haven/tests/test_energy_xafs_scan.py index 592ffc47..4b204125 100644 --- a/src/haven/tests/test_energy_xafs_scan.py +++ b/src/haven/tests/test_energy_xafs_scan.py @@ -184,12 +184,12 @@ def test_exafs_k_range(mono_motor, exposure_motor, I0): real_energies = [ i.args[0] for i in scan_list if i[0] == "set" and i.obj.name == "mono_energy" ] - np.testing.assert_equal(real_energies, expected_energies) + np.testing.assert_almost_equal(real_energies, expected_energies) # Check that the exposure is set correctly real_exposures = [ i.args[0] for i in scan_list if i[0] == "set" and i.obj.name == "exposure" ] - np.testing.assert_equal(real_exposures, expected_exposures) + np.testing.assert_almost_equal(real_exposures, expected_exposures) def test_named_E0(mono_motor, exposure_motor, I0): From 564e4dc41b4c4bcc5aef94ec98083e3dc8295170 Mon Sep 17 00:00:00 2001 From: Mark Wolfman Date: Sat, 11 Jan 2025 20:45:45 -0600 Subject: [PATCH 15/15] Fixed tests so they pass with pytest-asyncio==0.25.2 --- src/firefly/tests/test_controller.py | 19 ++++++++++--------- src/firefly/tests/test_line_scan_window.py | 5 ++--- 2 files changed, 12 insertions(+), 12 deletions(-) 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,