Skip to content
Open
Changes from 16 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7a4946f
feat(shutters): add `shutters.py`
joao-biondo Mar 17, 2026
4e5c8a3
feat(shutters): add firs idea for shutters devices.
joao-biondo Mar 17, 2026
867f197
feat(shutters): add docstrings for the shutter devices.
joao-biondo Mar 17, 2026
9cf44ce
fix(shutters): set correct `ophyd.Kind` for components.
joao-biondo Mar 17, 2026
7de535a
chore(shutters): set parameter `type` for the `__init__` method of sh…
joao-biondo Mar 17, 2026
b0135b4
feat(shutters): overwrite `read_configuration` method for dealing wit…
joao-biondo Mar 17, 2026
e49b4a5
fix(shutters): swap names from the two shutter classes.
joao-biondo Mar 23, 2026
88df15c
chore(shutters): make docstring example of usage simpler.
joao-biondo Mar 23, 2026
f9aa1c1
fix(shutters): fix typos in shutters class.
joao-biondo Mar 30, 2026
b338fe3
refactor(shutters): change `ophyd.Kind` of permission signal to "omit…
joao-biondo Mar 30, 2026
28726b1
refactor(shutters): fix the actuation PVs shuffixes `OPENCLOSE`, `OPE…
joao-biondo Apr 22, 2026
5c75b25
fix(shutters): fix typo in docstring.
joao-biondo Apr 22, 2026
e52a7d8
refactor(shutters): instantiate `permission` as attribute if `permis…
joao-biondo Apr 23, 2026
3ac7d59
refactor(shutters): add `permission_flag` inside `set` method and `co…
joao-biondo Apr 23, 2026
f769d15
feat(shutters): add `_is_closed` method to check the status of photon…
joao-biondo Apr 24, 2026
b227be8
chore: fix typo in docstring.
joao-biondo Apr 24, 2026
9f659d8
refactor(shutters): make the permission check for the `set` method si…
joao-biondo Apr 27, 2026
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
199 changes: 199 additions & 0 deletions src/sophys/common/devices/shutters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
from ophyd import EpicsSignal, EpicsSignalRO, FormattedComponent, Device
from ophyd.pv_positioner import PVPositionerComparator
from ..utils.status import PremadeStatus
from ophyd.status import AndStatus, SubscriptionStatus


Comment thread
joao-biondo marked this conversation as resolved.
class ShutterToggle(PVPositionerComparator):
"""
Comment thread
joao-biondo marked this conversation as resolved.
Abstraction layer for shutters with one actuation PV (OPENCLOSE) and one readback PV (PG_STATUS). There's an optional parameter for a permission PV.

Parameters
----------
prefix: str
Prefix for the shutter's PVs.

setpoint_suffix: str
Suffix for the actuation PV. NOTE: This should be place/location of the shutter, e.g. OEA/FOE.
The PV will be formatted as "{prefix}{setpoint_suffix}OPENCLOSE".

readback_suffix: str
Suffix for the readback PV, e.g. PG_STATUS

permission_suffix: str, optional
Permission PV string, if it exists.

NOTE
----
This implemantation considers that the value of the `readback` signal is 0 for an open shutter and 1 for a closed shutter.
This is not so intuitive, so the `set` method considers that 1 is for opening and 0 for closing the shutter.

Usage Example
-------------
>>> shutter = ShutterOpenClose(prefix="prefix", setpoint_suffix="setpoint_suffix", readback="readback_suffix", name="shutter")
>>> shutter.set(0).wait() # for closing
>>> shutter.set(1).wait() # for opening
"""

real_setpoint = None
setpoint = FormattedComponent(
EpicsSignal, "{prefix}{setpoint_suffix}OPENCLOSE", kind="config"
)
readback = FormattedComponent(
EpicsSignalRO, "{prefix}{readback_suffix}", kind="hinted"
)

def __init__(
self,
*args,
setpoint_suffix: str,
readback_suffix: str,
permission_pv: str = None,
**kwargs,
):
self.setpoint_suffix = setpoint_suffix
self.readback_suffix = readback_suffix
self.permission_pv = permission_pv
super().__init__(*args, **kwargs)
if self.permission_pv is not None:
self.permission = EpicsSignalRO(f"{self.permission_pv}", name="permission")
self.permission_flag = True
else:
self.permission_flag = False

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if self.permission_pv is not None:
self.permission = EpicsSignalRO(f"{self.permission_pv}", name="permission")
self.permission_flag = True
else:
self.permission_flag = False
if self.pemission_pv is not None:
self.permission = EpicsSignalRO(f"{self.permission_pv}", name="permission")

then to check permission_flag:

if hasattr(self, "permission"):

It would also make sense to me to change self.permission_pv to self._permission_pv_name and self.permission to self.permission_signal.


def set(self, value, *args, **kwargs):
if self.permission_flag:
try:
if not self.permission.get(connection_timeout=2, **kwargs):
raise PremadeStatus(
success=False,
exception=PermissionError(
f"Shutter open permission is denied: {self.permission.pvname} {self.permission.get()}"
),
)
except TimeoutError:
raise

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
try:
if not self.permission.get(connection_timeout=2, **kwargs):
raise PremadeStatus(
success=False,
exception=PermissionError(
f"Shutter open permission is denied: {self.permission.pvname} {self.permission.get()}"
),
)
except TimeoutError:
raise
permission_status = self.permission.get(connection_timeout=2, **kwargs)
if not permission_status:
raise PremadeStatus(
success=False,
exception=PermissionError(
f"Shutter open permission is denied: {self.permission.pvname} {permission_status}"
),
)

No need for the try ... except wrap if all you're doing is re-raising the exception. This would also avoid getting the value twice, the second time with DEFAULT_CONNECTION_TIMEOUT, which is normally like 10s.

if (
value == self.readback.get()
): # Since we're swapping the readback values (0 for closing and 1 for opening), we actuate when value == readback
self.real_setpoint = 1 if value == 0 else 0
return super().set(1, *args, **kwargs)
else:
return PremadeStatus(success=True)

def done_comparator(self, readback, setpoint):
return self.real_setpoint == readback

Comment thread
joao-biondo marked this conversation as resolved.

class ShutterOpenClose(Device):
"""
Abstraction layer for shutters with two actuation PV (OPEN and CLOSE) and two readback PV (PS_STATUS and GS_STATUS). There's an optional parameter for a permission PV.

Parameters
----------
prefix: str
Prefix for the shutter's PVs.

shutter_suffix: str
Suffix for the OPEN and CLOSE PVs. NOTE: This should be place/location of the shutter, e.g. OEA/FOE.
The PVs will be formatted as "{prefix}{shutter_suffix}OPEN" and "{prefix}{shutter_suffix}CLOSE".

ps_suffix: str
Suffix for one readback PVs, e.g. PS_STATUS

gs_suffix: str
Suffix for the second readback PV, e.g. GS_STATUS

permission_pv: str, optional
Permission PV string, if it exists.

NOTES
-----
This implemantation considers that the value of the `readback` signal is 0 for an open shutter and 1 for a closed shutter.
This is not so intuitive, so the `set` method considers that 1 is for opening and 0 for closing the shutter.

The `return` of the `set` method is an `AndStatus` with both `readback` signals.

There's a `done_comparator` method that returns the state of the shutter, based in the two `readback` PVs. This method is
used as the `callback` for both `readback` signals.

Usage Example
-------------
>>> shutter = ShutterToggle(prefix="prefix", open_suffix="open_suffix", close_suffix="close_suffix", ps_suffix="ps_suffix", gs_suffix="gs_suffix", name="shutter")
>>> shutter.set(0).wait() # for closing
>>> shutter.set(1).wait() # for opening
"""

setpoint = None
photon_status = FormattedComponent(
EpicsSignalRO, "{prefix}{ps_suffix}", kind="hinted"
)
gamma_status = FormattedComponent(
EpicsSignalRO, "{prefix}{gs_suffix}", kind="hinted"
)
open = FormattedComponent(
EpicsSignal, "{prefix}{shutter_suffix}OPEN", kind="config"
)
close = FormattedComponent(
EpicsSignal, "{prefix}{shutter_suffix}CLOSE", kind="config"
)

def __init__(
self,
*args,
shutter_suffix: str,
ps_suffix: str,
gs_suffix: str,
permission_pv: str = None,
**kwargs,
):
self.shutter_suffix = shutter_suffix
self.ps_suffix = ps_suffix
self.gs_suffix = gs_suffix
self.permission_pv = permission_pv
super().__init__(*args, **kwargs)
if self.permission_pv is not None:
self.permission = EpicsSignalRO(f"{self.permission_pv}", name="permission")
self.permission_flag = True
else:
self.permission_flag = False

def set(self, value, *args, **kwargs):
if self.permission_flag:
try:
if not self.permission.get(connection_timeout=2, **kwargs):
raise PremadeStatus(
success=False,
exception=PermissionError(
f"Shutter open permission is denied: {self.permission.pvname} {self.permission.get()}"
),
)
except TimeoutError:
raise

if value == 0 and not self._is_closed():
self.close.set(1, *args, **kwargs).wait()

elif value == 1 and self._is_closed():
self.open.set(1, *args, **kwargs).wait()

else:
return PremadeStatus(success=True)

self.setpoint = value

return AndStatus(
SubscriptionStatus(self.photon_status, self.done_comparator, settle_time=3),
SubscriptionStatus(self.gamma_status, self.done_comparator, settle_time=3),
timeout=15,
)

def _is_closed(self):
"""Check wheter the shutter is open or closed given the photon and gamma status PVs."""
return (self.photon_status.get() == 1) and (
self.gamma_status.get() == 1
) # NOTE: if one of the status is equal to zero, the shutter can be partially open

def done_comparator(self, value, **kwargs):
is_closed = self._is_closed()
return is_closed if self.setpoint == 0 else not is_closed
Loading