Skip to content

Commit 9f908c6

Browse files
Add load metadata plan for eiger detector (#1187)
* Add plan to test fastcs-eiger with adodin arming * Remove type ignores * Change logic from beamline testing * Change logic from beamline again * Remove reference to scratch * Move load metadata plan to plans directory * Remove entrypoint and detector params * Remove hard coded path from fastcs_eiger device * Address review comments * Rename file and add __main__ * Add minimal test for configure and arm eiger plan * Add test again as ignored last time * Test against branch that awaits eiger arm * Pin to branch * Change _calculate_expected_images params type to int * Add trigger and disarm to plan and tests * Amend conftest eiger params * Remove setting filename and add kickoff and complete * Change plan name to be more descriptive * Fix assertions and add mock callback * Use latest ophyd-async version * Fix typo --------- Co-authored-by: Dominic Oram <[email protected]>
1 parent d21544f commit 9f908c6

File tree

6 files changed

+279
-40
lines changed

6 files changed

+279
-40
lines changed

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ dependencies = [
2424
"requests",
2525
"graypy",
2626
"pydantic>=2.0",
27-
"opencv-python-headless", # For pin-tip detection.
28-
"aioca", # Required for CA support with ophyd-async.
29-
"p4p", # Required for PVA support with ophyd-async.
27+
"opencv-python-headless", # For pin-tip detection.
28+
"aioca", # Required for CA support with ophyd-async.
29+
"p4p", # Required for PVA support with ophyd-async.
3030
"numpy",
3131
"aiofiles",
3232
"aiohttp",

src/dodal/beamlines/i03.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from ophyd_async.fastcs.eiger import EigerDetector as FastEiger
12
from ophyd_async.fastcs.panda import HDFPanda
23

34
from dodal.common.beamlines.beamline_parameters import get_beamline_parameters
@@ -172,6 +173,20 @@ def eiger(mock: bool = False) -> EigerDetector:
172173
)
173174

174175

176+
@device_factory()
177+
def fastcs_eiger() -> FastEiger:
178+
"""Get the i03 FastCS Eiger device, instantiate it if it hasn't already been.
179+
If this is called when already instantiated in i03, it will return the existing object.
180+
"""
181+
182+
return FastEiger(
183+
prefix=PREFIX.beamline_prefix,
184+
path_provider=get_path_provider(),
185+
drv_suffix="-EA-EIGER-02:",
186+
hdf_suffix="-EA-EIGER-01:OD:",
187+
)
188+
189+
175190
@device_factory()
176191
def zebra_fast_grid_scan() -> ZebraFastGridScan:
177192
"""Get the i03 zebra_fast_grid_scan device, instantiate it if it hasn't already been.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import time
2+
3+
import bluesky.plan_stubs as bps
4+
from bluesky import preprocessors as bpp
5+
from bluesky.run_engine import RunEngine
6+
from ophyd_async.core import DetectorTrigger
7+
from ophyd_async.fastcs.eiger import EigerDetector, EigerTriggerInfo
8+
9+
from dodal.beamlines.i03 import fastcs_eiger
10+
from dodal.devices.detector import DetectorParams
11+
from dodal.log import LOGGER, do_default_logging_setup
12+
13+
14+
@bpp.run_decorator()
15+
def configure_arm_trigger_and_disarm_detector(
16+
eiger: EigerDetector,
17+
detector_params: DetectorParams,
18+
trigger_info: EigerTriggerInfo,
19+
):
20+
assert detector_params.expected_energy_ev
21+
start = time.time()
22+
yield from bps.unstage(eiger, wait=True)
23+
LOGGER.info(f"Stopping Eiger-Odin: {time.time() - start}s")
24+
start = time.time()
25+
yield from set_cam_pvs(eiger, detector_params, wait=True)
26+
LOGGER.info(f"Setting CAM PVs: {time.time() - start}s")
27+
start = time.time()
28+
yield from change_roi_mode(eiger, detector_params, wait=True)
29+
LOGGER.info(f"Changing ROI Mode: {time.time() - start}s")
30+
start = time.time()
31+
yield from bps.abs_set(eiger.odin.num_frames_chunks, 1)
32+
LOGGER.info(f"Setting # of Frame Chunks: {time.time() - start}s")
33+
start = time.time()
34+
yield from set_mx_settings_pvs(eiger, detector_params, wait=True)
35+
LOGGER.info(f"Setting MX PVs: {time.time() - start}s")
36+
start = time.time()
37+
yield from bps.prepare(eiger, trigger_info, wait=True)
38+
LOGGER.info(f"Preparing Eiger: {time.time() - start}s")
39+
start = time.time()
40+
yield from bps.kickoff(eiger, wait=True)
41+
LOGGER.info(f"Kickoff Eiger: {time.time() - start}s")
42+
start = time.time()
43+
yield from bps.trigger(eiger.drv.detector.trigger) # type: ignore
44+
LOGGER.info(f"Triggering Eiger: {time.time() - start}s")
45+
start = time.time()
46+
yield from bps.complete(eiger, wait=True)
47+
LOGGER.info(f"Completing Capture: {time.time() - start}s")
48+
start = time.time()
49+
yield from bps.unstage(eiger, wait=True)
50+
LOGGER.info(f"Disarming Eiger: {time.time() - start}s")
51+
52+
53+
def set_cam_pvs(
54+
eiger: EigerDetector,
55+
detector_params: DetectorParams,
56+
wait: bool,
57+
group="cam_pvs",
58+
):
59+
yield from bps.abs_set(
60+
eiger.drv.detector.count_time, detector_params.exposure_time_s, group=group
61+
)
62+
yield from bps.abs_set(
63+
eiger.drv.detector.frame_time, detector_params.exposure_time_s, group=group
64+
)
65+
yield from bps.abs_set(eiger.drv.detector.nexpi, 1, group=group)
66+
67+
if wait:
68+
yield from bps.wait(group)
69+
70+
71+
def change_roi_mode(
72+
eiger: EigerDetector,
73+
detector_params: DetectorParams,
74+
wait: bool,
75+
group="roi_mode",
76+
):
77+
detector_dimensions = (
78+
detector_params.detector_size_constants.roi_size_pixels
79+
if detector_params.use_roi_mode
80+
else detector_params.detector_size_constants.det_size_pixels
81+
)
82+
83+
yield from bps.abs_set(
84+
eiger.drv.detector.roi_mode,
85+
1 if detector_params.use_roi_mode else 0,
86+
group=group,
87+
)
88+
yield from bps.abs_set(
89+
eiger.odin.image_height,
90+
detector_dimensions.height,
91+
group=group,
92+
)
93+
yield from bps.abs_set(
94+
eiger.odin.image_width,
95+
detector_dimensions.width,
96+
group=group,
97+
)
98+
yield from bps.abs_set(
99+
eiger.odin.num_row_chunks,
100+
detector_dimensions.height,
101+
group=group,
102+
)
103+
yield from bps.abs_set(
104+
eiger.odin.num_col_chunks,
105+
detector_dimensions.width,
106+
group=group,
107+
)
108+
109+
if wait:
110+
yield from bps.wait(group)
111+
112+
113+
def set_mx_settings_pvs(
114+
eiger: EigerDetector,
115+
detector_params: DetectorParams,
116+
wait: bool,
117+
group="mx_settings",
118+
):
119+
beam_x_pixels, beam_y_pixels = detector_params.get_beam_position_pixels(
120+
detector_params.detector_distance
121+
)
122+
123+
yield from bps.abs_set(eiger.drv.detector.beam_center_x, beam_x_pixels, group)
124+
yield from bps.abs_set(eiger.drv.detector.beam_center_y, beam_y_pixels, group)
125+
yield from bps.abs_set(
126+
eiger.drv.detector.detector_distance, detector_params.detector_distance, group
127+
)
128+
129+
yield from bps.abs_set(
130+
eiger.drv.detector.omega_start, detector_params.omega_start, group
131+
)
132+
yield from bps.abs_set(
133+
eiger.drv.detector.omega_increment, detector_params.omega_increment, group
134+
)
135+
136+
if wait:
137+
yield from bps.wait(group)
138+
139+
140+
if __name__ == "__main__":
141+
RE = RunEngine()
142+
do_default_logging_setup()
143+
eiger = fastcs_eiger(connect_immediately=True)
144+
RE(
145+
configure_arm_trigger_and_disarm_detector(
146+
eiger=eiger,
147+
detector_params=DetectorParams(
148+
expected_energy_ev=12800,
149+
exposure_time_s=0.01,
150+
directory="/dls/i03/data/2025/cm40607-2/test_new_eiger/",
151+
prefix="",
152+
detector_distance=255,
153+
omega_start=0,
154+
omega_increment=0.1,
155+
num_images_per_trigger=1,
156+
num_triggers=1,
157+
use_roi_mode=False,
158+
det_dist_to_beam_converter_path="/dls_sw/i03/software/daq_configuration/lookup/DetDistToBeamXYConverter.txt",
159+
),
160+
trigger_info=EigerTriggerInfo(
161+
number_of_events=1,
162+
energy_ev=12800,
163+
trigger=DetectorTrigger.INTERNAL,
164+
deadtime=0.0001,
165+
),
166+
)
167+
)

tests/conftest.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import importlib
22
import os
3+
from pathlib import Path
34
from types import ModuleType
45
from unittest.mock import patch
56

67
import pytest
78

89
from conftest import mock_attributes_table
910
from dodal.common.beamlines import beamline_parameters, beamline_utils
11+
from dodal.devices.detector import DetectorParams
12+
from dodal.devices.detector.det_dim_constants import EIGER2_X_16M_SIZE
1013
from dodal.utils import (
1114
DeviceInitializationController,
1215
collect_factories,
@@ -39,3 +42,22 @@ def mock_beamline_module_filepaths(bl_name: str, bl_module: ModuleType):
3942
beamline_parameters.BEAMLINE_PARAMETER_PATHS[bl_name] = (
4043
"tests/test_data/i04_beamlineParameters"
4144
)
45+
46+
47+
@pytest.fixture
48+
def eiger_params(tmp_path: Path) -> DetectorParams:
49+
return DetectorParams(
50+
expected_energy_ev=100.0,
51+
exposure_time_s=1.0,
52+
directory=str(tmp_path),
53+
prefix="test",
54+
run_number=0,
55+
detector_distance=1.0,
56+
omega_start=0.0,
57+
omega_increment=1.0,
58+
num_images_per_trigger=1,
59+
num_triggers=2000,
60+
use_roi_mode=False,
61+
det_dist_to_beam_converter_path="tests/devices/unit_tests/test_lookup_table.txt",
62+
detector_size_constants=EIGER2_X_16M_SIZE.det_type_string, # type: ignore
63+
)

tests/devices/unit_tests/test_eiger.py

Lines changed: 6 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# type: ignore # Eiger will soon be ophyd-async https://github.com/DiamondLightSource/dodal/issues/700
22
import threading
3-
from pathlib import Path
43
from unittest.mock import ANY, MagicMock, Mock, call, create_autospec, patch
54

65
import pytest
@@ -16,49 +15,19 @@
1615
from dodal.devices.util.epics_util import run_functions_without_blocking
1716
from dodal.log import LOGGER
1817

19-
TEST_DETECTOR_SIZE_CONSTANTS = EIGER2_X_16M_SIZE
20-
21-
TEST_EXPECTED_ENERGY = 100.0
22-
TEST_EXPOSURE_TIME = 1.0
2318
TEST_PREFIX = "test"
2419
TEST_RUN_NUMBER = 0
25-
TEST_DETECTOR_DISTANCE = 1.0
26-
TEST_OMEGA_START = 0.0
27-
TEST_OMEGA_INCREMENT = 1.0
28-
TEST_NUM_IMAGES_PER_TRIGGER = 1
29-
TEST_NUM_TRIGGERS = 2000
30-
TEST_USE_ROI_MODE = False
31-
TEST_DET_DIST_TO_BEAM_CONVERTER_PATH = "tests/devices/unit_tests/test_lookup_table.txt"
3220

3321

3422
class StatusException(Exception):
3523
pass
3624

3725

3826
@pytest.fixture
39-
def params(tmp_path: Path) -> DetectorParams:
40-
return DetectorParams(
41-
expected_energy_ev=TEST_EXPECTED_ENERGY,
42-
exposure_time_s=TEST_EXPOSURE_TIME,
43-
directory=str(tmp_path),
44-
prefix=TEST_PREFIX,
45-
run_number=TEST_RUN_NUMBER,
46-
detector_distance=TEST_DETECTOR_DISTANCE,
47-
omega_start=TEST_OMEGA_START,
48-
omega_increment=TEST_OMEGA_INCREMENT,
49-
num_images_per_trigger=TEST_NUM_IMAGES_PER_TRIGGER,
50-
num_triggers=TEST_NUM_TRIGGERS,
51-
use_roi_mode=TEST_USE_ROI_MODE,
52-
det_dist_to_beam_converter_path=TEST_DET_DIST_TO_BEAM_CONVERTER_PATH,
53-
detector_size_constants=TEST_DETECTOR_SIZE_CONSTANTS.det_type_string,
54-
)
55-
56-
57-
@pytest.fixture
58-
def fake_eiger(request, params: DetectorParams):
27+
def fake_eiger(request, eiger_params: DetectorParams):
5928
FakeEigerDetector: EigerDetector = make_fake_device(EigerDetector)
6029
fake_eiger: EigerDetector = FakeEigerDetector.with_params(
61-
params=params, name=f"test fake Eiger: {request.node.name}"
30+
params=eiger_params, name=f"test fake Eiger: {request.node.name}"
6231
)
6332
return fake_eiger
6433

@@ -255,8 +224,8 @@ def test_disable_roi_mode_sets_correct_roi_mode(fake_eiger):
255224
@pytest.mark.parametrize(
256225
"roi_mode, expected_detector_dimensions",
257226
[
258-
(True, TEST_DETECTOR_SIZE_CONSTANTS.roi_size_pixels),
259-
(False, TEST_DETECTOR_SIZE_CONSTANTS.det_size_pixels),
227+
(True, EIGER2_X_16M_SIZE.roi_size_pixels),
228+
(False, EIGER2_X_16M_SIZE.det_size_pixels),
260229
],
261230
)
262231
def test_change_roi_mode_sets_correct_detector_size_constants(
@@ -733,10 +702,10 @@ def test_when_eiger_is_stopped_then_dev_shm_disabled(fake_eiger: EigerDetector):
733702
assert fake_eiger.odin.fan.dev_shm_enable.get() == 0
734703

735704

736-
def test_for_other_beamlines_i03_used_as_default(params: DetectorParams):
705+
def test_for_other_beamlines_i03_used_as_default(eiger_params: DetectorParams):
737706
FakeEigerDetector: EigerDetector = make_fake_device(EigerDetector)
738707
fake_eiger: EigerDetector = FakeEigerDetector.with_params(
739-
params=params, beamline="ixx"
708+
params=eiger_params, beamline="ixx"
740709
)
741710
assert fake_eiger.beamline == "ixx"
742711
assert fake_eiger.timeouts == AVAILABLE_TIMEOUTS["i03"]
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from collections.abc import AsyncGenerator
2+
from unittest.mock import AsyncMock, MagicMock
3+
4+
import pytest
5+
from bluesky.run_engine import RunEngine
6+
from ophyd_async.core import DetectorTrigger
7+
from ophyd_async.fastcs.eiger import EigerDetector as FastEiger
8+
from ophyd_async.fastcs.eiger import EigerTriggerInfo
9+
from ophyd_async.testing import callback_on_mock_put, set_mock_value
10+
11+
from dodal.plans.configure_arm_trigger_and_disarm_detector import (
12+
configure_arm_trigger_and_disarm_detector,
13+
)
14+
15+
16+
@pytest.fixture
17+
async def fake_eiger():
18+
fake_eiger = FastEiger("", MagicMock())
19+
await fake_eiger.connect(mock=True)
20+
fake_eiger.drv.detector.arm.trigger = AsyncMock()
21+
fake_eiger.drv.detector.disarm.trigger = AsyncMock()
22+
fake_eiger._writer.observe_indices_written = fake_observe_indices_written
23+
return fake_eiger
24+
25+
26+
async def fake_observe_indices_written(timeout: float) -> AsyncGenerator[int, None]:
27+
yield 1
28+
29+
30+
async def test_configure_arm_trigger_and_disarm_detector(
31+
fake_eiger, eiger_params, RE: RunEngine
32+
):
33+
trigger_info = EigerTriggerInfo(
34+
# Manual trigger, so setting number of triggers to 1.
35+
number_of_events=1,
36+
energy_ev=eiger_params.expected_energy_ev,
37+
trigger=DetectorTrigger.INTERNAL,
38+
deadtime=0.0001,
39+
)
40+
41+
def set_meta_active(*args, **kwargs) -> None:
42+
set_mock_value(fake_eiger.odin.meta_active, "Active")
43+
44+
def set_capture_rbv_meta_writing_and_detector_state(*args, **kwargs) -> None:
45+
# Mimics capturing and immediete completion status on Eiger.
46+
set_mock_value(fake_eiger.odin.capture_rbv, "Capturing")
47+
set_mock_value(fake_eiger.odin.meta_writing, "Writing")
48+
set_mock_value(fake_eiger.drv.detector.state, "idle")
49+
50+
callback_on_mock_put(fake_eiger.odin.num_to_capture, set_meta_active)
51+
callback_on_mock_put(
52+
fake_eiger.odin.capture, set_capture_rbv_meta_writing_and_detector_state
53+
)
54+
55+
RE(
56+
configure_arm_trigger_and_disarm_detector(
57+
fake_eiger, eiger_params, trigger_info
58+
)
59+
)
60+
fake_eiger.drv.detector.arm.trigger.assert_called_once()
61+
# Disarm occurs at the start and end of the plan.
62+
assert len(fake_eiger.drv.detector.disarm.trigger.call_args_list) == 2
63+
assert (
64+
await fake_eiger.drv.detector.photon_energy.get_value()
65+
== eiger_params.expected_energy_ev
66+
)

0 commit comments

Comments
 (0)