From 0933fc4cb35b0178561c032800c6e051052042df Mon Sep 17 00:00:00 2001 From: Emily Arnold Date: Tue, 24 Mar 2026 10:02:53 +0000 Subject: [PATCH 1/5] add hutch_shutter --- src/dodal/beamlines/i15_1.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/dodal/beamlines/i15_1.py b/src/dodal/beamlines/i15_1.py index 54e6844921..37b6f208dc 100644 --- a/src/dodal/beamlines/i15_1.py +++ b/src/dodal/beamlines/i15_1.py @@ -9,6 +9,7 @@ from dodal.devices.beamlines.i15_1.attenuator import Attenuator from dodal.devices.beamlines.i15_1.puck_detector import PuckDetect from dodal.devices.beamlines.i15_1.robot import Robot +from dodal.devices.hutch_shutter import HutchInterlock, InterlockedHutchShutter from dodal.devices.motors import XYPhiStage, XYStage, YZStage from dodal.devices.slits import Slits from dodal.devices.synchrotron import Synchrotron @@ -176,3 +177,11 @@ def puck_detect() -> PuckDetect: @devices.factory() def attenuator() -> Attenuator: return Attenuator(f"{PREFIX.beamline_prefix}-OP-ATTN-02:") + + +@devices.factory() +def hutch_shutter() -> InterlockedHutchShutter: + return InterlockedHutchShutter( + PREFIX.beamline_prefix, + HutchInterlock(bl_prefix="BL15I", interlock_suffix="-PS-IOC-02:M14:LOP"), + ) From 1b7738fd584b7b65a268b1270a3e159dd13b5f38 Mon Sep 17 00:00:00 2001 From: Emily Arnold Date: Wed, 25 Mar 2026 17:47:37 +0000 Subject: [PATCH 2/5] add PLCShutterInterlock and hutch_interlock --- src/dodal/beamlines/i15_1.py | 17 ++++++++++--- src/dodal/devices/hutch_shutter.py | 38 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/dodal/beamlines/i15_1.py b/src/dodal/beamlines/i15_1.py index 37b6f208dc..1b635706c7 100644 --- a/src/dodal/beamlines/i15_1.py +++ b/src/dodal/beamlines/i15_1.py @@ -9,7 +9,11 @@ from dodal.devices.beamlines.i15_1.attenuator import Attenuator from dodal.devices.beamlines.i15_1.puck_detector import PuckDetect from dodal.devices.beamlines.i15_1.robot import Robot -from dodal.devices.hutch_shutter import HutchInterlock, InterlockedHutchShutter +from dodal.devices.hutch_shutter import ( + HutchInterlock, + InterlockedHutchShutter, + PLCShutterInterlock, +) from dodal.devices.motors import XYPhiStage, XYStage, YZStage from dodal.devices.slits import Slits from dodal.devices.synchrotron import Synchrotron @@ -179,9 +183,16 @@ def attenuator() -> Attenuator: return Attenuator(f"{PREFIX.beamline_prefix}-OP-ATTN-02:") +@devices.factory() +def hutch_interlock() -> HutchInterlock: + return HutchInterlock(bl_prefix="BL15I", interlock_suffix="-PS-IOC-02:M11:LOP") + + @devices.factory() def hutch_shutter() -> InterlockedHutchShutter: return InterlockedHutchShutter( - PREFIX.beamline_prefix, - HutchInterlock(bl_prefix="BL15I", interlock_suffix="-PS-IOC-02:M14:LOP"), + bl_prefix=PREFIX.beamline_prefix, + interlock=PLCShutterInterlock( + bl_prefix=PREFIX.beamline_prefix, interlock_suffix="-PS-SHTR-01:ILKSTA" + ), ) diff --git a/src/dodal/devices/hutch_shutter.py b/src/dodal/devices/hutch_shutter.py index 9243fb25d9..563ae8fd83 100644 --- a/src/dodal/devices/hutch_shutter.py +++ b/src/dodal/devices/hutch_shutter.py @@ -43,6 +43,13 @@ class ShutterState(StrictEnum): CLOSING = "Closing" +class InterlockState(StrictEnum): + FAILED = "Failed" + RUN_ILKS_OK = "Run Ilks Ok" + OK = "OK" + DISARMED = "Disarmed" + + class BaseHutchInterlock(ABC, StandardReadable): status: SignalR[float | EnumTypes] bl_prefix: str @@ -95,6 +102,37 @@ async def shutter_safe_to_operate(self) -> bool: return isclose(float(interlock_state), HUTCH_SAFE_FOR_OPERATIONS, abs_tol=5e-2) +class PLCShutterInterlock(BaseHutchInterlock): + """Device to check the interlock state of the shutter using PLC pv.""" + + def __init__( + self, + bl_prefix: str, + shtr_infix: str = "", + interlock_suffix: str = EXP_SHUTTER_1_INFIX, + name: str = "", + ) -> None: + super().__init__( + signal_type=InterlockState, + bl_prefix=bl_prefix, + interlock_infix=shtr_infix, + interlock_suffix=interlock_suffix, + name=name, + ) + + # TODO replace with read + # See https://github.com/DiamondLightSource/dodal/issues/651 + # TODO check if shutter only opens when Ilks are "OK" + async def shutter_safe_to_operate(self) -> bool: + """If the status value is 0, hutch has been searched and locked and it is safe \ + to operate the shutter. + If the status value is not 0 (usually set to 7), the hutch is open and the \ + shutter should not be in use. + """ + interlock_state = await self.status.get_value() + return interlock_state == InterlockState.OK + + class BaseHutchShutter(ABC, StandardReadable, Movable[ShutterDemand]): """Device to operate the hutch shutter. From 020eccdf134fc8e0c1dd2098cd3ed314d5fa9450 Mon Sep 17 00:00:00 2001 From: Emily Arnold Date: Thu, 26 Mar 2026 11:50:22 +0000 Subject: [PATCH 3/5] doc string update and add tests --- src/dodal/devices/hutch_shutter.py | 14 ++++---- tests/devices/test_hutch_shutter.py | 56 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/dodal/devices/hutch_shutter.py b/src/dodal/devices/hutch_shutter.py index 563ae8fd83..b000d5b331 100644 --- a/src/dodal/devices/hutch_shutter.py +++ b/src/dodal/devices/hutch_shutter.py @@ -122,15 +122,17 @@ def __init__( # TODO replace with read # See https://github.com/DiamondLightSource/dodal/issues/651 - # TODO check if shutter only opens when Ilks are "OK" + # TODO check if shutter only opens when Ilks are "OK" or if "Run Ilks Ok" is also healthy async def shutter_safe_to_operate(self) -> bool: - """If the status value is 0, hutch has been searched and locked and it is safe \ - to operate the shutter. - If the status value is not 0 (usually set to 7), the hutch is open and the \ - shutter should not be in use. + """If the status value is OK, shutter is safe to operate. + + If the status value is not OK (Failed or Disarmed), the shutter cannot be + operated. """ interlock_state = await self.status.get_value() - return interlock_state == InterlockState.OK + return (interlock_state == InterlockState.OK) | ( + interlock_state == InterlockState.RUN_ILKS_OK + ) class BaseHutchShutter(ABC, StandardReadable, Movable[ShutterDemand]): diff --git a/tests/devices/test_hutch_shutter.py b/tests/devices/test_hutch_shutter.py index 8d31a3116e..345586dd3d 100644 --- a/tests/devices/test_hutch_shutter.py +++ b/tests/devices/test_hutch_shutter.py @@ -16,6 +16,8 @@ HutchInterlock, HutchShutter, InterlockedHutchShutter, + InterlockState, + PLCShutterInterlock, ShutterDemand, ShutterNotSafeToOperateError, ShutterState, @@ -29,6 +31,13 @@ async def interlock() -> HutchInterlock: return interlock +@pytest.fixture +async def plc_interlock() -> PLCShutterInterlock: + async with init_devices(mock=True): + interlock = PLCShutterInterlock(bl_prefix="TEST") + return interlock + + def _apply_status_setter(abstract_shutter: BaseHutchShutter): def set_status(value: ShutterDemand, *args, **kwargs): value_sta = ShutterState.OPEN if value == "Open" else ShutterState.CLOSED @@ -51,6 +60,20 @@ async def fake_interlocked_shutter( return interlocked_shutter +@pytest.fixture +async def fake_plc_interlocked_shutter( + plc_interlock: PLCShutterInterlock, +) -> InterlockedHutchShutter: + async with init_devices(mock=True): + interlocked_shutter = InterlockedHutchShutter( + bl_prefix="TEST", interlock=plc_interlock + ) + + _apply_status_setter(interlocked_shutter) + + return interlocked_shutter + + @pytest.fixture async def fake_shutter_without_interlock() -> HutchShutter: async with init_devices(mock=True): @@ -70,6 +93,12 @@ def test_interlocked_shutter_can_be_created( assert isinstance(fake_interlocked_shutter, BaseHutchShutter) +def test_plc_interlocked_shutter_can_be_created( + fake_plc_interlocked_shutter: InterlockedHutchShutter, +): + assert isinstance(fake_plc_interlocked_shutter, BaseHutchShutter) + + async def test_interlock_is_readable(interlock: HutchInterlock): await assert_reading( interlock, @@ -79,6 +108,15 @@ async def test_interlock_is_readable(interlock: HutchInterlock): ) +async def test_plc_interlock_is_readable(plc_interlock: PLCShutterInterlock): + await assert_reading( + plc_interlock, + { + f"{plc_interlock.name}-status": partial_reading(InterlockState.FAILED), + }, + ) + + @pytest.mark.parametrize( "readback, expected_state", [ @@ -96,6 +134,24 @@ async def test_hutch_interlock_safe_to_operate_logic( assert await interlock.shutter_safe_to_operate() is expected_state +@pytest.mark.parametrize( + "readback, expected_state", + [ + (InterlockState.OK, True), + (InterlockState.RUN_ILKS_OK, True), + (InterlockState.DISARMED, False), + (InterlockState.FAILED, False), + ], +) +async def test_plc_shutter_interlock_safe_to_operate_logic( + plc_interlock: PLCShutterInterlock, + readback: float, + expected_state: bool, +): + set_mock_value(plc_interlock.status, readback) + assert await plc_interlock.shutter_safe_to_operate() is expected_state + + async def test_shutter_readable(fake_shutter_without_interlock: HutchShutter): result = { f"{fake_shutter_without_interlock.name}-status": partial_reading( From 5e9d35f7fc351324f7bb7fbb7c058bad971b4cc6 Mon Sep 17 00:00:00 2001 From: Emily Arnold <222046505+EmsArnold@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:28:12 +0000 Subject: [PATCH 4/5] update plc interlock docstring --- src/dodal/devices/hutch_shutter.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/dodal/devices/hutch_shutter.py b/src/dodal/devices/hutch_shutter.py index b000d5b331..b44d066915 100644 --- a/src/dodal/devices/hutch_shutter.py +++ b/src/dodal/devices/hutch_shutter.py @@ -124,10 +124,13 @@ def __init__( # See https://github.com/DiamondLightSource/dodal/issues/651 # TODO check if shutter only opens when Ilks are "OK" or if "Run Ilks Ok" is also healthy async def shutter_safe_to_operate(self) -> bool: - """If the status value is OK, shutter is safe to operate. + """If the status value is OK or Run Ilk OK, shutter is open or safe to operate. - If the status value is not OK (Failed or Disarmed), the shutter cannot be - operated. + If the status value is "OK", valve or shutter is open and interlocks are OK to + operate. If the status value is "Run Ilks Ok", the opening action can be + started. If the status value is not OK ("Failed" or "Disarmed"), interlocks have + failed and the shutter cannot be operated. Disarmed status applies only to fast + shutters. """ interlock_state = await self.status.get_value() return (interlock_state == InterlockState.OK) | ( From af29b2dbfb061ffac803ab16661acb21662a66f8 Mon Sep 17 00:00:00 2001 From: Emily Arnold <222046505+EmsArnold@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:16:01 +0000 Subject: [PATCH 5/5] Remove todo which was already done --- src/dodal/devices/hutch_shutter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/dodal/devices/hutch_shutter.py b/src/dodal/devices/hutch_shutter.py index b44d066915..a208af9603 100644 --- a/src/dodal/devices/hutch_shutter.py +++ b/src/dodal/devices/hutch_shutter.py @@ -122,7 +122,6 @@ def __init__( # TODO replace with read # See https://github.com/DiamondLightSource/dodal/issues/651 - # TODO check if shutter only opens when Ilks are "OK" or if "Run Ilks Ok" is also healthy async def shutter_safe_to_operate(self) -> bool: """If the status value is OK or Run Ilk OK, shutter is open or safe to operate.