Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Async shutters #277

Merged
merged 5 commits into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/firefly/run_browser.ui
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@
<item>
<widget class="QLabel" name="sleep_label">
<property name="text">
<string>&lt;- Press the button</string>
<string> Press the button</string>
</property>
</widget>
</item>
Expand Down
4 changes: 2 additions & 2 deletions src/firefly/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ def add_shutter_widgets(self):
for shutter in shutters[::-1]:
# Add a layout with the buttons
layout = QHBoxLayout()
name = shutter.attr_name if shutter.attr_name != "" else shutter.name
label = name_to_title(name) + ":"
label = name_to_title(shutter.name) + ":"
form.insertRow(row_idx, label, layout)
# Indicator to show if the shutter is open
indicator = PyDMByteIndicator(
Expand All @@ -62,6 +61,7 @@ def add_shutter_widgets(self):
relative=False,
init_channel=f"haven://{shutter.name}.setpoint",
)
print(f"{shutter.name} - {getattr(shutter, 'allow_open', True)=}")
open_btn.setEnabled(getattr(shutter, "allow_open", True))
layout.addWidget(open_btn)
# Button to close the shutter
Expand Down
46 changes: 3 additions & 43 deletions src/haven/devices/motor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,57 +4,17 @@
from apstools.utils.misc import safe_ophyd_name
from ophyd import Component as Cpt
from ophyd import EpicsMotor, EpicsSignal, EpicsSignalRO, Kind
from ophyd_async.core import (
CALCULATE_TIMEOUT,
DEFAULT_TIMEOUT,
AsyncStatus,
CalculatableTimeout,
ConfigSignal,
SignalBackend,
SignalX,
SubsetEnum,
)
from ophyd_async.core import DEFAULT_TIMEOUT, ConfigSignal, SubsetEnum
from ophyd_async.epics.motor import Motor as MotorBase
from ophyd_async.epics.signal import epics_signal_r, epics_signal_rw
from ophyd_async.epics.signal._signal import _epics_signal_backend
from ophydregistry import Registry

from .motor_flyer import MotorFlyer
from .signal import epics_signal_xval

log = logging.getLogger(__name__)


class SignalX(SignalX):
trigger_value = 1

def __init__(self, *args, trigger_value=1, **kwargs):
self.trigger_value = trigger_value
super().__init__(*args, **kwargs)

def trigger(
self, wait=False, timeout: CalculatableTimeout = CALCULATE_TIMEOUT
) -> AsyncStatus:
"""Trigger the action and return a status saying when it's done"""
if timeout is CALCULATE_TIMEOUT:
timeout = self._timeout
coro = self._backend.put(self.trigger_value, wait=wait, timeout=timeout)
return AsyncStatus(coro)


def epics_signal_x(write_pv: str, name: str = "", trigger_value=1) -> SignalX:
"""Create a `SignalX` backed by 1 EPICS PVs

Parameters
----------
write_pv:
The PV to write its initial value to on trigger
trigger_value:
The value to send to the write PV.
"""
backend: SignalBackend = _epics_signal_backend(None, write_pv, write_pv)
return SignalX(backend, name=name, trigger_value=trigger_value)


class Motor(MotorBase):
"""The default motor for asynchrnous movement."""

Expand Down Expand Up @@ -94,7 +54,7 @@ def __init__(
# Load all the parent signals
super().__init__(prefix=prefix, name=name)
# Override the motor stop signal to use the right trigger value
self.motor_stop = epics_signal_x(f"{prefix}.STOP")
self.motor_stop = epics_signal_xval(f"{prefix}.STOP")
self.set_name(self.name)

async def connect(
Expand Down
57 changes: 34 additions & 23 deletions src/haven/devices/shutter.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import logging
import warnings
from enum import IntEnum, unique
from typing import Mapping

from ophyd import Component as Cpt
from ophyd import EpicsSignal, EpicsSignalRO
from ophyd.pv_positioner import PVPositionerIsClose
from ophyd.utils.errors import ReadOnlyError
from pcdsdevices.signal import MultiDerivedSignal
from pcdsdevices.type_hints import SignalToValue
from ophyd_async.core import soft_signal_rw
from ophyd_async.epics.signal import epics_signal_r

from ..positioner import Positioner
from .signal import derived_signal_rw, epics_signal_xval

# from apstools.devices.shutters import ApsPssShutterWithStatus as Shutter

Expand All @@ -23,7 +24,8 @@ class ShutterState(IntEnum):
UNKNOWN = 4 # 0b100


class PssShutter(PVPositionerIsClose):
class PssShutter(Positioner):
_ophyd_labels_ = {"shutters"}
_last_setpoint: int = ShutterState.UNKNOWN
allow_open: bool
allow_close: bool
Expand All @@ -39,7 +41,25 @@ def __init__(
):
self.allow_open = allow_open
self.allow_close = allow_close
super().__init__(prefix=prefix, name=name, labels=labels, **kwargs)
# Actuators for opening/closing the shutter
self.open_signal = epics_signal_xval(f"{prefix}OpenEPICSC")
self.close_signal = epics_signal_xval(f"{prefix}CloseEPICSC")
# Just use convenient values for these 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)
# Positioner signals for moving the shutter
self.readback = epics_signal_r(bool, f"{prefix}BeamBlockingM.VAL")
self.setpoint = derived_signal_rw(
int,
derived_from={
"open_signal": self.open_signal,
"close_signal": self.close_signal,
},
forward=self._actuate_shutter,
inverse=self._shutter_setpoint,
)
super().__init__(name=name, **kwargs)

def check_value(self, pos):
"""Check that the shutter has the right permissions."""
Expand All @@ -52,20 +72,21 @@ def check_value(self, pos):
f"Shutter {self.name} is not permitted to be opened per iconfig.toml. Set `allow_open` for this shutter."
)

def _actuate_shutter(self, mds: MultiDerivedSignal, value: int) -> SignalToValue:
async def _actuate_shutter(self, value: int, open_signal, close_signal) -> Mapping:
"""Open/close the shutter using derived-from signals."""
self.check_value(value)
if value == ShutterState.OPEN:
items = {self.open_signal: 1}
items = {open_signal: 1}
elif value == ShutterState.CLOSED:
items = {self.close_signal: 1}
items = {close_signal: 1}
else:
raise ValueError(f"Invalid shutter state for {self}")
return items

def _shutter_setpoint(self, mds: MultiDerivedSignal, items: SignalToValue) -> int:
def _shutter_setpoint(self, values: Mapping, open_signal, close_signal) -> int:
"""Determine whether the shutter was last opened or closed."""
do_open = items[self.open_signal]
do_close = items[self.close_signal]
do_open = values[open_signal]
do_close = values[close_signal]
if do_open and do_close:
# Shutter is both opening and closing??
warnings.warn("Unknown shutter setpoint")
Expand All @@ -76,16 +97,6 @@ def _shutter_setpoint(self, mds: MultiDerivedSignal, items: SignalToValue) -> in
self._last_setpoint = ShutterState.CLOSED
return self._last_setpoint

readback = Cpt(EpicsSignalRO, "BeamBlockingM.VAL")
setpoint = Cpt(
MultiDerivedSignal,
attrs=["open_signal", "close_signal"],
calculate_on_put=_actuate_shutter,
calculate_on_get=_shutter_setpoint,
)
open_signal = Cpt(EpicsSignal, "OpenEPICSC", kind="omitted")
close_signal = Cpt(EpicsSignal, "CloseEPICSC", kind="omitted")


# -----------------------------------------------------------------------------
# :author: Mark Wolfman
Expand Down
50 changes: 49 additions & 1 deletion src/haven/devices/signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@
import numpy as np
from bluesky.protocols import Reading, Subscribable
from ophyd_async.core import (
CALCULATE_TIMEOUT,
DEFAULT_TIMEOUT,
AsyncStatus,
CalculatableTimeout,
ReadingValueCallback,
SignalBackend,
SignalMetadata,
SignalR,
SignalRW,
SignalX,
SoftSignalBackend,
T,
)
from ophyd_async.epics.signal._signal import _epics_signal_backend


class DerivedSignalBackend(SoftSignalBackend):
Expand Down Expand Up @@ -195,8 +200,17 @@ async def get_reading(self) -> Reading:
return self.combine_readings(readings)

async def get_value(self) -> T:
# Sort out which types of signals we have
gettable_signals = [
sig for sig in self._derived_from.values() if hasattr(sig, "get_value")
]
# Retrieve current values from signals
values = {sig: (await sig.get_value()) for sig in self._derived_from.values()}
values = await asyncio.gather(*(sig.get_value() for sig in gettable_signals))
values = {sig: val for sig, val in zip(gettable_signals, values)}
# Set default value of None for missing signals
for sig in self._derived_from.values():
values.setdefault(sig, None)
# Compute the new value
new_value = self.inverse(values, **self._derived_from)
return self.converter.value(new_value)

Expand Down Expand Up @@ -417,3 +431,37 @@ def __init__(self, prefix, name="", **kwargs):
)
signal = SignalX(backend, name=name)
return signal


class SignalXVal(SignalX):
trigger_value = 1

def __init__(self, *args, trigger_value=1, **kwargs):
self.trigger_value = trigger_value
super().__init__(*args, **kwargs)

def trigger(
self, wait=False, timeout: CalculatableTimeout = CALCULATE_TIMEOUT
) -> AsyncStatus:
"""Trigger the action and return a status saying when it's done"""
if timeout is CALCULATE_TIMEOUT:
timeout = self._timeout
coro = self._backend.put(self.trigger_value, wait=wait, timeout=timeout)
return AsyncStatus(coro)


def epics_signal_xval(write_pv: str, name: str = "", trigger_value=1) -> SignalXVal:
"""Create a `SignalX` backed by 1 EPICS PVs. This differs from the
standard ophyd-async trigger in that it accepts a prescribed
*trigger_value* that will be sent to the PV when triggered.

Parameters
----------
write_pv:
The PV to write its initial value to on trigger
trigger_value:
The value to send to the write PV.

"""
backend: SignalBackend = _epics_signal_backend(None, write_pv, write_pv)
return SignalXVal(backend, name=name, trigger_value=trigger_value)
1 change: 0 additions & 1 deletion src/haven/iconfig_testing.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ prefix = "S255ID-PSS:FES:"
allow_close = false
# allow_open = true # Default


[[ pss_shutter ]]
name = "hutch_shutter"
prefix = "S255ID-PSS:SCS:"
Expand Down
2 changes: 1 addition & 1 deletion src/haven/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ async def load(
"aerotech_stage": AerotechStage,
"motor": Motor,
"energy": EnergyPositioner,
"pss_shutter": PssShutter,
# Threaded ophyd devices
"blade_slits": BladeSlits,
"aperture_slits": ApertureSlits,
Expand All @@ -387,7 +388,6 @@ async def load(
"synchrotron": ApsMachine,
"robot": Robot,
"pfcu4": PFCUFilterBank, # <-- fails if mocked
"pss_shutter": PssShutter,
"xspress": make_xspress_device,
"dxp": make_dxp_device,
"beamline_manager": BeamlineManager,
Expand Down
10 changes: 5 additions & 5 deletions src/haven/positioner.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import asyncio
import logging
from functools import partial
import warnings
from functools import partial

import numpy as np
from bluesky.protocols import Movable, Stoppable
Expand Down Expand Up @@ -107,12 +107,12 @@ async def set(self, value: float, timeout: CalculatableTimeout = CALCULATE_TIMEO
self.watch_done, done_event=done_event, started_event=started_event
)
)
done_status = AsyncStatus(asyncio.wait_for(done_event.wait(), timeout))
aws = asyncio.gather(done_event.wait(), set_status)
done_status = AsyncStatus(asyncio.wait_for(aws, timeout))
else:
# Monitor based on readback position
done_status = AsyncStatus(
asyncio.wait_for(reached_setpoint.wait(), timeout)
)
aws = asyncio.gather(reached_setpoint.wait(), set_status)
done_status = AsyncStatus(asyncio.wait_for(aws, timeout))
# Monitor the position of the readback value
async for current_position in observe_value(
self.readback, done_status=done_status
Expand Down
Loading