Skip to content
21 changes: 19 additions & 2 deletions src/dodal/beamlines/i05.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
from dodal.devices.beamlines.i05 import I05Goniometer
from dodal.devices.beamlines.i05_shared import LensMode, M4M5Mirror, PassEnergy
from dodal.devices.common_mirror import XYZSwitchingMirror
from dodal.devices.electron_analyser.mbs import MbsDetector
from dodal.devices.electron_analyser.mbs import (
EntranceSlitInformationDevice,
MbsDetector,
)
from dodal.devices.hutch_shutter import HutchShutter
from dodal.devices.pgm import PlaneGratingMonochromator
from dodal.devices.temperture_controller import Lakeshore336
Expand Down Expand Up @@ -51,10 +54,24 @@ def sa() -> I05Goniometer:


@devices.factory
def analyser(pgm: PlaneGratingMonochromator) -> MbsDetector[LensMode, PassEnergy]:
def analyser_slits() -> EntranceSlitInformationDevice:
return EntranceSlitInformationDevice(f"{PREFIX.beamline_prefix}-EA-SLITS-01:POS")


@devices.factory
def analyser(
pgm: PlaneGratingMonochromator, analyser_slits: EntranceSlitInformationDevice
) -> MbsDetector[LensMode, PassEnergy]:
config_sigs = (
analyser_slits.direction,
analyser_slits.size,
analyser_slits.shape,
analyser_slits.setting,
Copy link
Copy Markdown
Contributor

@Villtord Villtord Jun 2, 2026

Choose a reason for hiding this comment

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

It looks good, but I worry about users who will be tempted to make changes like:
analyser_slits.size.set(300)
in their terminal, then because you have only one directional syncing in EntranceSlitInformationDevice layer this soft signal will fall back to Epics value.
Maybe those soft signals should not be exposed at all?

Copy link
Copy Markdown
Contributor Author

@oliwenmandiamond oliwenmandiamond Jun 2, 2026

Choose a reason for hiding this comment

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

These signals are read only. The write signals are all private. From blueapi side, I believe it doesn't expose private signals. They can only change it via epics (which is how it should be done). The read only config sigs are then given to the detector to record the data at the start of the scan whenever data collection is done. It is also impossible (very hard to do) to sync in the opposite direction as the valid combinations are defined by the epics enums.

)
return MbsDetector[LensMode, PassEnergy](
prefix=f"{PREFIX.beamline_prefix}-EA-DET-02:CAM:",
lens_mode_type=LensMode,
pass_energy_type=PassEnergy,
energy_source=pgm.energy.user_readback,
config_sigs=config_sigs,
)
22 changes: 20 additions & 2 deletions src/dodal/beamlines/i05_1.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
from dodal.devices.beamlines.i05_1 import XYZAzimuthPolarDefocusStage
from dodal.devices.beamlines.i05_shared import LensMode, Mj7j8Mirror, PassEnergy
from dodal.devices.common_mirror import XYZPiezoSwitchingMirror
from dodal.devices.electron_analyser.mbs import MbsDetector
from dodal.devices.electron_analyser.mbs import (
EntranceSlitInformationDevice,
MbsDetector,
)
from dodal.devices.hutch_shutter import HutchShutter
from dodal.devices.pgm import PlaneGratingMonochromator
from dodal.log import set_beamline as set_log_beamline
Expand Down Expand Up @@ -39,11 +42,26 @@ def sm() -> XYZAzimuthPolarDefocusStage:
return XYZAzimuthPolarDefocusStage(prefix=f"{PREFIX.beamline_prefix}-EA-SM-01:")


# Note: Currently fails. Requires https://jira.diamond.ac.uk/browse/I05-764
@devices.factory
def analyser(pgm: PlaneGratingMonochromator) -> MbsDetector[LensMode, PassEnergy]:
def analyser_slits() -> EntranceSlitInformationDevice:
return EntranceSlitInformationDevice(f"{PREFIX.beamline_prefix}-EA-SLITS-01:POS")


@devices.factory
def analyser(
pgm: PlaneGratingMonochromator, analyser_slits: EntranceSlitInformationDevice
) -> MbsDetector[LensMode, PassEnergy]:
config_sigs = (
analyser_slits.direction,
analyser_slits.size,
analyser_slits.shape,
analyser_slits.setting,
)
return MbsDetector[LensMode, PassEnergy](
prefix=f"{PREFIX.beamline_prefix}-EA-DET-04:CAM:",
lens_mode_type=LensMode,
pass_energy_type=PassEnergy,
energy_source=pgm.energy.user_readback,
config_sigs=config_sigs,
)
8 changes: 8 additions & 0 deletions src/dodal/devices/electron_analyser/mbs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
from .mbs_analyser_slits import (
EntranceSlitInformation,
EntranceSlitInformationDevice,
SlitPosition,
)
from .mbs_detector import MbsDetector
from .mbs_driver_io import MbsAnalyserDriverIO
from .mbs_enums import AcquisitionMode
from .mbs_region import MbsRegion, MbsSequence

__all__ = [
"EntranceSlitInformationDevice",
"EntranceSlitInformation",
"SlitPosition",
"MbsDetector",
"MbsAnalyserDriverIO",
"AcquisitionMode",
Expand Down
80 changes: 80 additions & 0 deletions src/dodal/devices/electron_analyser/mbs/mbs_analyser_slits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from bluesky.protocols import Reading
from ophyd_async.core import (
DEFAULT_TIMEOUT,
AsyncStatus,
DeviceMock,
StandardReadable,
StrictEnum,
soft_signal_r_and_setter,
)
from ophyd_async.epics.core import epics_signal_rw
from pydantic import BaseModel


class SlitPosition(StrictEnum):
P100_0_1_CURVED = "100 0.1 curved"
P200_0_1_STRAIGHT = "200 0.1 straight"
P300_0_2_CURVED = "300 0.2 curved"
P400_0_2_STRAIGHT = "400 0.2 straight"
P500_0_2_STRAIGHT = "500 0.2 straight"
P600_0_3_STRAIGHT = "600 0.3 straight"
P700_0_5_STRAIGHT = "700 0.5 straight"
P800_0_8_STRAIGHT = "800 0.8 straight"
P850_3_HOLE = "850 3 hole"
P900_1_5_STRAIGHT = "900 1.5 straight"


class EntranceSlitInformation(BaseModel):
direction: str = "vertical"
setting: int = 100
size: float = 0.1
shape: str = "curved"

@classmethod
def from_slit_positions(cls, pos: SlitPosition):
Comment thread
oliwenmandiamond marked this conversation as resolved.
Outdated
setting, size, shape = str(pos).split()
return cls(setting=int(setting), size=float(size), shape=shape)

def to_slit_position(self) -> SlitPosition:
return SlitPosition(f"{self.setting} {self.size:g} {self.shape}")


class EntranceSlitInformationDevice(StandardReadable):
"""Device that connects to epics signal containing slit information from an enum
value. This is synced with soft signals as individual signals which can be added as
config_signals to give to detectors to save as nicely formatted data.
"""

def __init__(self, pv: str, name: str = ""):
self.slit_pos = epics_signal_rw(SlitPosition, pv)
# Formatted slit info as individual soft signals for metadata
with self.add_children_as_readables():
self.direction, self._direction_w = soft_signal_r_and_setter(str)
self.setting, self._setting_w = soft_signal_r_and_setter(int)
self.size, self._size_w = soft_signal_r_and_setter(float)
self.shape, self._shape_w = soft_signal_r_and_setter(str)
super().__init__(name)

@AsyncStatus.wrap
async def set(self, value: SlitPosition):
await self.slit_pos.set(value)

async def connect(
self,
mock: bool | DeviceMock = False,
timeout: float = DEFAULT_TIMEOUT,
force_reconnect: bool = False,
) -> None:
await super().connect(mock, timeout, force_reconnect)

def _sync_soft_signals_with_epics(
value: dict[str, Reading[SlitPosition]],
) -> None:
val = value[self.slit_pos.name]["value"]
new_slit_info = EntranceSlitInformation.from_slit_positions(val)
self._direction_w(new_slit_info.direction)
self._setting_w(new_slit_info.setting)
self._size_w(new_slit_info.size)
self._shape_w(new_slit_info.shape)

self.slit_pos.subscribe(_sync_soft_signals_with_epics)
Comment thread
oliwenmandiamond marked this conversation as resolved.
Outdated
3 changes: 3 additions & 0 deletions src/dodal/devices/electron_analyser/mbs/mbs_detector.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections.abc import Sequence
from typing import Generic

from ophyd_async.core import SignalR, soft_signal_rw
Expand Down Expand Up @@ -32,6 +33,7 @@ def __init__(
energy_source: SignalR[float],
shutter: GenericFastShutter | None = None,
source_selector: SourceSelector | None = None,
config_sigs: Sequence[SignalR] = (),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why allow here any arbitrary config signals, is there a use case for it or is it not a final class?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is reflecting how StandardDetector adds config_sigs https://github.com/bluesky/ophyd-async/blob/main/src/ophyd_async/core/_detector.py#L421C2-L421C3

and AreaDetector API https://github.com/bluesky/ophyd-async/blob/1e07267fb12ff511b248a77a5ed5b6ac50364ad9/src/ophyd_async/epics/adcore/_detector.py#L17

The electron analyser detectors currently use StandardDetector, but I am going to move to use AreaDetector soon. I was waiting for stable release of ophyd-async first to make it easier so was waiting for this to be merged first bluesky/ophyd-async#1268

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

OK, but I mean you could specify here analyser_slit as input parameter and pass down all it's soft signals to

config_sigs = (
            analyser_slit.direction,
            analyser_slit.setting,
            analyser_slit.size,
            analyser_slit.shape,
            self.driver.region_name,
            self.driver.energy_mode,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I could, but then it makes it less generic when I can reuse what it already does rather than adding it to the constructor to do the exact same thing

name: str = "",
):
# Make attribute of class so connect applies to driver and populates parent.
Expand All @@ -49,6 +51,7 @@ def __init__(
)
trigger_logic = ElectronAnalayserTriggerLogic(self.driver, set())
config_sigs = (
*config_sigs,
self.driver.region_name,
self.driver.energy_mode,
self.driver.acquisition_mode,
Expand Down
69 changes: 69 additions & 0 deletions tests/devices/electron_analyser/mbs/test_mbs_analyser_slits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import pytest
from ophyd_async.core import init_devices
from ophyd_async.testing import assert_reading, partial_reading

from dodal.devices.electron_analyser.mbs import (
EntranceSlitInformation,
EntranceSlitInformationDevice,
SlitPosition,
)


@pytest.mark.parametrize("slit_pos", [pos.value for pos in SlitPosition])
def test_entrance_slit_info_to_slit_position(slit_pos: SlitPosition):
slit_info = EntranceSlitInformation.from_slit_positions(slit_pos)
assert slit_info.to_slit_position() == slit_pos


def test_entrance_slit_info_from_slit_position():
slit_info = EntranceSlitInformation.from_slit_positions(SlitPosition.P850_3_HOLE)
assert slit_info.setting == 850
assert slit_info.size == 3.0
assert slit_info.shape == "hole"
assert slit_info.direction == "vertical"

slit_info = EntranceSlitInformation.from_slit_positions(
SlitPosition.P300_0_2_CURVED
)
assert slit_info.setting == 300
assert slit_info.size == 0.2
assert slit_info.shape == "curved"
assert slit_info.direction == "vertical"


@pytest.fixture
def slit_info_device() -> EntranceSlitInformationDevice:
with init_devices(mock=True):
slit_info_device = EntranceSlitInformationDevice("TEST:")
return slit_info_device


@pytest.mark.parametrize("slit_pos", [pos.value for pos in SlitPosition])
async def test_slit_info_device_soft_signals_sync_with_epics(
slit_info_device: EntranceSlitInformationDevice, slit_pos: SlitPosition
) -> None:
await slit_info_device.set(slit_pos)

slit_info = EntranceSlitInformation.from_slit_positions(slit_pos)
assert await slit_info_device.setting.get_value() == slit_info.setting
assert await slit_info_device.shape.get_value() == slit_info.shape
assert await slit_info_device.size.get_value() == slit_info.size
assert await slit_info_device.direction.get_value() == slit_info.direction


@pytest.mark.parametrize("slit_pos", [pos.value for pos in SlitPosition])
async def test_slit_info_device_read_and_soft_signals_sync_with_epics(
slit_info_device: EntranceSlitInformationDevice, slit_pos: SlitPosition
) -> None:
await slit_info_device.set(slit_pos)
slit_info = EntranceSlitInformation.from_slit_positions(slit_pos)

await assert_reading(
slit_info_device,
{
"slit_info_device-size": partial_reading(slit_info.size),
"slit_info_device-shape": partial_reading(slit_info.shape),
"slit_info_device-setting": partial_reading(slit_info.setting),
"slit_info_device-direction": partial_reading(slit_info.direction),
},
)
Loading