From ca1038e8de04645f9f44f527190be6a9a81346c4 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Mon, 11 Aug 2025 17:47:04 +0100 Subject: [PATCH 01/64] Add do manual acquisition plan --- src/mx_bluesky/beamlines/i24/__init__.py | 1 + .../do_manual_acquisition.py | 82 +++++++++++++++++++ .../test_do_manual_acquisition.py | 37 +++++++++ tests/unit_tests/conftest.py | 25 +++++- 4 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_manual_acquisition.py create mode 100644 tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_manual_acquisition.py diff --git a/src/mx_bluesky/beamlines/i24/__init__.py b/src/mx_bluesky/beamlines/i24/__init__.py index e69de29bb2..105ef32117 100644 --- a/src/mx_bluesky/beamlines/i24/__init__.py +++ b/src/mx_bluesky/beamlines/i24/__init__.py @@ -0,0 +1 @@ +# todo move serial plans here too? diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_manual_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_manual_acquisition.py new file mode 100644 index 0000000000..2d9f0087ba --- /dev/null +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_manual_acquisition.py @@ -0,0 +1,82 @@ +from typing import cast + +import bluesky.plan_stubs as bps +from dodal.common import inject +from ophyd_async.core import WatchableAsyncStatus, Watcher +from ophyd_async.fastcs.jungfrau import ( + Jungfrau, + create_jungfrau_external_triggering_info, +) + +from mx_bluesky.common.utils.log import LOGGER + + +class LogOnPercentageProgressWatcher(Watcher[int]): + def __init__( + self, + status: WatchableAsyncStatus[int], + message_prefix: str, + percent_interval: int = 25, + ): + status.watch(self) + self.percent_interval = percent_interval + self.current_percent_interval = 0 + self.message_prefix = message_prefix + + def __call__( + self, + current: int | None = None, + initial: int | None = None, + target: int | None = None, + name: str | None = None, + unit: str | None = None, + precision: int | None = None, + fraction: float | None = None, + time_elapsed: float | None = None, + time_remaining: float | None = None, + ): + if isinstance(current, int) and isinstance(target, int) and target: + current_percent = int((current / target) * 100) + if ( + current_percent + >= (self.current_percent_interval + 1) * self.percent_interval + ): + LOGGER.info(f"{self.message_prefix}: {current_percent}%") + self.current_percent_interval = current_percent // self.percent_interval + + +def log_on_percentage_complete( + status: WatchableAsyncStatus[int], message_prefix: str, percent_interval: int = 25 +): + LogOnPercentageProgressWatcher(status, message_prefix, percent_interval) + + +# TODO: make the pathprovider adjustable with a absolute path to file param +def do_manual_acquisition( + exp_time_s: float, + period_between_frames_s: float, + frames_per_trigger: int = 1, + total_triggers: int = 1, + jungfrau: Jungfrau = inject("jungfrau"), + wait: bool = False, +): + trigger_info = create_jungfrau_external_triggering_info( + total_triggers, frames_per_trigger, exp_time_s, period_between_frames_s + ) + yield from bps.stage(jungfrau) + LOGGER.info("Setting up detector...") + yield from bps.prepare(jungfrau, trigger_info, wait=True) + LOGGER.info("Detector prepared. Starting acquisition") + + yield from bps.kickoff(jungfrau, wait=True) + + LOGGER.info("Waiting for acquisition to complete...") + status = yield from bps.complete(jungfrau, group="jf_complete") + + # StandardDetector.complete converts regular status to watchable status, + # but bluesky plan stubs can't see this currently + status = cast(WatchableAsyncStatus, status) + log_on_percentage_complete(status, "Jungfrau data collection triggers recieved", 10) + if wait: + yield from bps.wait("jf_complete") + return status diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_manual_acquisition.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_manual_acquisition.py new file mode 100644 index 0000000000..791db5f5bb --- /dev/null +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_manual_acquisition.py @@ -0,0 +1,37 @@ +import asyncio +from unittest.mock import AsyncMock + +import bluesky.plan_stubs as bps +from bluesky.preprocessors import run_decorator +from bluesky.run_engine import RunEngine +from ophyd_async.fastcs.jungfrau import Jungfrau +from ophyd_async.testing import set_mock_value + +from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_manual_acquisition import ( + do_manual_acquisition, +) + + +async def _do_sleep(): + await asyncio.sleep(0) + + +def test_full_do_manual_acquisition(jungfrau: Jungfrau, RE: RunEngine, caplog): + @run_decorator() + def test_plan(): + status = yield from do_manual_acquisition(0.001, 0.002, 5, 5, jungfrau) + assert not status.done + val = 0 + while not status.done: + val += 1 + set_mock_value(jungfrau._writer._drv.num_captured, val) + + # Let status update + yield from bps.wait_for([_do_sleep]) + yield from bps.wait("jf_complete") + + jungfrau._controller.arm = AsyncMock() + RE(test_plan()) + for i in range(20, 120, 20): + assert f"Jungfrau data collection triggers recieved: {i}%" in caplog.messages + print(caplog) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index fb3c6b0c3d..b8bdd86598 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -26,9 +26,10 @@ from dodal.devices.synchrotron import Synchrotron, SynchrotronMode from dodal.devices.zocalo import ZocaloResults, ZocaloTrigger from event_model.documents import Event -from ophyd_async.core import AsyncStatus +from ophyd_async.core import AsyncStatus, init_devices +from ophyd_async.fastcs.jungfrau import Jungfrau from ophyd_async.fastcs.panda import HDFPanda -from ophyd_async.testing import set_mock_value +from ophyd_async.testing import callback_on_mock_put, set_mock_value from mx_bluesky.common.experiment_plans.common_flyscan_xray_centre_plan import ( BeamlineSpecificFGSFeatures, @@ -470,3 +471,23 @@ async def hyperion_grid_detect_xrc_devices(grid_detect_xrc_devices): composite.panda = MagicMock(spec=HDFPanda) composite.panda_fast_grid_scan = MagicMock(spec=PandAFastGridScan) return composite + + +# TODO put this fixture logic in ophyd-async testing module, then just call that +@pytest.fixture +def jungfrau(RE: RunEngine): + with init_devices(mock=True): + detector = Jungfrau("prefix", MagicMock(), "", "", 4, "jungfrau") + + def set_meta_filename_and_id(value, *args, **kwargs): + set_mock_value(detector.odin.meta_file_name, value) + set_mock_value(detector.odin.id, value) + + callback_on_mock_put(detector.odin.file_name, set_meta_filename_and_id) + + detector._writer._path_provider.return_value.filename = "filename.h5" # type: ignore + + set_mock_value(detector.odin.meta_active, "Active") + set_mock_value(detector.odin.capture_rbv, "Capturing") + set_mock_value(detector.odin.meta_writing, "Writing") + return detector From bb2d12754308857e9b1205f741255eff381d9411 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Wed, 13 Aug 2025 09:51:56 +0100 Subject: [PATCH 02/64] Add option to override path provider --- pyproject.toml | 2 +- .../do_external_acquisition.py | 72 ++++++++++++++++ .../do_manual_acquisition.py | 82 ------------------- .../test_do_manual_acquisition.py | 53 ++++++++++-- tests/unit_tests/conftest.py | 4 +- 5 files changed, 124 insertions(+), 89 deletions(-) create mode 100644 src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py delete mode 100644 src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_manual_acquisition.py diff --git a/pyproject.toml b/pyproject.toml index d6c9131a54..7f8d7ab586 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "ophyd >= 1.10.5", "ophyd-async >= 0.10.0a2", "bluesky >= 1.13.1", - "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@53a7ab512c0ac824471381283ca742951f088c11", + "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@2fc34bef6d287faec0ac0b74a77f0e52a820cbec", ] diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py new file mode 100644 index 0000000000..c9c1e90512 --- /dev/null +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py @@ -0,0 +1,72 @@ +from pathlib import Path +from typing import cast + +import bluesky.plan_stubs as bps +from dodal.common import inject +from dodal.common.watcher_utils import log_on_percentage_complete +from ophyd_async.core import ( + AutoIncrementFilenameProvider, + StaticPathProvider, + WatchableAsyncStatus, +) +from ophyd_async.fastcs.jungfrau import ( + Jungfrau, + create_jungfrau_external_triggering_info, +) + +from mx_bluesky.common.utils.log import LOGGER + + +# TODO: make the pathprovider adjustable with a absolute path to file param +def do_external_acquisition( + exp_time_s: float, + period_between_frames_s: float, + frames_per_trigger: int = 1, + total_triggers: int = 1, + jungfrau: Jungfrau = inject("jungfrau"), + path_of_output_file: str | None = None, + wait: bool = False, +): + """ + Kickoff external triggering on the jungfrau, and optionally wait for completion. + + Must be used within an open Bluesky run. + + Args: + exp_time_s: Length of detector exposure for each frame + period_between_frames_s: Time between each detector frame, including deadtime + frames_per_trigger: Number of frames for each external trigger + total_triggers: Number of external triggers recieved before acquisition is marked as complete + jungfrau: Jungfrau device + path_of_output_file: Absolute path of the detector file output, including file name. If None, then use the PathProvider + set during jungfrau device instantiation + """ + + # While we should generally use device instantiation to set the path, + # this will be useful during commissioning + if path_of_output_file: + _file_path = Path(path_of_output_file) + filename_provider = AutoIncrementFilenameProvider(_file_path.name) + path_provider = StaticPathProvider(filename_provider, _file_path.parent) + jungfrau._writer._path_provider = path_provider # noqa: SLF001 + + trigger_info = create_jungfrau_external_triggering_info( + total_triggers, frames_per_trigger, exp_time_s, period_between_frames_s + ) + yield from bps.stage(jungfrau) + LOGGER.info("Setting up detector...") + yield from bps.prepare(jungfrau, trigger_info, wait=True) + LOGGER.info("Detector prepared. Starting acquisition") + + yield from bps.kickoff(jungfrau, wait=True) + + LOGGER.info("Waiting for acquisition to complete...") + status = yield from bps.complete(jungfrau, group="jf_complete") + + # StandardDetector.complete converts regular status to watchable status, + # but bluesky plan stubs can't see this currently + status = cast(WatchableAsyncStatus, status) + log_on_percentage_complete(status, "Jungfrau data collection triggers recieved", 10) + if wait: + yield from bps.wait("jf_complete") + return status diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_manual_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_manual_acquisition.py deleted file mode 100644 index 2d9f0087ba..0000000000 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_manual_acquisition.py +++ /dev/null @@ -1,82 +0,0 @@ -from typing import cast - -import bluesky.plan_stubs as bps -from dodal.common import inject -from ophyd_async.core import WatchableAsyncStatus, Watcher -from ophyd_async.fastcs.jungfrau import ( - Jungfrau, - create_jungfrau_external_triggering_info, -) - -from mx_bluesky.common.utils.log import LOGGER - - -class LogOnPercentageProgressWatcher(Watcher[int]): - def __init__( - self, - status: WatchableAsyncStatus[int], - message_prefix: str, - percent_interval: int = 25, - ): - status.watch(self) - self.percent_interval = percent_interval - self.current_percent_interval = 0 - self.message_prefix = message_prefix - - def __call__( - self, - current: int | None = None, - initial: int | None = None, - target: int | None = None, - name: str | None = None, - unit: str | None = None, - precision: int | None = None, - fraction: float | None = None, - time_elapsed: float | None = None, - time_remaining: float | None = None, - ): - if isinstance(current, int) and isinstance(target, int) and target: - current_percent = int((current / target) * 100) - if ( - current_percent - >= (self.current_percent_interval + 1) * self.percent_interval - ): - LOGGER.info(f"{self.message_prefix}: {current_percent}%") - self.current_percent_interval = current_percent // self.percent_interval - - -def log_on_percentage_complete( - status: WatchableAsyncStatus[int], message_prefix: str, percent_interval: int = 25 -): - LogOnPercentageProgressWatcher(status, message_prefix, percent_interval) - - -# TODO: make the pathprovider adjustable with a absolute path to file param -def do_manual_acquisition( - exp_time_s: float, - period_between_frames_s: float, - frames_per_trigger: int = 1, - total_triggers: int = 1, - jungfrau: Jungfrau = inject("jungfrau"), - wait: bool = False, -): - trigger_info = create_jungfrau_external_triggering_info( - total_triggers, frames_per_trigger, exp_time_s, period_between_frames_s - ) - yield from bps.stage(jungfrau) - LOGGER.info("Setting up detector...") - yield from bps.prepare(jungfrau, trigger_info, wait=True) - LOGGER.info("Detector prepared. Starting acquisition") - - yield from bps.kickoff(jungfrau, wait=True) - - LOGGER.info("Waiting for acquisition to complete...") - status = yield from bps.complete(jungfrau, group="jf_complete") - - # StandardDetector.complete converts regular status to watchable status, - # but bluesky plan stubs can't see this currently - status = cast(WatchableAsyncStatus, status) - log_on_percentage_complete(status, "Jungfrau data collection triggers recieved", 10) - if wait: - yield from bps.wait("jf_complete") - return status diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_manual_acquisition.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_manual_acquisition.py index 791db5f5bb..1beaa22249 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_manual_acquisition.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_manual_acquisition.py @@ -1,14 +1,16 @@ import asyncio -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock, patch import bluesky.plan_stubs as bps from bluesky.preprocessors import run_decorator from bluesky.run_engine import RunEngine +from bluesky.simulators import RunEngineSimulator, assert_message_and_return_remaining +from ophyd_async.core import AutoIncrementFilenameProvider, StaticPathProvider from ophyd_async.fastcs.jungfrau import Jungfrau from ophyd_async.testing import set_mock_value -from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_manual_acquisition import ( - do_manual_acquisition, +from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_external_acquisition import ( + do_external_acquisition, ) @@ -16,10 +18,10 @@ async def _do_sleep(): await asyncio.sleep(0) -def test_full_do_manual_acquisition(jungfrau: Jungfrau, RE: RunEngine, caplog): +def test_full_do_external_acquisition(jungfrau: Jungfrau, RE: RunEngine, caplog): @run_decorator() def test_plan(): - status = yield from do_manual_acquisition(0.001, 0.002, 5, 5, jungfrau) + status = yield from do_external_acquisition(0.001, 0.002, 5, 5, jungfrau) assert not status.done val = 0 while not status.done: @@ -35,3 +37,44 @@ def test_plan(): for i in range(20, 120, 20): assert f"Jungfrau data collection triggers recieved: {i}%" in caplog.messages print(caplog) + + +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.do_external_acquisition.log_on_percentage_complete" +) +def test_do_external_acquisition_does_wait( + mock_log_on_percent_complete: MagicMock, + sim_run_engine: RunEngineSimulator, + jungfrau: Jungfrau, +): + msgs = sim_run_engine.simulate_plan( + do_external_acquisition(0.01, 0.02, 1, 1, jungfrau, wait=True) + ) + assert_message_and_return_remaining( + msgs, lambda msg: msg.command == "wait" and msg.kwargs["group"] == "jf_complete" + ) + + +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.do_external_acquisition.log_on_percentage_complete" +) +def test_do_external_acquisition_setting_path( + mock_log_on_percent_complete: MagicMock, + sim_run_engine: RunEngineSimulator, + jungfrau: Jungfrau, + tmpdir, +): + test_path = f"{tmpdir}/test_file" + sim_run_engine.simulate_plan( + do_external_acquisition( + 0.01, 0.02, 1, 1, jungfrau, path_of_output_file=test_path + ) + ) + real_path_provider = jungfrau._writer._path_provider + assert isinstance(real_path_provider, StaticPathProvider) + assert isinstance( + real_path_provider._filename_provider, + AutoIncrementFilenameProvider, + ) + assert real_path_provider._filename_provider._base_filename == "test_file" + assert (real_path_provider._directory_path) == tmpdir diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index b8bdd86598..86b6b22566 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -473,9 +473,11 @@ async def hyperion_grid_detect_xrc_devices(grid_detect_xrc_devices): return composite -# TODO put this fixture logic in ophyd-async testing module, then just call that +# See https://github.com/DiamondLightSource/dodal/issues/1455 @pytest.fixture def jungfrau(RE: RunEngine): + """The extra logic here prevents exceptions during data collection unit tests""" + with init_devices(mock=True): detector = Jungfrau("prefix", MagicMock(), "", "", 4, "jungfrau") From b392a2670593fc28c227c595d500a57646fed2e1 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Wed, 13 Aug 2025 09:55:48 +0100 Subject: [PATCH 03/64] remove todos --- src/mx_bluesky/beamlines/i24/__init__.py | 1 - .../i24/jungfrau_commissioning/do_external_acquisition.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/__init__.py b/src/mx_bluesky/beamlines/i24/__init__.py index 105ef32117..e69de29bb2 100644 --- a/src/mx_bluesky/beamlines/i24/__init__.py +++ b/src/mx_bluesky/beamlines/i24/__init__.py @@ -1 +0,0 @@ -# todo move serial plans here too? diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py index c9c1e90512..a061cc2003 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py @@ -17,7 +17,6 @@ from mx_bluesky.common.utils.log import LOGGER -# TODO: make the pathprovider adjustable with a absolute path to file param def do_external_acquisition( exp_time_s: float, period_between_frames_s: float, @@ -28,7 +27,7 @@ def do_external_acquisition( wait: bool = False, ): """ - Kickoff external triggering on the jungfrau, and optionally wait for completion. + Kickoff external triggering on the Jungfrau, and optionally wait for completion. Must be used within an open Bluesky run. From 972ed36dac812d4a453456d67069bcdba1fded3f Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Thu, 21 Aug 2025 11:16:08 +0100 Subject: [PATCH 04/64] Review response --- ...nual_acquisition.py => test_do_external_acquisition.py} | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) rename tests/unit_tests/beamlines/i24/jungfrau_commissioning/{test_do_manual_acquisition.py => test_do_external_acquisition.py} (96%) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_manual_acquisition.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py similarity index 96% rename from tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_manual_acquisition.py rename to tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py index 1beaa22249..0e953f5c3e 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_manual_acquisition.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py @@ -1,4 +1,5 @@ import asyncio +from functools import partial from unittest.mock import AsyncMock, MagicMock, patch import bluesky.plan_stubs as bps @@ -14,10 +15,6 @@ ) -async def _do_sleep(): - await asyncio.sleep(0) - - def test_full_do_external_acquisition(jungfrau: Jungfrau, RE: RunEngine, caplog): @run_decorator() def test_plan(): @@ -29,7 +26,7 @@ def test_plan(): set_mock_value(jungfrau._writer._drv.num_captured, val) # Let status update - yield from bps.wait_for([_do_sleep]) + yield from bps.wait_for([partial(asyncio.sleep, 0)]) yield from bps.wait("jf_complete") jungfrau._controller.arm = AsyncMock() From de5479fbf9dc52ab3dd7dd3131ec84344e8f1a0f Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Fri, 29 Aug 2025 15:51:07 +0100 Subject: [PATCH 05/64] remove accidentally added file --- .../do_internal_acquisition.py | 71 ------------------- 1 file changed, 71 deletions(-) delete mode 100644 src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py deleted file mode 100644 index 4491cb70a0..0000000000 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py +++ /dev/null @@ -1,71 +0,0 @@ -from pathlib import Path -from typing import cast - -import bluesky.plan_stubs as bps -from dodal.common import inject -from dodal.common.watcher_utils import log_on_percentage_complete -from ophyd_async.core import ( - AutoIncrementFilenameProvider, - StaticPathProvider, - WatchableAsyncStatus, -) -from ophyd_async.fastcs.jungfrau import ( - Jungfrau, - create_jungfrau_external_triggering_info, -) - -from mx_bluesky.common.utils.log import LOGGER - - -def do_internal_acquisition( - exp_time_s: float, - period_between_frames_s: float, - frames_per_trigger: int = 1, - total_triggers: int = 1, - jungfrau: Jungfrau = inject("jungfrau"), - path_of_output_file: str | None = None, - wait: bool = False, -): - """ - Kickoff internal triggering on the Jungfrau, and optionally wait for completion. - - Must be used within an open Bluesky run. - - Args: - exp_time_s: Length of detector exposure for each frame - period_between_frames_s: Time between each detector frame, including deadtime - frames_per_trigger: Number of frames for each external trigger - total_triggers: Number of external triggers recieved before acquisition is marked as complete - jungfrau: Jungfrau device - path_of_output_file: Absolute path of the detector file output, including file name. If None, then use the PathProvider - set during jungfrau device instantiation - """ - - # While we should generally use device instantiation to set the path, - # this will be useful during commissioning - if path_of_output_file: - _file_path = Path(path_of_output_file) - filename_provider = AutoIncrementFilenameProvider(_file_path.name) - path_provider = StaticPathProvider(filename_provider, _file_path.parent) - jungfrau._writer._path_provider = path_provider # noqa: SLF001 - - trigger_info = create_jungfrau_external_triggering_info( - total_triggers, frames_per_trigger, exp_time_s, period_between_frames_s - ) - yield from bps.stage(jungfrau) - LOGGER.info("Setting up detector...") - yield from bps.prepare(jungfrau, trigger_info, wait=True) - LOGGER.info("Detector prepared. Starting acquisition") - - yield from bps.kickoff(jungfrau, wait=True) - - LOGGER.info("Waiting for acquisition to complete...") - status = yield from bps.complete(jungfrau, group="jf_complete") - - # StandardDetector.complete converts regular status to watchable status, - # but bluesky plan stubs can't see this currently - status = cast(WatchableAsyncStatus, status) - log_on_percentage_complete(status, "Jungfrau data collection triggers recieved", 10) - if wait: - yield from bps.wait("jf_complete") - return status From 8f28c71900d64f90d2759a185b91cda864c7345d Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Fri, 29 Aug 2025 16:39:29 +0100 Subject: [PATCH 06/64] Remove print from test --- .../i24/jungfrau_commissioning/test_do_external_acquisition.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py index db3e8b2e81..7367c22f28 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py @@ -33,7 +33,6 @@ def test_plan(): RE(test_plan()) for i in range(20, 120, 20): assert f"Jungfrau data collection triggers recieved: {i}%" in caplog.messages - print(caplog) @patch( From f67cb7f9df30dbb069492304fbe1770976a17b23 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Mon, 1 Sep 2025 16:02:05 +0100 Subject: [PATCH 07/64] Remove frames per trigger for external acquisitions --- .../i24/jungfrau_commissioning/do_external_acquisition.py | 4 +--- .../test_do_external_acquisition.py | 8 +++----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py index 7586eb64e3..91941d6099 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py @@ -21,7 +21,6 @@ def do_external_acquisition( exp_time_s: float, total_triggers: PositiveInt = 1, - frames_per_trigger: PositiveInt = 1, period_between_frames_s: float | None = None, jungfrau: Jungfrau = inject("jungfrau"), path_of_output_file: str | None = None, @@ -35,7 +34,6 @@ def do_external_acquisition( Args: exp_time_s: Length of detector exposure for each frame. total_triggers: Number of external triggers recieved before acquisition is marked as complete. - frames_per_trigger: Number of frames for each external trigger. period_between_frames_s: Time between each detector frame, including deadtime. Not needed if frames_per_triggers is 1. jungfrau: Jungfrau device path_of_output_file: Absolute path of the detector file output, including file name. If None, then use the PathProvider @@ -51,7 +49,7 @@ def do_external_acquisition( jungfrau._writer._path_provider = path_provider # noqa: SLF001 trigger_info = create_jungfrau_external_triggering_info( - total_triggers, frames_per_trigger, exp_time_s, period_between_frames_s + total_triggers, exp_time_s, period_between_frames_s ) yield from bps.stage(jungfrau) LOGGER.info("Setting up detector...") diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py index 7367c22f28..f43773d39c 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py @@ -18,7 +18,7 @@ def test_full_do_external_acquisition(jungfrau: Jungfrau, RE: RunEngine, caplog): @run_decorator() def test_plan(): - status = yield from do_external_acquisition(0.001, 5, 5, 0.002, jungfrau) + status = yield from do_external_acquisition(0.001, 5, 0.002, jungfrau) assert not status.done val = 0 while not status.done: @@ -44,7 +44,7 @@ def test_do_external_acquisition_does_wait( jungfrau: Jungfrau, ): msgs = sim_run_engine.simulate_plan( - do_external_acquisition(0.01, 1, 1, 0.02, jungfrau, wait=True) + do_external_acquisition(0.01, 1, 0.02, jungfrau, wait=True) ) assert_message_and_return_remaining( msgs, lambda msg: msg.command == "wait" and msg.kwargs["group"] == "jf_complete" @@ -62,9 +62,7 @@ def test_do_external_acquisition_setting_path( ): test_path = f"{tmpdir}/test_file" sim_run_engine.simulate_plan( - do_external_acquisition( - 0.01, 1, 1, 0.02, jungfrau, path_of_output_file=test_path - ) + do_external_acquisition(0.01, 1, 0.02, jungfrau, path_of_output_file=test_path) ) real_path_provider = jungfrau._writer._path_provider assert isinstance(real_path_provider, StaticPathProvider) From cb8447e906222e15353cd6015187ac3e0ace9b85 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Fri, 29 Aug 2025 16:38:56 +0100 Subject: [PATCH 08/64] Add do internal acquisition plan --- .../do_external_acquisition.py | 26 ++----- .../do_internal_acquisition.py | 52 ++++++++++++++ .../i24/jungfrau_commissioning/plan_utils.py | 46 ++++++++++++ .../test_do_external_acquisition.py | 4 +- .../test_do_internal_acquisition.py | 71 +++++++++++++++++++ 5 files changed, 176 insertions(+), 23 deletions(-) create mode 100644 src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py create mode 100644 src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py create mode 100644 tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py index 91941d6099..1e53ff0d2f 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py @@ -1,9 +1,7 @@ from pathlib import Path -from typing import cast -import bluesky.plan_stubs as bps +from bluesky.utils import MsgGenerator from dodal.common import inject -from dodal.common.watcher_utils import log_on_percentage_complete from ophyd_async.core import ( AutoIncrementFilenameProvider, StaticPathProvider, @@ -15,7 +13,7 @@ ) from pydantic import PositiveInt -from mx_bluesky.common.utils.log import LOGGER +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import fly_jungfrau def do_external_acquisition( @@ -25,7 +23,7 @@ def do_external_acquisition( jungfrau: Jungfrau = inject("jungfrau"), path_of_output_file: str | None = None, wait: bool = False, -): +) -> MsgGenerator[WatchableAsyncStatus]: """ Kickoff external triggering on the Jungfrau, and optionally wait for completion. @@ -38,6 +36,7 @@ def do_external_acquisition( jungfrau: Jungfrau device path_of_output_file: Absolute path of the detector file output, including file name. If None, then use the PathProvider set during jungfrau device instantiation + wait: Optionally block until data collection is complete. """ # While we should generally use device instantiation to set the path, @@ -51,20 +50,5 @@ def do_external_acquisition( trigger_info = create_jungfrau_external_triggering_info( total_triggers, exp_time_s, period_between_frames_s ) - yield from bps.stage(jungfrau) - LOGGER.info("Setting up detector...") - yield from bps.prepare(jungfrau, trigger_info, wait=True) - LOGGER.info("Detector prepared. Starting acquisition") - - yield from bps.kickoff(jungfrau, wait=True) - - LOGGER.info("Waiting for acquisition to complete...") - status = yield from bps.complete(jungfrau, group="jf_complete") - - # StandardDetector.complete converts regular status to watchable status, - # but bluesky plan stubs can't see this currently - status = cast(WatchableAsyncStatus, status) - log_on_percentage_complete(status, "Jungfrau data collection triggers recieved", 10) - if wait: - yield from bps.wait("jf_complete") + status = yield from fly_jungfrau(jungfrau, trigger_info, wait) return status diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py new file mode 100644 index 0000000000..567b126cc7 --- /dev/null +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py @@ -0,0 +1,52 @@ +from pathlib import Path + +from bluesky.utils import MsgGenerator +from dodal.common import inject +from ophyd_async.core import ( + AutoIncrementFilenameProvider, + StaticPathProvider, + WatchableAsyncStatus, +) +from ophyd_async.fastcs.jungfrau import ( + Jungfrau, + create_jungfrau_internal_triggering_info, +) +from pydantic import PositiveInt + +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import fly_jungfrau + + +def do_internal_acquisition( + exp_time_s: float, + total_frames: PositiveInt = 1, + jungfrau: Jungfrau = inject("jungfrau"), + path_of_output_file: str | None = None, + wait: bool = False, +) -> MsgGenerator[WatchableAsyncStatus]: + """ + Kickoff internal triggering on the Jungfrau, and optionally wait for completion. Frames + per trigger will trigger as rapidly as possible according to the Jungfrau deadtime. + + Must be used within an open Bluesky run. + + Args: + exp_time_s: Length of detector exposure for each frame. + total_frames: Number of frames taken after being internally triggered. + period_between_frames_s: Time between each detector frame, including deadtime. Not needed if frames_per_triggers is 1. + jungfrau: Jungfrau device + path_of_output_file: Absolute path of the detector file output, including file name. If None, then use the PathProvider + set during jungfrau device instantiation + wait: Optionally block until data collection is complete. + """ + + # While we should generally use device instantiation to set the path, + # this will be useful during commissioning + if path_of_output_file: + _file_path = Path(path_of_output_file) + filename_provider = AutoIncrementFilenameProvider(_file_path.name) + path_provider = StaticPathProvider(filename_provider, _file_path.parent) + jungfrau._writer._path_provider = path_provider # noqa: SLF001 + + trigger_info = create_jungfrau_internal_triggering_info(total_frames, exp_time_s) + status = yield from fly_jungfrau(jungfrau, trigger_info, wait) + return status diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py new file mode 100644 index 0000000000..7ac14ae6c9 --- /dev/null +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py @@ -0,0 +1,46 @@ +from typing import cast + +import bluesky.plan_stubs as bps +from bluesky.utils import MsgGenerator +from dodal.common.watcher_utils import log_on_percentage_complete +from ophyd_async.core import ( + TriggerInfo, + WatchableAsyncStatus, +) +from ophyd_async.fastcs.jungfrau import ( + Jungfrau, +) + +from mx_bluesky.common.utils.log import LOGGER + + +def fly_jungfrau( + jungfrau: Jungfrau, trigger_info: TriggerInfo, wait: bool = False +) -> MsgGenerator[WatchableAsyncStatus]: + """Stage, prepare, and kickoff Jungfrau with a configured TriggerInfo. Optionally wait + for completion. + + Note that this plan doesn't include unstaging of the Jungfrau. + + Args: + jungfrau: Jungfrau device. + trigger_info: TriggerInfo which should be acquired using jungfrau util functions create_jungfrau_internal_triggering_info + or create_jungfrau_external_triggering_info. + wait: Optionally block until data collection is complete. + """ + + yield from bps.stage(jungfrau) + LOGGER.info("Setting up detector...") + yield from bps.prepare(jungfrau, trigger_info, wait=True) + LOGGER.info("Detector prepared. Starting acquisition") + yield from bps.kickoff(jungfrau, wait=True) + LOGGER.info("Waiting for acquisition to complete...") + status = yield from bps.complete(jungfrau, group="jf_complete") + + # StandardDetector.complete converts regular status to watchable status, + # but bluesky plan stubs can't see this currently + status = cast(WatchableAsyncStatus, status) + log_on_percentage_complete(status, "Jungfrau data collection triggers recieved", 10) + if wait: + yield from bps.wait("jf_complete") + return status diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py index f43773d39c..73f23707cd 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py @@ -36,7 +36,7 @@ def test_plan(): @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.do_external_acquisition.log_on_percentage_complete" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils.log_on_percentage_complete" ) def test_do_external_acquisition_does_wait( mock_log_on_percent_complete: MagicMock, @@ -52,7 +52,7 @@ def test_do_external_acquisition_does_wait( @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.do_external_acquisition.log_on_percentage_complete" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils.log_on_percentage_complete" ) def test_do_external_acquisition_setting_path( mock_log_on_percent_complete: MagicMock, diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py new file mode 100644 index 0000000000..ae44b0cf14 --- /dev/null +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py @@ -0,0 +1,71 @@ +import asyncio +from functools import partial +from unittest.mock import AsyncMock, MagicMock, patch + +import bluesky.plan_stubs as bps +from bluesky.preprocessors import run_decorator +from bluesky.run_engine import RunEngine +from bluesky.simulators import RunEngineSimulator, assert_message_and_return_remaining +from ophyd_async.core import AutoIncrementFilenameProvider, StaticPathProvider +from ophyd_async.fastcs.jungfrau import Jungfrau +from ophyd_async.testing import set_mock_value + +from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_internal_acquisition import ( + do_internal_acquisition, +) + + +def test_full_do_internal_acquisition(jungfrau: Jungfrau, RE: RunEngine, caplog): + @run_decorator() + def test_plan(): + status = yield from do_internal_acquisition(0.001, 5, jungfrau) + assert not status.done + val = 0 + while not status.done: + val += 1 + set_mock_value(jungfrau._writer._drv.num_captured, val) + yield from bps.wait_for([partial(asyncio.sleep, 0)]) + yield from bps.wait("jf_complete") + + jungfrau._controller.arm = AsyncMock() + RE(test_plan()) + assert "Jungfrau data collection triggers recieved: 100%" in caplog.messages + + +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils.log_on_percentage_complete" +) +def test_do_internal_acquisition_does_wait( + mock_log_on_percent_complete: MagicMock, + sim_run_engine: RunEngineSimulator, + jungfrau: Jungfrau, +): + msgs = sim_run_engine.simulate_plan( + do_internal_acquisition(0.01, 1, jungfrau, wait=True) + ) + assert_message_and_return_remaining( + msgs, lambda msg: msg.command == "wait" and msg.kwargs["group"] == "jf_complete" + ) + + +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils.log_on_percentage_complete" +) +def test_do_internal_acquisition_setting_path( + mock_log_on_percent_complete: MagicMock, + sim_run_engine: RunEngineSimulator, + jungfrau: Jungfrau, + tmpdir, +): + test_path = f"{tmpdir}/test_file" + sim_run_engine.simulate_plan( + do_internal_acquisition(0.01, 1, jungfrau, path_of_output_file=test_path) + ) + real_path_provider = jungfrau._writer._path_provider + assert isinstance(real_path_provider, StaticPathProvider) + assert isinstance( + real_path_provider._filename_provider, + AutoIncrementFilenameProvider, + ) + assert real_path_provider._filename_provider._base_filename == "test_file" + assert (real_path_provider._directory_path) == tmpdir From aff58b047debd477315275ffd95f047b60aad379 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Mon, 1 Sep 2025 16:00:20 +0100 Subject: [PATCH 09/64] Add initial plan --- .../i24/jungfrau_commissioning/do_darks.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py new file mode 100644 index 0000000000..f6c73a9dad --- /dev/null +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py @@ -0,0 +1,33 @@ +import bluesky.plan_stubs as bps +from bluesky.utils import MsgGenerator +from dodal.common import inject +from ophyd_async.core import ( + WatchableAsyncStatus, +) +from ophyd_async.fastcs.jungfrau import ( + AcquisitionType, + Jungfrau, + create_jungfrau_pedestal_triggering_info, +) +from pydantic import PositiveInt + +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import fly_jungfrau + + +def do_pedestal_darks( + exp_time_s: float, + pedestal_frames: PositiveInt = 1, + pedestal_loops: PositiveInt = 1, + period_between_frames_s: float | None = None, + jungfrau: Jungfrau = inject("jungfrau"), + path_of_output_file: str | None = None, + wait: bool = False, +) -> MsgGenerator[WatchableAsyncStatus]: + yield from bps.abs_set( + jungfrau.drv.acquisition_type, AcquisitionType.PEDESTAL, wait=True + ) + trigger_info = create_jungfrau_pedestal_triggering_info( + exp_time_s, pedestal_frames, pedestal_loops + ) + status = yield from fly_jungfrau(jungfrau, trigger_info, wait) + return status From 4bf34af5974be69bb6e896ae98f5fec7554daebc Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Mon, 1 Sep 2025 17:59:49 +0100 Subject: [PATCH 10/64] Add tests and fixes --- .../i24/jungfrau_commissioning/do_darks.py | 74 ++++++++++++++++--- .../do_external_acquisition.py | 16 ++-- .../do_internal_acquisition.py | 16 ++-- .../i24/jungfrau_commissioning/plan_utils.py | 26 ++++++- .../jungfrau_commissioning/test_do_darks.py | 49 ++++++++++++ .../test_do_external_acquisition.py | 6 +- .../test_do_internal_acquisition.py | 6 +- .../jungfrau_commissioning/test_plan_utils.py | 22 ++++++ 8 files changed, 173 insertions(+), 42 deletions(-) create mode 100644 tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py create mode 100644 tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py index f6c73a9dad..97de9bbc81 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py @@ -1,33 +1,83 @@ +import asyncio +from functools import partial + import bluesky.plan_stubs as bps +import bluesky.preprocessors as bpp from bluesky.utils import MsgGenerator from dodal.common import inject -from ophyd_async.core import ( - WatchableAsyncStatus, -) +from ophyd_async.core import WatchableAsyncStatus from ophyd_async.fastcs.jungfrau import ( AcquisitionType, + GainMode, Jungfrau, + PedestalMode, create_jungfrau_pedestal_triggering_info, ) from pydantic import PositiveInt -from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import fly_jungfrau +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( + fly_jungfrau, + override_file_name_and_path, +) def do_pedestal_darks( - exp_time_s: float, - pedestal_frames: PositiveInt = 1, - pedestal_loops: PositiveInt = 1, - period_between_frames_s: float | None = None, + exp_time_s: float = 0.001, + pedestal_frames: PositiveInt = 20, + pedestal_loops: PositiveInt = 200, jungfrau: Jungfrau = inject("jungfrau"), path_of_output_file: str | None = None, wait: bool = False, ) -> MsgGenerator[WatchableAsyncStatus]: - yield from bps.abs_set( - jungfrau.drv.acquisition_type, AcquisitionType.PEDESTAL, wait=True + """Acquire darks in pedestal mode, using dynamic gain mode. This calibrates the offsets + for the jungfrau, and must be performed before acquiring real data in dynamic gain mode. + + Args: + exp_time_s: Length of detector exposure for each frame. + pedestal_frames: Number of frames acquired per pedestal loop. + pedestal_loops: Number of times to acquire a set of pedestal_frames + jungfrau: Jungfrau device + path_of_output_file: Absolute path of the detector file output, including file name. If None, then use the PathProvider + set during jungfrau device instantiation + wait: Optionally block until data collection is complete. + """ + + prev_acq_type = yield from bps.rd(jungfrau.drv.acquisition_type) + prev_gain = yield from bps.rd(jungfrau.drv.gain_mode) + prev_pedestal_mode = yield from bps.rd(jungfrau.drv.pedestal_mode) + + if path_of_output_file: + override_file_name_and_path(jungfrau, path_of_output_file) + + yield from bps.mv( + jungfrau.drv.acquisition_type, + AcquisitionType.PEDESTAL, + jungfrau.drv.pedestal_mode, + PedestalMode.ON, + jungfrau.drv.gain_mode, + GainMode.DYNAMIC, ) + + yield from bps.wait_for([partial(asyncio.sleep, 0.5)]) + trigger_info = create_jungfrau_pedestal_triggering_info( exp_time_s, pedestal_frames, pedestal_loops ) - status = yield from fly_jungfrau(jungfrau, trigger_info, wait) - return status + + # Revert pedestal soft signal, pedestal hard signal, and gain mode to whatever they were before running the plan + def _revert_acq_type_and_gain(): + yield from bps.mv( + jungfrau.drv.acquisition_type, + prev_acq_type, + jungfrau.drv.gain_mode, + prev_gain, + jungfrau.drv.pedestal_mode, + prev_pedestal_mode, + ) + + @bpp.finalize_decorator(final_plan=lambda: _revert_acq_type_and_gain()) + def _fly_then_revert_acquisition_type_and_gain(): + status = yield from fly_jungfrau(jungfrau, trigger_info, wait) + return status + + return (yield from _fly_then_revert_acquisition_type_and_gain()) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py index 1e53ff0d2f..786630a07d 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py @@ -1,10 +1,6 @@ -from pathlib import Path - from bluesky.utils import MsgGenerator from dodal.common import inject from ophyd_async.core import ( - AutoIncrementFilenameProvider, - StaticPathProvider, WatchableAsyncStatus, ) from ophyd_async.fastcs.jungfrau import ( @@ -13,7 +9,10 @@ ) from pydantic import PositiveInt -from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import fly_jungfrau +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( + fly_jungfrau, + override_file_name_and_path, +) def do_external_acquisition( @@ -39,13 +38,8 @@ def do_external_acquisition( wait: Optionally block until data collection is complete. """ - # While we should generally use device instantiation to set the path, - # this will be useful during commissioning if path_of_output_file: - _file_path = Path(path_of_output_file) - filename_provider = AutoIncrementFilenameProvider(_file_path.name) - path_provider = StaticPathProvider(filename_provider, _file_path.parent) - jungfrau._writer._path_provider = path_provider # noqa: SLF001 + override_file_name_and_path(jungfrau, path_of_output_file) trigger_info = create_jungfrau_external_triggering_info( total_triggers, exp_time_s, period_between_frames_s diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py index 567b126cc7..24c3b57833 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py @@ -1,10 +1,6 @@ -from pathlib import Path - from bluesky.utils import MsgGenerator from dodal.common import inject from ophyd_async.core import ( - AutoIncrementFilenameProvider, - StaticPathProvider, WatchableAsyncStatus, ) from ophyd_async.fastcs.jungfrau import ( @@ -13,7 +9,10 @@ ) from pydantic import PositiveInt -from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import fly_jungfrau +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( + fly_jungfrau, + override_file_name_and_path, +) def do_internal_acquisition( @@ -39,13 +38,8 @@ def do_internal_acquisition( wait: Optionally block until data collection is complete. """ - # While we should generally use device instantiation to set the path, - # this will be useful during commissioning if path_of_output_file: - _file_path = Path(path_of_output_file) - filename_provider = AutoIncrementFilenameProvider(_file_path.name) - path_provider = StaticPathProvider(filename_provider, _file_path.parent) - jungfrau._writer._path_provider = path_provider # noqa: SLF001 + override_file_name_and_path(jungfrau, path_of_output_file) trigger_info = create_jungfrau_internal_triggering_info(total_frames, exp_time_s) status = yield from fly_jungfrau(jungfrau, trigger_info, wait) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py index 7ac14ae6c9..f0c66cad1f 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py @@ -1,9 +1,12 @@ +from pathlib import Path from typing import cast import bluesky.plan_stubs as bps from bluesky.utils import MsgGenerator from dodal.common.watcher_utils import log_on_percentage_complete from ophyd_async.core import ( + AutoIncrementFilenameProvider, + StaticPathProvider, TriggerInfo, WatchableAsyncStatus, ) @@ -13,9 +16,14 @@ from mx_bluesky.common.utils.log import LOGGER +JF_PREPARE_GROUP = "JF prepare" +JF_COMPLETE_GROUP = "JF complete" + def fly_jungfrau( - jungfrau: Jungfrau, trigger_info: TriggerInfo, wait: bool = False + jungfrau: Jungfrau, + trigger_info: TriggerInfo, + wait: bool = False, ) -> MsgGenerator[WatchableAsyncStatus]: """Stage, prepare, and kickoff Jungfrau with a configured TriggerInfo. Optionally wait for completion. @@ -31,16 +39,26 @@ def fly_jungfrau( yield from bps.stage(jungfrau) LOGGER.info("Setting up detector...") - yield from bps.prepare(jungfrau, trigger_info, wait=True) + yield from bps.prepare(jungfrau, trigger_info, group=JF_PREPARE_GROUP) + yield from bps.wait(group=JF_PREPARE_GROUP) LOGGER.info("Detector prepared. Starting acquisition") yield from bps.kickoff(jungfrau, wait=True) LOGGER.info("Waiting for acquisition to complete...") - status = yield from bps.complete(jungfrau, group="jf_complete") + status = yield from bps.complete(jungfrau, group=JF_COMPLETE_GROUP) # StandardDetector.complete converts regular status to watchable status, # but bluesky plan stubs can't see this currently status = cast(WatchableAsyncStatus, status) log_on_percentage_complete(status, "Jungfrau data collection triggers recieved", 10) if wait: - yield from bps.wait("jf_complete") + yield from bps.wait(JF_COMPLETE_GROUP) return status + + +# While we should generally use device instantiation to set the path, +# this will be useful during commissioning +def override_file_name_and_path(jungfrau: Jungfrau, path_of_output_file: str): + _file_path = Path(path_of_output_file) + filename_provider = AutoIncrementFilenameProvider(_file_path.name) + path_provider = StaticPathProvider(filename_provider, _file_path.parent) + jungfrau._writer._path_provider = path_provider # noqa: SLF001 diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py new file mode 100644 index 0000000000..265bacde45 --- /dev/null +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py @@ -0,0 +1,49 @@ +import asyncio +from functools import partial +from unittest.mock import AsyncMock, MagicMock, patch + +import bluesky.plan_stubs as bps +from bluesky.preprocessors import run_decorator +from bluesky.run_engine import RunEngine +from ophyd_async.fastcs.jungfrau import ( + AcquisitionType, + GainMode, + Jungfrau, + PedestalMode, +) +from ophyd_async.testing import set_mock_value + +from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks import do_pedestal_darks + +# todo: use bps.monitor to check that gain and pedestal mode changed and changed back during test + + +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks.override_file_name_and_path" +) +async def test_full_do_pedestal_darks( + mock_override_path: MagicMock, jungfrau: Jungfrau, RE: RunEngine, caplog +): + test_path = "path" + + @run_decorator() + def test_plan(): + status = yield from do_pedestal_darks(0.001, 2, 2, jungfrau, test_path) + assert not status.done + val = 0 + while not status.done: + val += 1 + set_mock_value(jungfrau._writer._drv.num_captured, val) + # Let status update + yield from bps.wait_for([partial(asyncio.sleep, 0)]) + + jungfrau._controller.arm = AsyncMock() + assert await jungfrau.drv.acquisition_type.get_value() == AcquisitionType.STANDARD + await jungfrau.drv.gain_mode.set(GainMode.FIX_G2) + await jungfrau.drv.pedestal_mode.set(PedestalMode.OFF) + + RE(test_plan()) + assert await jungfrau.drv.acquisition_type.get_value() == AcquisitionType.STANDARD + assert await jungfrau.drv.gain_mode.get_value() == GainMode.FIX_G2 + assert await jungfrau.drv.pedestal_mode.get_value() == PedestalMode.OFF + mock_override_path.assert_called_once_with(jungfrau, test_path) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py index 73f23707cd..7f872ba7dd 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py @@ -13,6 +13,7 @@ from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_external_acquisition import ( do_external_acquisition, ) +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import JF_COMPLETE_GROUP def test_full_do_external_acquisition(jungfrau: Jungfrau, RE: RunEngine, caplog): @@ -27,7 +28,7 @@ def test_plan(): # Let status update yield from bps.wait_for([partial(asyncio.sleep, 0)]) - yield from bps.wait("jf_complete") + yield from bps.wait(JF_COMPLETE_GROUP) jungfrau._controller.arm = AsyncMock() RE(test_plan()) @@ -47,7 +48,8 @@ def test_do_external_acquisition_does_wait( do_external_acquisition(0.01, 1, 0.02, jungfrau, wait=True) ) assert_message_and_return_remaining( - msgs, lambda msg: msg.command == "wait" and msg.kwargs["group"] == "jf_complete" + msgs, + lambda msg: msg.command == "wait" and msg.kwargs["group"] == JF_COMPLETE_GROUP, ) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py index ae44b0cf14..190cb29e5e 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py @@ -13,6 +13,7 @@ from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_internal_acquisition import ( do_internal_acquisition, ) +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import JF_COMPLETE_GROUP def test_full_do_internal_acquisition(jungfrau: Jungfrau, RE: RunEngine, caplog): @@ -25,7 +26,7 @@ def test_plan(): val += 1 set_mock_value(jungfrau._writer._drv.num_captured, val) yield from bps.wait_for([partial(asyncio.sleep, 0)]) - yield from bps.wait("jf_complete") + yield from bps.wait(JF_COMPLETE_GROUP) jungfrau._controller.arm = AsyncMock() RE(test_plan()) @@ -44,7 +45,8 @@ def test_do_internal_acquisition_does_wait( do_internal_acquisition(0.01, 1, jungfrau, wait=True) ) assert_message_and_return_remaining( - msgs, lambda msg: msg.command == "wait" and msg.kwargs["group"] == "jf_complete" + msgs, + lambda msg: msg.command == "wait" and msg.kwargs["group"] == JF_COMPLETE_GROUP, ) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py new file mode 100644 index 0000000000..c306f064cf --- /dev/null +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py @@ -0,0 +1,22 @@ +from ophyd_async.core import AutoIncrementFilenameProvider, StaticPathProvider +from ophyd_async.fastcs.jungfrau import Jungfrau + +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( + override_file_name_and_path, +) + + +def test_override_file_name_and_path( + jungfrau: Jungfrau, + tmpdir, +): + test_path = f"{tmpdir}/test_file" + override_file_name_and_path(jungfrau, test_path) + real_path_provider = jungfrau._writer._path_provider + assert isinstance(real_path_provider, StaticPathProvider) + assert isinstance( + real_path_provider._filename_provider, + AutoIncrementFilenameProvider, + ) + assert real_path_provider._filename_provider._base_filename == "test_file" + assert (real_path_provider._directory_path) == tmpdir From a8ee4291a580cb89de87ac6a4b16a5a4c869b160 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Tue, 2 Sep 2025 09:53:57 +0100 Subject: [PATCH 11/64] Monitor signals during tests --- .../i24/jungfrau_commissioning/do_darks.py | 3 -- .../jungfrau_commissioning/test_do_darks.py | 52 ++++++++++++++++--- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py index 97de9bbc81..00fd271a69 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py @@ -10,7 +10,6 @@ AcquisitionType, GainMode, Jungfrau, - PedestalMode, create_jungfrau_pedestal_triggering_info, ) from pydantic import PositiveInt @@ -52,8 +51,6 @@ def do_pedestal_darks( yield from bps.mv( jungfrau.drv.acquisition_type, AcquisitionType.PEDESTAL, - jungfrau.drv.pedestal_mode, - PedestalMode.ON, jungfrau.drv.gain_mode, GainMode.DYNAMIC, ) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py index 265bacde45..938f80f340 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py @@ -3,7 +3,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import bluesky.plan_stubs as bps -from bluesky.preprocessors import run_decorator +from bluesky.callbacks import CallbackBase +from bluesky.preprocessors import monitor_during_wrapper, run_decorator from bluesky.run_engine import RunEngine from ophyd_async.fastcs.jungfrau import ( AcquisitionType, @@ -15,7 +16,15 @@ from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks import do_pedestal_darks -# todo: use bps.monitor to check that gain and pedestal mode changed and changed back during test + +class CheckMonitor(CallbackBase): + def __init__(self, signals_to_track: list[str]): + self.signals_and_values = {signal: [] for signal in signals_to_track} + + def event(self, doc): + key, value = next(iter(doc["data"].items())) + self.signals_and_values[key].append(value) + return doc @patch( @@ -24,6 +33,7 @@ async def test_full_do_pedestal_darks( mock_override_path: MagicMock, jungfrau: Jungfrau, RE: RunEngine, caplog ): + # Test plan succeeds in RunEngine and pedestal-specific signals are changed as expected test_path = "path" @run_decorator() @@ -41,9 +51,37 @@ def test_plan(): assert await jungfrau.drv.acquisition_type.get_value() == AcquisitionType.STANDARD await jungfrau.drv.gain_mode.set(GainMode.FIX_G2) await jungfrau.drv.pedestal_mode.set(PedestalMode.OFF) - - RE(test_plan()) - assert await jungfrau.drv.acquisition_type.get_value() == AcquisitionType.STANDARD - assert await jungfrau.drv.gain_mode.get_value() == GainMode.FIX_G2 - assert await jungfrau.drv.pedestal_mode.get_value() == PedestalMode.OFF + monitor_tracker = CheckMonitor( + [ + "jungfrau-drv-acquisition_type", + "jungfrau-drv-gain_mode", + "jungfrau-drv-pedestal_mode", + ] + ) + RE.subscribe(monitor_tracker) + RE( + monitor_during_wrapper( + test_plan(), + [ + jungfrau.drv.acquisition_type, + jungfrau.drv.gain_mode, + jungfrau.drv.pedestal_mode, + ], + ) + ) + assert monitor_tracker.signals_and_values["jungfrau-drv-acquisition_type"] == [ + AcquisitionType.STANDARD, + AcquisitionType.PEDESTAL, + AcquisitionType.STANDARD, + ] + assert monitor_tracker.signals_and_values["jungfrau-drv-gain_mode"] == [ + GainMode.FIX_G2, + GainMode.DYNAMIC, + GainMode.FIX_G2, + ] + assert monitor_tracker.signals_and_values["jungfrau-drv-pedestal_mode"] == [ + PedestalMode.OFF, + PedestalMode.ON, + PedestalMode.OFF, + ] mock_override_path.assert_called_once_with(jungfrau, test_path) From 61b6e3879628eb0eb764c84ab939ddb6bae7b7b3 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Tue, 2 Sep 2025 10:29:12 +0100 Subject: [PATCH 12/64] Add full do darks plan --- .../i24/jungfrau_commissioning/do_darks.py | 78 ++++++++++++++++++- .../i24/jungfrau_commissioning/plan_utils.py | 3 +- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py index 00fd271a69..00ca119d4f 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py @@ -74,7 +74,83 @@ def _revert_acq_type_and_gain(): @bpp.finalize_decorator(final_plan=lambda: _revert_acq_type_and_gain()) def _fly_then_revert_acquisition_type_and_gain(): - status = yield from fly_jungfrau(jungfrau, trigger_info, wait) + status = yield from fly_jungfrau( + jungfrau, + trigger_info, + wait, + log_on_percentage_message="Jungfrau dynamic gain mode darks triggers recieved", + ) + return status + + return (yield from _fly_then_revert_acquisition_type_and_gain()) + + +def do_darks_full( + exp_time_s: float = 0.001, + pedestal_frames: PositiveInt = 20, + pedestal_loops: PositiveInt = 200, + jungfrau: Jungfrau = inject("jungfrau"), + path_of_output_file: str | None = None, + wait: bool = True, +) -> MsgGenerator[WatchableAsyncStatus]: + prev_acq_type = yield from bps.rd(jungfrau.drv.acquisition_type) + prev_gain = yield from bps.rd(jungfrau.drv.gain_mode) + prev_pedestal_mode = yield from bps.rd(jungfrau.drv.pedestal_mode) + + if path_of_output_file: + override_file_name_and_path(jungfrau, path_of_output_file) + + trigger_info = create_jungfrau_pedestal_triggering_info( + exp_time_s, pedestal_frames, pedestal_loops + ) + + yield from bps.mv( + jungfrau.drv.acquisition_type, + AcquisitionType.PEDESTAL, + jungfrau.drv.gain_mode, + GainMode.DYNAMIC, + ) + + yield from fly_jungfrau( + jungfrau, + trigger_info, + wait, + log_on_percentage_message=f"Jungfrau {GainMode.DYNAMIC} gain mode darks triggers recieved", + ) + yield from bps.mv(jungfrau.drv.gain_mode, GainMode.FORCE_SWITCH_G1) + yield from fly_jungfrau( + jungfrau, + trigger_info, + wait, + log_on_percentage_message=f"Jungfrau {GainMode.FORCE_SWITCH_G1} gain mode darks triggers recieved", + ) + yield from bps.mv(jungfrau.drv.gain_mode, GainMode.FORCE_SWITCH_G2) + yield from fly_jungfrau( + jungfrau, + trigger_info, + wait, + log_on_percentage_message=f"Jungfrau {GainMode.FORCE_SWITCH_G2} gain mode darks triggers recieved", + ) + + # Revert pedestal soft signal, pedestal hard signal, and gain mode to whatever they were before running the plan + def _revert_acq_type_and_gain(): + yield from bps.mv( + jungfrau.drv.acquisition_type, + prev_acq_type, + jungfrau.drv.gain_mode, + prev_gain, + jungfrau.drv.pedestal_mode, + prev_pedestal_mode, + ) + + @bpp.finalize_decorator(final_plan=lambda: _revert_acq_type_and_gain()) + def _fly_then_revert_acquisition_type_and_gain(): + status = yield from fly_jungfrau( + jungfrau, + trigger_info, + wait, + log_on_percentage_message=f"Jungfrau {GainMode.FORCE_SWITCH_G2} gain mode darks triggers recieved", + ) return status return (yield from _fly_then_revert_acquisition_type_and_gain()) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py index f0c66cad1f..f0fc8dd747 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py @@ -24,6 +24,7 @@ def fly_jungfrau( jungfrau: Jungfrau, trigger_info: TriggerInfo, wait: bool = False, + log_on_percentage_message: str = "Jungfrau data collection triggers recieved", ) -> MsgGenerator[WatchableAsyncStatus]: """Stage, prepare, and kickoff Jungfrau with a configured TriggerInfo. Optionally wait for completion. @@ -49,7 +50,7 @@ def fly_jungfrau( # StandardDetector.complete converts regular status to watchable status, # but bluesky plan stubs can't see this currently status = cast(WatchableAsyncStatus, status) - log_on_percentage_complete(status, "Jungfrau data collection triggers recieved", 10) + log_on_percentage_complete(status, log_on_percentage_message, 10) if wait: yield from bps.wait(JF_COMPLETE_GROUP) return status From 6cfab71941453f12265efd60f4516f899a8800ad Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Tue, 2 Sep 2025 16:57:58 +0100 Subject: [PATCH 13/64] Add tests and docstring --- .../i24/jungfrau_commissioning/do_darks.py | 62 +++++++------------ .../jungfrau_commissioning/test_do_darks.py | 52 +++++++++++++--- 2 files changed, 64 insertions(+), 50 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py index 00ca119d4f..661b21e78e 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py @@ -10,6 +10,7 @@ AcquisitionType, GainMode, Jungfrau, + create_jungfrau_internal_triggering_info, create_jungfrau_pedestal_triggering_info, ) from pydantic import PositiveInt @@ -37,12 +38,11 @@ def do_pedestal_darks( pedestal_loops: Number of times to acquire a set of pedestal_frames jungfrau: Jungfrau device path_of_output_file: Absolute path of the detector file output, including file name. If None, then use the PathProvider - set during jungfrau device instantiation + set during Jungfrau device instantiation wait: Optionally block until data collection is complete. """ prev_acq_type = yield from bps.rd(jungfrau.drv.acquisition_type) - prev_gain = yield from bps.rd(jungfrau.drv.gain_mode) prev_pedestal_mode = yield from bps.rd(jungfrau.drv.pedestal_mode) if path_of_output_file: @@ -61,13 +61,11 @@ def do_pedestal_darks( exp_time_s, pedestal_frames, pedestal_loops ) - # Revert pedestal soft signal, pedestal hard signal, and gain mode to whatever they were before running the plan + # Revert pedestal soft signal and pedestal hard signal to whatever they were before running the plan def _revert_acq_type_and_gain(): yield from bps.mv( jungfrau.drv.acquisition_type, prev_acq_type, - jungfrau.drv.gain_mode, - prev_gain, jungfrau.drv.pedestal_mode, prev_pedestal_mode, ) @@ -78,35 +76,40 @@ def _fly_then_revert_acquisition_type_and_gain(): jungfrau, trigger_info, wait, - log_on_percentage_message="Jungfrau dynamic gain mode darks triggers recieved", + log_on_percentage_message="Jungfrau pedestal dynamic gain mode darks triggers recieved", ) return status return (yield from _fly_then_revert_acquisition_type_and_gain()) -def do_darks_full( +def do_darks_for_dynamic_gain_switching( exp_time_s: float = 0.001, - pedestal_frames: PositiveInt = 20, - pedestal_loops: PositiveInt = 200, + triggers_per_dark_scan: PositiveInt = 1000, jungfrau: Jungfrau = inject("jungfrau"), path_of_output_file: str | None = None, - wait: bool = True, -) -> MsgGenerator[WatchableAsyncStatus]: - prev_acq_type = yield from bps.rd(jungfrau.drv.acquisition_type) - prev_gain = yield from bps.rd(jungfrau.drv.gain_mode) - prev_pedestal_mode = yield from bps.rd(jungfrau.drv.pedestal_mode) +) -> MsgGenerator: + """Internally take a set of images at dynamic gain, forced gain 1, and forced gain 2. + Blocks until all 3 collections are complete. + + Args: + exp_time_s: Length of detector exposure for each frame. + triggers_per_dark_scan: Number of frames acquired for each of the 3 dark scans. + jungfrau: Jungfrau device + path_of_output_file: Absolute path of the detector file output, including file name. If None, then use the PathProvider + set during Jungfrau device instantiation + """ + + wait = True if path_of_output_file: override_file_name_and_path(jungfrau, path_of_output_file) - trigger_info = create_jungfrau_pedestal_triggering_info( - exp_time_s, pedestal_frames, pedestal_loops + trigger_info = create_jungfrau_internal_triggering_info( + triggers_per_dark_scan, exp_time_s ) yield from bps.mv( - jungfrau.drv.acquisition_type, - AcquisitionType.PEDESTAL, jungfrau.drv.gain_mode, GainMode.DYNAMIC, ) @@ -131,26 +134,3 @@ def do_darks_full( wait, log_on_percentage_message=f"Jungfrau {GainMode.FORCE_SWITCH_G2} gain mode darks triggers recieved", ) - - # Revert pedestal soft signal, pedestal hard signal, and gain mode to whatever they were before running the plan - def _revert_acq_type_and_gain(): - yield from bps.mv( - jungfrau.drv.acquisition_type, - prev_acq_type, - jungfrau.drv.gain_mode, - prev_gain, - jungfrau.drv.pedestal_mode, - prev_pedestal_mode, - ) - - @bpp.finalize_decorator(final_plan=lambda: _revert_acq_type_and_gain()) - def _fly_then_revert_acquisition_type_and_gain(): - status = yield from fly_jungfrau( - jungfrau, - trigger_info, - wait, - log_on_percentage_message=f"Jungfrau {GainMode.FORCE_SWITCH_G2} gain mode darks triggers recieved", - ) - return status - - return (yield from _fly_then_revert_acquisition_type_and_gain()) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py index 938f80f340..a6c4156e3d 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py @@ -14,7 +14,10 @@ ) from ophyd_async.testing import set_mock_value -from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks import do_pedestal_darks +from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks import ( + do_darks_for_dynamic_gain_switching, + do_pedestal_darks, +) class CheckMonitor(CallbackBase): @@ -31,9 +34,9 @@ def event(self, doc): "mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks.override_file_name_and_path" ) async def test_full_do_pedestal_darks( - mock_override_path: MagicMock, jungfrau: Jungfrau, RE: RunEngine, caplog + mock_override_path: MagicMock, jungfrau: Jungfrau, RE: RunEngine ): - # Test plan succeeds in RunEngine and pedestal-specific signals are changed as expected + # Test that plan succeeds in RunEngine and pedestal-specific signals are changed as expected test_path = "path" @run_decorator() @@ -54,7 +57,6 @@ def test_plan(): monitor_tracker = CheckMonitor( [ "jungfrau-drv-acquisition_type", - "jungfrau-drv-gain_mode", "jungfrau-drv-pedestal_mode", ] ) @@ -74,14 +76,46 @@ def test_plan(): AcquisitionType.PEDESTAL, AcquisitionType.STANDARD, ] - assert monitor_tracker.signals_and_values["jungfrau-drv-gain_mode"] == [ - GainMode.FIX_G2, - GainMode.DYNAMIC, - GainMode.FIX_G2, - ] assert monitor_tracker.signals_and_values["jungfrau-drv-pedestal_mode"] == [ PedestalMode.OFF, PedestalMode.ON, PedestalMode.OFF, ] mock_override_path.assert_called_once_with(jungfrau, test_path) + + +@patch("mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks.fly_jungfrau") +async def test_full_do_darks_for_dynamic_gain_switching( + mock_do_darks: MagicMock, + jungfrau: Jungfrau, + RE: RunEngine, +): + monitor_tracker = CheckMonitor( + [ + "jungfrau-drv-gain_mode", + ] + ) + RE.subscribe(monitor_tracker) + + @run_decorator() + def test_plan(): + yield from do_darks_for_dynamic_gain_switching( + triggers_per_dark_scan=5, jungfrau=jungfrau, path_of_output_file="test" + ) + + jungfrau._controller.arm = AsyncMock() + RE( + monitor_during_wrapper( + test_plan(), + [ + jungfrau.drv.gain_mode, + ], + ) + ) + assert monitor_tracker.signals_and_values["jungfrau-drv-gain_mode"] == [ + GainMode.DYNAMIC, + GainMode.DYNAMIC, + GainMode.FORCE_SWITCH_G1, + GainMode.FORCE_SWITCH_G2, + ] + assert mock_do_darks.call_count == 3 From f832c1d19f8e09934d3912bac1214b1074654d61 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Wed, 3 Sep 2025 19:01:28 +0100 Subject: [PATCH 14/64] WIP add rotation scan plan --- .../callbacks/__init__.py | 0 .../callbacks/metadata_writer.py | 83 ++++++ .../rotation_scan_plans.py | 239 ++++++++++++++++++ .../jungfrau_commissioning/utility_plans.py | 34 +++ .../experiment_plans/rotation/__init__.py | 0 .../rotation/rotation_utils.py | 127 ++++++++++ .../common/experiment_plans/setup_zebra.py | 115 +++++++++ .../external_interaction/config_server.py | 4 +- src/mx_bluesky/common/parameters/constants.py | 4 +- .../common/parameters/device_composites.py | 9 + .../parameters/rotation.py | 16 +- .../device_setup_plans/setup_zebra.py | 103 -------- .../experiment_plans/experiment_registry.py | 4 +- .../load_centre_collect_full_plan.py | 4 +- .../experiment_plans/rotation_scan_plan.py | 138 +--------- .../callbacks/rotation/ispyb_callback.py | 4 +- .../callbacks/rotation/ispyb_mapping.py | 4 +- .../callbacks/rotation/nexus_callback.py | 9 +- .../external_interaction/config_server.py | 4 +- .../hyperion/parameters/constants.py | 15 +- .../parameters/load_centre_collect.py | 4 +- tests/conftest.py | 4 +- .../callbacks/test_external_callbacks.py | 4 +- .../hyperion/external_interaction/conftest.py | 4 +- .../test_ispyb_dev_connection.py | 4 +- .../external_interaction/test_nexgen.py | 4 +- .../common/test_snapshot_callback.py | 4 +- .../test_config_server.py | 8 +- tests/unit_tests/hyperion/conftest.py | 4 +- .../device_setup_plans/test_zebra_setup.py | 2 +- .../test_load_centre_collect_full_plan.py | 8 +- .../test_rotation_scan_plan.py | 9 +- .../callbacks/conftest.py | 4 +- .../callbacks/test_rotation_callbacks.py | 4 +- .../hyperion/external_interaction/conftest.py | 4 +- .../test_compare_nexus_to_gda_exhaustively.py | 4 +- .../test_write_rotation_nexus.py | 4 +- .../parameters/test_parameter_model.py | 4 +- 38 files changed, 712 insertions(+), 287 deletions(-) create mode 100644 src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/__init__.py create mode 100644 src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py create mode 100644 src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plans.py create mode 100644 src/mx_bluesky/beamlines/i24/jungfrau_commissioning/utility_plans.py create mode 100644 src/mx_bluesky/common/experiment_plans/rotation/__init__.py create mode 100644 src/mx_bluesky/common/experiment_plans/rotation/rotation_utils.py create mode 100644 src/mx_bluesky/common/experiment_plans/setup_zebra.py rename src/mx_bluesky/{hyperion => common}/parameters/rotation.py (94%) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/__init__.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py new file mode 100644 index 0000000000..627b3a1764 --- /dev/null +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py @@ -0,0 +1,83 @@ +import json +from pathlib import Path + +from bluesky.callbacks import CallbackBase + +from mx_bluesky.common.parameters.rotation import SingleRotationScan +from mx_bluesky.common.utils.log import LOGGER + +EXPERIMENT_PARAM_DUMP_FILENAME = "experiment_params.json" +READING_DUMP_FILENAME = "collection_info.json" + + +class JsonMetadataWriter(CallbackBase): + """Callback class to handle the creation of metadata json files for commissioning. + + To use, subscribe the Bluesky RunEngine to an instance of this class. + E.g.: + metadata_writer_callback = JsonMetadataWriter(parameters) + RE.subscribe(metadata_writer_callback) + Or decorate a plan using bluesky.preprocessors.subs_decorator. + + See: https://blueskyproject.io/bluesky/callbacks.html#ways-to-invoke-callbacks + + """ + + def __init__(self, beam_xy: tuple[float, float]): + self.beam_xy = beam_xy + super().__init__() + + descriptors: dict[str, dict] = {} + parameters: SingleRotationScan + wavelength: float | None = None + flux: float | None = None + transmission: float | None = None + + def start(self, doc: dict): # type: ignore + if doc.get("subplan_name") == "rotation_scan_with_cleanup": + LOGGER.info( + "Metadata writer recieved start document with experiment parameters." + ) + json_params = doc.get("rotation_scan_params") + assert json_params is not None + self.parameters = SingleRotationScan(**json.loads(json_params)) + self.run_start_uid = doc.get("uid") + + def descriptor(self, doc: dict): # type: ignore + self.descriptors[doc["uid"]] = doc + + def event(self, doc: dict): # type: ignore + LOGGER.info("Nexus handler received event document.") + event_descriptor = self.descriptors[doc["descriptor"]] + + if event_descriptor.get("name") == "beam params": + assert self.parameters is not None + data = doc.get("data") + assert data is not None + self.wavelength_in_a = data.get("dcm-wavelength_in_a") + self.energy_in_kev = data.get("dcm-energy_in_kev") + self.detector_distance_mm = data.get("detector_motion-z") + LOGGER.info( + f"Nexus handler received beam parameters, transmission: {self.transmission}, flux: {self.flux}, wavelength: {self.wavelength}, det distance: {self.detector_distance}, beam_xy: {self.beam_xy}" + ) + LOGGER.info("") + + def stop(self, doc: dict): # type: ignore + if ( + self.run_start_uid is not None + and doc.get("run_start") == self.run_start_uid + ): + with open( + Path(self.parameters.storage_directory) / READING_DUMP_FILENAME, "w" + ) as f: + f.write( + json.dumps( + { + "wavelength_in_a": self.wavelength_in_a, + "energy_kev": self.energy_in_kev, + "angular_increment_deg": self.parameters.rotation_increment_deg, + "beam_xy_mm": self.beam_xy, + "detector_distance_mm": self.detector_distance_mm, + } + ) + ) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plans.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plans.py new file mode 100644 index 0000000000..760fc9406b --- /dev/null +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plans.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import bluesky.plan_stubs as bps +import bluesky.preprocessors as bpp +import pydantic +from dodal.devices.attenuator.attenuator import EnumFilterAttenuator +from dodal.devices.hutch_shutter import HutchShutter, ShutterState +from dodal.devices.i24.aperture import Aperture, AperturePositions +from dodal.devices.i24.beamstop import Beamstop, BeamstopPositions +from dodal.devices.i24.dcm import DCM +from dodal.devices.i24.dual_backlight import BacklightPositions, DualBacklight +from dodal.devices.motors import YZStage +from dodal.devices.synchrotron import Synchrotron +from dodal.devices.xbpm_feedback import XBPMFeedback +from dodal.devices.zebra.zebra import Zebra +from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter +from dodal.plan_stubs.check_topup import check_topup_and_wait_if_necessary +from ophyd_async.fastcs.jungfrau import ( + Jungfrau, + create_jungfrau_external_triggering_info, +) + +from mx_bluesky.beamlines.i24.jungfrau_commissioning.callbacks.metadata_writer import ( + JsonMetadataWriter, +) +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( + JF_COMPLETE_GROUP, + fly_jungfrau, + override_file_name_and_path, +) +from mx_bluesky.beamlines.i24.serial.setup_beamline.setup_zebra_plans import ( + disarm_zebra, +) +from mx_bluesky.common.experiment_plans.inner_plans.read_hardware import ( + read_hardware_plan, +) +from mx_bluesky.common.experiment_plans.rotation.rotation_utils import ( + RotationMotionProfile, + calculate_motion_profile, +) +from mx_bluesky.common.experiment_plans.setup_zebra import ( + arm_zebra, + setup_zebra_for_rotation, +) +from mx_bluesky.common.parameters.constants import ( + DocDescriptorNames, + PlanGroupCheckpointConstants, + PlanNameConstants, +) +from mx_bluesky.common.parameters.device_composites import GonioWithXYZOmega +from mx_bluesky.common.parameters.rotation import ( + SingleRotationScan, +) +from mx_bluesky.common.utils.log import LOGGER + +EXPERIMENT_PARAM_DUMP_FILENAME = "experiment_params.json" +READING_DUMP_FILENAME = "collection_info.json" + +JF_DET_STAGE_Y_POSITION = 0 # TODO find out what this is! + + +@pydantic.dataclasses.dataclass(config={"arbitrary_types_allowed": True}) +class RotationScanComposite: + """All devices which are directly or indirectly required by this plan""" + + aperture: Aperture + attenuator: EnumFilterAttenuator + jungfrau: Jungfrau + gonio: GonioWithXYZOmega + synchrotron: Synchrotron + sample_shutter: ZebraShutter + zebra: Zebra + xbpm_feedback: XBPMFeedback + hutch_shutter: HutchShutter + beamstop: Beamstop + det_stage: YZStage # TODO add JF position to det stage device + backlight: DualBacklight + dcm: DCM + + +def set_up_beamline_for_rotation( + composite: RotationScanComposite, +): + """Check hutch is open, move backlight in, then, in parallel, + move aperture in, move backlight out and move det stage in""" + + hutch_shutter_state: ShutterState = yield from bps.rd( + composite.hutch_shutter.status + ) + LOGGER.info(f"Hutch shutter: {hutch_shutter_state}") + if hutch_shutter_state != ShutterState.OPEN: + LOGGER.error(f"Hutch shutter is not open! State is {hutch_shutter_state}") + raise Exception() + LOGGER.info("Making sure backlight is moved out...") + yield from bps.mv(composite.backlight.backlight_position, BacklightPositions.OUT) + + LOGGER.info( + "Making sure aperture and beamstop are in, and detector stage is in position" + ) + yield from bps.mv( + composite.aperture, + AperturePositions, + composite.beamstop.pos_select, + composite.beamstop, + BeamstopPositions.DATA_COLLECTION, + composite.det_stage.y, + JF_DET_STAGE_Y_POSITION, + ) + + +def single_rotation_plan( + composite: RotationScanComposite, + params: SingleRotationScan, +): + """A stub plan to collect diffraction images from a sample continuously rotating + about a fixed axis - for now this axis is limited to omega. + Needs additional setup of the sample environment and a wrapper to clean up.""" + + # This should be somewhere more sensible - like in the parameter model + if not params.detector_distance_mm: + raise ValueError("Must specify detector distance in mm") + beam_xy = params.detector_params.get_beam_position_mm(params.detector_distance_mm) + + yield from set_up_beamline_for_rotation(composite) + LOGGER.info( + f"Moving detector Z stage to specified {params.detector_distance_mm} mm..." + ) + # This can probably be done in parallel with other stuff, but will do wait for now until tested + yield from bps.mv(composite.det_stage.z, params.detector_distance_mm) + + # This value isn't actually used, see https://github.com/DiamondLightSource/mx-bluesky/issues/1224 + _motor_time_to_speed = 1 + _max_velocity_deg_s = yield from bps.rd(composite.gonio.omega.max_velocity) + + motion_values = calculate_motion_profile( + params, _motor_time_to_speed, _max_velocity_deg_s + ) + + metadata_writer = JsonMetadataWriter(beam_xy) + + @bpp.subs_decorator([metadata_writer]) + @bpp.set_run_key_decorator(PlanNameConstants.ROTATION_MAIN) + @bpp.run_decorator( + md={ + "subplan_name": PlanNameConstants.ROTATION_MAIN, + "scan_points": [params.scan_points], + } + ) + def _rotation_scan_plan( + motion_values: RotationMotionProfile, + composite: RotationScanComposite, + ): + _jf_trigger_info = create_jungfrau_external_triggering_info( + params.num_images, params.detector_params.exposure_time_s + ) + + axis = composite.gonio.omega + + # can move to start as fast as possible + yield from bps.abs_set( + axis.velocity, motion_values.max_velocity_deg_s, wait=True + ) + LOGGER.info(f"Moving omega to beginning, {motion_values.start_scan_deg=}") + yield from bps.abs_set( + axis, + motion_values.start_motion_deg, + group=PlanGroupCheckpointConstants.ROTATION_READY_FOR_DC, + ) + + yield from setup_zebra_for_rotation( + composite.zebra, + composite.sample_shutter, + start_angle=motion_values.start_scan_deg, + scan_width=motion_values.scan_width_deg, + direction=motion_values.direction, + shutter_opening_deg=motion_values.shutter_opening_deg, + shutter_opening_s=motion_values.shutter_time_s, + group="setup_zebra", + ) + + LOGGER.info("Wait for any previous moves...") + # wait for all the setup tasks at once + yield from bps.wait(PlanGroupCheckpointConstants.ROTATION_READY_FOR_DC) + yield from bps.wait(PlanGroupCheckpointConstants.MOVE_GONIO_TO_START) + + yield from read_hardware_plan( + [composite.synchrotron, composite.gonio], + DocDescriptorNames.HARDWARE_READ_PRE, + ) + + # Get ready for the actual scan + yield from bps.abs_set( + axis.velocity, motion_values.speed_for_rotation_deg_s, wait=True + ) + + yield from bps.wait("setup_zebra") + yield from arm_zebra(composite.zebra) + + # Check topup gate + yield from check_topup_and_wait_if_necessary( + composite.synchrotron, + motion_values.total_exposure_s, + ops_time=10.0, # Additional time to account for rotation, is s + ) # See #https://github.com/DiamondLightSource/hyperion/issues/932 + + override_file_name_and_path( + composite.jungfrau, params.detector_params.full_filename + ) + + yield from fly_jungfrau( + composite.jungfrau, + _jf_trigger_info, + wait=False, + log_on_percentage_message="Jungfrau rotation scan triggers received", + ) + + LOGGER.info("Executing rotation scan") + yield from bps.rel_set(axis, motion_values.distance_to_move_deg, wait=True) + + yield from read_hardware_plan( + [composite.attenuator, composite.jungfrau], + DocDescriptorNames.HARDWARE_READ_DURING, + ) + + yield from bps.wait(group=JF_COMPLETE_GROUP) + + # TODO check bluesky doesnt do this for us + yield from bpp.contingency_wrapper( + _rotation_scan_plan(motion_values, composite), + except_plan=lambda: (yield from bps.unstage(composite.jungfrau)), + ) + + yield from _rotation_scan_plan(motion_values, composite) + + +def cleanup_plan(zebra: Zebra, group="cleanup"): + yield from bps.abs_set(zebra.inputs.soft_in_1, 0, group=group) + yield from disarm_zebra(zebra) + yield from bps.wait("cleanup") diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/utility_plans.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/utility_plans.py new file mode 100644 index 0000000000..68df83baa8 --- /dev/null +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/utility_plans.py @@ -0,0 +1,34 @@ +import bluesky.plan_stubs as bps +from dodal.devices.attenuator.attenuator import EnumFilterAttenuator + +from mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plans import ( + RotationScanComposite, +) +from mx_bluesky.common.utils.log import LOGGER + +METADATA_READ = "metadata read" + + +# Long term this should be done by adding a set function to the attenuator device. +# See https://github.com/DiamondLightSource/dodal/issues/972 +def set_transmission( + set_attenuator: EnumFilterAttenuator, transmission_fraction: float +): + LOGGER.info(f"Setting transmission to {transmission_fraction:.3f}") + yield from bps.abs_set( + set_attenuator.transmission_setpoint, transmission_fraction, wait=True + ) + f1_inpos = yield from bps.rd(set_attenuator.filters[0]) + f2_inpos = yield from bps.rd(set_attenuator.filters[1]) + while not (f1_inpos and f2_inpos): + LOGGER.info(f"Waiting for filters: {f1_inpos=}, {f2_inpos=}...") + f1_inpos = yield from bps.rd(set_attenuator.filters[0]) + f2_inpos = yield from bps.rd(set_attenuator.filters[1]) + yield from bps.sleep(0.5) + + +def read_devices_for_metadata(composite: RotationScanComposite): + yield from bps.create(METADATA_READ) + yield from bps.read(composite.dcm.energy_in_kev) + yield from bps.read(composite.dcm.wavelength_in_a) + yield from bps.read(composite.det_stage.z) diff --git a/src/mx_bluesky/common/experiment_plans/rotation/__init__.py b/src/mx_bluesky/common/experiment_plans/rotation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/mx_bluesky/common/experiment_plans/rotation/rotation_utils.py b/src/mx_bluesky/common/experiment_plans/rotation/rotation_utils.py new file mode 100644 index 0000000000..4570ed044c --- /dev/null +++ b/src/mx_bluesky/common/experiment_plans/rotation/rotation_utils.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import dataclasses + +from dodal.devices.zebra.zebra import RotationDirection +from dodal.utils import get_beamline_name + +from mx_bluesky.common.parameters.constants import RotationParamConstants +from mx_bluesky.common.parameters.rotation import SingleRotationScan +from mx_bluesky.common.utils.log import LOGGER + +DEFAULT_DIRECTION = RotationDirection.NEGATIVE +DEFAULT_MAX_VELOCITY = 120 +# Use a slightly larger time to acceleration than EPICS as it's better to be cautious +ACCELERATION_MARGIN = 1.5 + + +@dataclasses.dataclass +class RotationMotionProfile: + start_scan_deg: float + start_motion_deg: float + scan_width_deg: float + shutter_time_s: float + direction: RotationDirection + speed_for_rotation_deg_s: float + acceleration_offset_deg: float + shutter_opening_deg: float + total_exposure_s: float + distance_to_move_deg: float + max_velocity_deg_s: float + + +def calculate_motion_profile( + params: SingleRotationScan, + motor_time_to_speed_s: float, + max_velocity_deg_s: float, +) -> RotationMotionProfile: + """Calculates the various numbers needed for motions in the rotation scan. + Rotates through "scan width" plus twice an "offset" to take into account + acceleration at the start and deceleration at the end, plus the number of extra + degrees of rotation needed to make sure the fast shutter has fully opened before the + detector trigger is sent. + See https://github.com/DiamondLightSource/hyperion/wiki/rotation-scan-geometry + for a simple pictorial explanation.""" + + assert params.rotation_increment_deg > 0 + + direction = params.rotation_direction + start_scan_deg = params.omega_start_deg + + if RotationParamConstants.OMEGA_FLIP: + # If omega_flip is True then the motor omega axis is inverted with respect to the + # coordinate system. + start_scan_deg = -start_scan_deg + direction = ( + direction.POSITIVE + if direction == direction.NEGATIVE + else direction.NEGATIVE + ) + + num_images = params.num_images + shutter_time_s = params.shutter_opening_time_s + image_width_deg = params.rotation_increment_deg + exposure_time_s = params.exposure_time_s + motor_time_to_speed_s *= ACCELERATION_MARGIN + + LOGGER.info("Calculating rotation scan motion profile:") + LOGGER.info( + f"{num_images=}, {shutter_time_s=}, {image_width_deg=}, {exposure_time_s=}, {direction=}" + ) + + scan_width_deg = num_images * params.rotation_increment_deg + LOGGER.info(f"{scan_width_deg=} = {num_images=} * {params.rotation_increment_deg=}") + + speed_for_rotation_deg_s = image_width_deg / exposure_time_s + LOGGER.info("speed_for_rotation_deg_s = image_width_deg / exposure_time_s") + LOGGER.info( + f"{speed_for_rotation_deg_s=} = {image_width_deg=} / {exposure_time_s=}" + ) + + acceleration_offset_deg = motor_time_to_speed_s * speed_for_rotation_deg_s + LOGGER.info( + f"{acceleration_offset_deg=} = {motor_time_to_speed_s=} * {speed_for_rotation_deg_s=}" + ) + + start_motion_deg = start_scan_deg - (acceleration_offset_deg * direction.multiplier) + LOGGER.info( + f"{start_motion_deg=} = {start_scan_deg=} - ({acceleration_offset_deg=} * {direction.multiplier=})" + ) + + shutter_opening_deg = speed_for_rotation_deg_s * shutter_time_s + LOGGER.info( + f"{shutter_opening_deg=} = {speed_for_rotation_deg_s=} * {shutter_time_s=}" + ) + + shutter_opening_deg = speed_for_rotation_deg_s * shutter_time_s + LOGGER.info( + f"{shutter_opening_deg=} = {speed_for_rotation_deg_s=} * {shutter_time_s=}" + ) + + total_exposure_s = num_images * exposure_time_s + LOGGER.info(f"{total_exposure_s=} = {num_images=} * {exposure_time_s=}") + + distance_to_move_deg = ( + scan_width_deg + shutter_opening_deg + acceleration_offset_deg * 2 + ) * direction.multiplier + LOGGER.info( + f"{distance_to_move_deg=} = ({scan_width_deg=} + {shutter_opening_deg=} + {acceleration_offset_deg=} * 2) * {direction=})" + ) + + # See https://github.com/DiamondLightSource/mx-bluesky/issues/1224 + if get_beamline_name("i03") == "i24": + acceleration_offset_deg = 10 + + return RotationMotionProfile( + start_scan_deg=start_scan_deg, + start_motion_deg=start_motion_deg, + scan_width_deg=scan_width_deg, + shutter_time_s=shutter_time_s, + direction=direction, + speed_for_rotation_deg_s=speed_for_rotation_deg_s, + acceleration_offset_deg=acceleration_offset_deg, + shutter_opening_deg=shutter_opening_deg, + total_exposure_s=total_exposure_s, + distance_to_move_deg=distance_to_move_deg, + max_velocity_deg_s=max_velocity_deg_s, + ) diff --git a/src/mx_bluesky/common/experiment_plans/setup_zebra.py b/src/mx_bluesky/common/experiment_plans/setup_zebra.py new file mode 100644 index 0000000000..0093040d80 --- /dev/null +++ b/src/mx_bluesky/common/experiment_plans/setup_zebra.py @@ -0,0 +1,115 @@ +import bluesky.plan_stubs as bps +from dodal.devices.zebra.zebra import ( + ArmDemand, + EncEnum, + I03Axes, + RotationDirection, + Zebra, +) +from dodal.devices.zebra.zebra_controlled_shutter import ( + ZebraShutter, + ZebraShutterControl, +) + +from mx_bluesky.common.parameters.constants import ZEBRA_STATUS_TIMEOUT +from mx_bluesky.common.utils.log import LOGGER +from mx_bluesky.phase1_zebra.device_setup_plans.setup_zebra import ( + configure_zebra_and_shutter_for_auto_shutter, +) + + +def arm_zebra(zebra: Zebra): + yield from bps.abs_set(zebra.pc.arm, ArmDemand.ARM, wait=True) + + +def tidy_up_zebra_after_rotation_scan( + zebra: Zebra, + zebra_shutter: ZebraShutter, + group="tidy_up_zebra_after_rotation", + wait=True, +): + yield from bps.abs_set(zebra.pc.arm, ArmDemand.DISARM, group=group) + yield from bps.abs_set( + zebra_shutter.control_mode, ZebraShutterControl.MANUAL, group=group + ) + if wait: + yield from bps.wait(group, timeout=ZEBRA_STATUS_TIMEOUT) + + +def setup_zebra_for_rotation( + zebra: Zebra, + zebra_shutter: ZebraShutter, + axis: EncEnum = I03Axes.OMEGA, + start_angle: float = 0, + scan_width: float = 360, + shutter_opening_deg: float = 2.5, + shutter_opening_s: float = 0.04, + direction: RotationDirection = RotationDirection.POSITIVE, + group: str = "setup_zebra_for_rotation", + wait: bool = True, +): + """Set up the Zebra to collect a rotation dataset. Any plan using this is + responsible for setting the smargon velocity appropriately so that the desired + image width is achieved with the exposure time given here. + + Parameters: + zebra: The zebra device to use + axis: I03 axes enum representing which axis to use for position + compare. Currently always omega. + start_angle: Position at which the scan should begin, in degrees. + scan_width: Total angle through which to collect, in degrees. + shutter_opening_deg:How many degrees of rotation it takes for the fast shutter + to open. Increases the gate width. + shutter_opening_s: How many seconds it takes for the fast shutter to open. The + detector pulse is delayed after the shutter signal by this + amount. + direction: RotationDirection enum for positive or negative. + Defaults to Positive. + group: A name for the group of statuses generated + wait: Block until all the settings have completed + """ + + if not isinstance(direction, RotationDirection): + raise ValueError( + "Disallowed rotation direction provided to Zebra setup plan. " + "Use RotationDirection.POSITIVE or RotationDirection.NEGATIVE." + ) + yield from bps.abs_set(zebra.pc.dir, direction.value, group=group) + LOGGER.info("ZEBRA SETUP: START") + # Set gate start, adjust for shutter opening time if necessary + LOGGER.info(f"ZEBRA SETUP: degrees to adjust for shutter = {shutter_opening_deg}") + LOGGER.info(f"ZEBRA SETUP: start angle start: {start_angle}") + LOGGER.info(f"ZEBRA SETUP: start angle adjusted, gate start set to: {start_angle}") + yield from bps.abs_set(zebra.pc.gate_start, start_angle, group=group) + # set gate width to total width + yield from bps.abs_set( + zebra.pc.gate_width, scan_width + shutter_opening_deg, group=group + ) + LOGGER.info( + f"Pulse start set to shutter open time, set to: {abs(shutter_opening_s)}" + ) + yield from bps.abs_set(zebra.pc.pulse_start, abs(shutter_opening_s), group=group) + # Set gate position to be angle of interest + yield from bps.abs_set(zebra.pc.gate_trigger, axis.value, group=group) + # Set shutter to automatic and to trigger via PC_GATE + yield from configure_zebra_and_shutter_for_auto_shutter( + zebra, zebra_shutter, zebra.mapping.sources.PC_GATE, group=group + ) + # Trigger the detector with a pulse + yield from bps.abs_set( + zebra.output.out_pvs[zebra.mapping.outputs.TTL_DETECTOR], + zebra.mapping.sources.PC_PULSE, + group=group, + ) + # Don't use the fluorescence detector + yield from bps.abs_set( + zebra.output.out_pvs[zebra.mapping.outputs.TTL_XSPRESS3], + zebra.mapping.sources.DISCONNECT, + group=group, + ) + yield from bps.abs_set( + zebra.output.pulse_1.input, zebra.mapping.sources.DISCONNECT, group=group + ) + LOGGER.info(f"ZEBRA SETUP: END - {'' if wait else 'not'} waiting for completion") + if wait: + yield from bps.wait(group, timeout=ZEBRA_STATUS_TIMEOUT) diff --git a/src/mx_bluesky/common/external_interaction/config_server.py b/src/mx_bluesky/common/external_interaction/config_server.py index 58d1b55c89..9d0c169bf3 100644 --- a/src/mx_bluesky/common/external_interaction/config_server.py +++ b/src/mx_bluesky/common/external_interaction/config_server.py @@ -10,7 +10,7 @@ from mx_bluesky.common.parameters.constants import ( GDA_DOMAIN_PROPERTIES_PATH, FeatureSetting, - FeatureSettingources, + FeatureSettingSources, OavConstants, ) from mx_bluesky.common.utils.log import LOGGER @@ -25,7 +25,7 @@ class MXConfigClient(ConfigServer, Generic[T]): def __init__( self, - feature_sources: type[FeatureSettingources], + feature_sources: type[FeatureSettingSources], feature_dc: type[T], url: str = "https://daq-config.diamond.ac.uk", ): diff --git a/src/mx_bluesky/common/parameters/constants.py b/src/mx_bluesky/common/parameters/constants.py index dcfcfb13c5..42986b7623 100644 --- a/src/mx_bluesky/common/parameters/constants.py +++ b/src/mx_bluesky/common/parameters/constants.py @@ -107,6 +107,8 @@ class GridscanParamConstants: @dataclass(frozen=True) class RotationParamConstants: DEFAULT_APERTURE_POSITION = ApertureValue.LARGE + DEFAULT_SHUTTER_TIME_S = 0.06 + OMEGA_FLIP = True # See https://github.com/DiamondLightSource/mx-bluesky/issues/1223 to make beamline-specific @dataclass(frozen=True) @@ -165,6 +167,6 @@ class Status(Enum): class FeatureSetting: ... # List of features and their default values. Subclasses must also be a pydantic dataclass -class FeatureSettingources( +class FeatureSettingSources( StrEnum ): ... # List of features and the name of that property in domain.properties diff --git a/src/mx_bluesky/common/parameters/device_composites.py b/src/mx_bluesky/common/parameters/device_composites.py index ff964498f1..1d8e2379f0 100644 --- a/src/mx_bluesky/common/parameters/device_composites.py +++ b/src/mx_bluesky/common/parameters/device_composites.py @@ -23,6 +23,8 @@ from dodal.devices.zebra.zebra import Zebra from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter from dodal.devices.zocalo import ZocaloResults +from ophyd_async.core import StandardReadable +from ophyd_async.epics.motor import Motor @pydantic.dataclasses.dataclass(config={"arbitrary_types_allowed": True}) @@ -63,3 +65,10 @@ class GridDetectThenXRayCentreComposite(FlyScanEssentialDevices): zebra: Zebra robot: BartRobot sample_shutter: ZebraShutter + + +class GonioWithXYZOmega(StandardReadable): + omega: Motor + x: Motor + y: Motor + z: Motor diff --git a/src/mx_bluesky/hyperion/parameters/rotation.py b/src/mx_bluesky/common/parameters/rotation.py similarity index 94% rename from src/mx_bluesky/hyperion/parameters/rotation.py rename to src/mx_bluesky/common/parameters/rotation.py index 5d79f0f1ca..73d1a0c94f 100644 --- a/src/mx_bluesky/hyperion/parameters/rotation.py +++ b/src/mx_bluesky/common/parameters/rotation.py @@ -28,9 +28,9 @@ WithSample, WithScan, ) -from mx_bluesky.hyperion.parameters.constants import ( - CONST, - I03Constants, +from mx_bluesky.common.parameters.constants import ( + DetectorParamConstants, + RotationParamConstants, ) @@ -56,7 +56,9 @@ class RotationScanPerSweep(OptionalGonioAngleStarts, OptionalXyzStarts, WithSamp class RotationExperiment(DiffractionExperiment): - shutter_opening_time_s: float = Field(default=CONST.I03.SHUTTER_TIME_S) + shutter_opening_time_s: float = Field( + default=RotationParamConstants.DEFAULT_SHUTTER_TIME_S + ) rotation_increment_deg: float = Field(default=0.1, gt=0) ispyb_experiment_type: IspybExperimentType = Field( default=IspybExperimentType.ROTATION @@ -67,7 +69,7 @@ def _detector_params_impl( ) -> DetectorParams: self.det_dist_to_beam_converter_path = ( self.det_dist_to_beam_converter_path - or CONST.PARAM.DETECTOR.BEAM_XY_LUT_PATH + or DetectorParamConstants.BEAM_XY_LUT_PATH ) optional_args = {} if self.run_number: @@ -75,7 +77,7 @@ def _detector_params_impl( assert self.detector_distance_mm is not None os.makedirs(self.storage_directory, exist_ok=True) return DetectorParams( - detector_size_constants=I03Constants.DETECTOR, + detector_size_constants=DetectorParamConstants.DETECTOR, expected_energy_ev=self.demand_energy_ev, exposure_time_s=self.exposure_time_s, directory=self.storage_directory, @@ -97,7 +99,7 @@ def _detector_params(self, omega_start_deg: float) -> DetectorParams: @classmethod def _set_default_aperture_position(cls, aperture_position: ApertureValue | None): if not aperture_position: - default_aperture = CONST.PARAM.ROTATION.DEFAULT_APERTURE_POSITION + default_aperture = RotationParamConstants.DEFAULT_APERTURE_POSITION LOGGER.warning( f"No aperture position selected. Defaulting to {default_aperture}" ) diff --git a/src/mx_bluesky/hyperion/device_setup_plans/setup_zebra.py b/src/mx_bluesky/hyperion/device_setup_plans/setup_zebra.py index e2efd12a01..0fbbd12dbd 100644 --- a/src/mx_bluesky/hyperion/device_setup_plans/setup_zebra.py +++ b/src/mx_bluesky/hyperion/device_setup_plans/setup_zebra.py @@ -1,120 +1,17 @@ import bluesky.plan_stubs as bps from dodal.devices.zebra.zebra import ( - ArmDemand, - EncEnum, - I03Axes, - RotationDirection, Zebra, ) from dodal.devices.zebra.zebra_controlled_shutter import ( ZebraShutter, - ZebraShutterControl, ) from mx_bluesky.common.parameters.constants import ZEBRA_STATUS_TIMEOUT -from mx_bluesky.common.utils.log import LOGGER from mx_bluesky.phase1_zebra.device_setup_plans.setup_zebra import ( configure_zebra_and_shutter_for_auto_shutter, ) -def arm_zebra(zebra: Zebra): - yield from bps.abs_set(zebra.pc.arm, ArmDemand.ARM, wait=True) - - -def tidy_up_zebra_after_rotation_scan( - zebra: Zebra, - zebra_shutter: ZebraShutter, - group="tidy_up_zebra_after_rotation", - wait=True, -): - yield from bps.abs_set(zebra.pc.arm, ArmDemand.DISARM, group=group) - yield from bps.abs_set( - zebra_shutter.control_mode, ZebraShutterControl.MANUAL, group=group - ) - if wait: - yield from bps.wait(group, timeout=ZEBRA_STATUS_TIMEOUT) - - -def setup_zebra_for_rotation( - zebra: Zebra, - zebra_shutter: ZebraShutter, - axis: EncEnum = I03Axes.OMEGA, - start_angle: float = 0, - scan_width: float = 360, - shutter_opening_deg: float = 2.5, - shutter_opening_s: float = 0.04, - direction: RotationDirection = RotationDirection.POSITIVE, - group: str = "setup_zebra_for_rotation", - wait: bool = True, -): - """Set up the Zebra to collect a rotation dataset. Any plan using this is - responsible for setting the smargon velocity appropriately so that the desired - image width is achieved with the exposure time given here. - - Parameters: - zebra: The zebra device to use - axis: I03 axes enum representing which axis to use for position - compare. Currently always omega. - start_angle: Position at which the scan should begin, in degrees. - scan_width: Total angle through which to collect, in degrees. - shutter_opening_deg:How many degrees of rotation it takes for the fast shutter - to open. Increases the gate width. - shutter_opening_s: How many seconds it takes for the fast shutter to open. The - detector pulse is delayed after the shutter signal by this - amount. - direction: RotationDirection enum for positive or negative. - Defaults to Positive. - group: A name for the group of statuses generated - wait: Block until all the settings have completed - """ - - if not isinstance(direction, RotationDirection): - raise ValueError( - "Disallowed rotation direction provided to Zebra setup plan. " - "Use RotationDirection.POSITIVE or RotationDirection.NEGATIVE." - ) - yield from bps.abs_set(zebra.pc.dir, direction.value, group=group) - LOGGER.info("ZEBRA SETUP: START") - # Set gate start, adjust for shutter opening time if necessary - LOGGER.info(f"ZEBRA SETUP: degrees to adjust for shutter = {shutter_opening_deg}") - LOGGER.info(f"ZEBRA SETUP: start angle start: {start_angle}") - LOGGER.info(f"ZEBRA SETUP: start angle adjusted, gate start set to: {start_angle}") - yield from bps.abs_set(zebra.pc.gate_start, start_angle, group=group) - # set gate width to total width - yield from bps.abs_set( - zebra.pc.gate_width, scan_width + shutter_opening_deg, group=group - ) - LOGGER.info( - f"Pulse start set to shutter open time, set to: {abs(shutter_opening_s)}" - ) - yield from bps.abs_set(zebra.pc.pulse_start, abs(shutter_opening_s), group=group) - # Set gate position to be angle of interest - yield from bps.abs_set(zebra.pc.gate_trigger, axis.value, group=group) - # Set shutter to automatic and to trigger via PC_GATE - yield from configure_zebra_and_shutter_for_auto_shutter( - zebra, zebra_shutter, zebra.mapping.sources.PC_GATE, group=group - ) - # Trigger the detector with a pulse - yield from bps.abs_set( - zebra.output.out_pvs[zebra.mapping.outputs.TTL_DETECTOR], - zebra.mapping.sources.PC_PULSE, - group=group, - ) - # Don't use the fluorescence detector - yield from bps.abs_set( - zebra.output.out_pvs[zebra.mapping.outputs.TTL_XSPRESS3], - zebra.mapping.sources.DISCONNECT, - group=group, - ) - yield from bps.abs_set( - zebra.output.pulse_1.input, zebra.mapping.sources.DISCONNECT, group=group - ) - LOGGER.info(f"ZEBRA SETUP: END - {'' if wait else 'not'} waiting for completion") - if wait: - yield from bps.wait(group, timeout=ZEBRA_STATUS_TIMEOUT) - - def setup_zebra_for_panda_flyscan( zebra: Zebra, zebra_shutter: ZebraShutter, diff --git a/src/mx_bluesky/hyperion/experiment_plans/experiment_registry.py b/src/mx_bluesky/hyperion/experiment_plans/experiment_registry.py index 5fe640eb33..e1fc2f8f5a 100644 --- a/src/mx_bluesky/hyperion/experiment_plans/experiment_registry.py +++ b/src/mx_bluesky/hyperion/experiment_plans/experiment_registry.py @@ -4,6 +4,9 @@ from typing import TypedDict import mx_bluesky.hyperion.experiment_plans.rotation_scan_plan as rotation_scan_plan +from mx_bluesky.common.parameters.rotation import ( + RotationScan, +) from mx_bluesky.hyperion.experiment_plans import ( hyperion_grid_detect_then_xray_centre_plan, load_centre_collect_full_plan, @@ -15,7 +18,6 @@ PinTipCentreThenXrayCentre, ) from mx_bluesky.hyperion.parameters.load_centre_collect import LoadCentreCollect -from mx_bluesky.hyperion.parameters.rotation import RotationScan def not_implemented(): diff --git a/src/mx_bluesky/hyperion/experiment_plans/load_centre_collect_full_plan.py b/src/mx_bluesky/hyperion/experiment_plans/load_centre_collect_full_plan.py index e69c424ed2..2b7bd1d3be 100644 --- a/src/mx_bluesky/hyperion/experiment_plans/load_centre_collect_full_plan.py +++ b/src/mx_bluesky/hyperion/experiment_plans/load_centre_collect_full_plan.py @@ -12,6 +12,9 @@ import mx_bluesky.common.xrc_result as flyscan_result from mx_bluesky.common.parameters.components import WithSnapshot +from mx_bluesky.common.parameters.rotation import ( + RotationScanPerSweep, +) from mx_bluesky.common.utils.context import device_composite_from_context from mx_bluesky.common.utils.log import LOGGER from mx_bluesky.common.xrc_result import XRayCentreEventHandler @@ -29,7 +32,6 @@ ) from mx_bluesky.hyperion.parameters.constants import CONST, I03Constants from mx_bluesky.hyperion.parameters.load_centre_collect import LoadCentreCollect -from mx_bluesky.hyperion.parameters.rotation import RotationScanPerSweep @pydantic.dataclasses.dataclass(config={"arbitrary_types_allowed": True}) diff --git a/src/mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py b/src/mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py index 1402973a69..28a24a6346 100644 --- a/src/mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py +++ b/src/mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py @@ -1,7 +1,5 @@ from __future__ import annotations -import dataclasses - import bluesky.plan_stubs as bps import bluesky.preprocessors as bpp import pydantic @@ -23,7 +21,7 @@ from dodal.devices.synchrotron import Synchrotron from dodal.devices.undulator import Undulator from dodal.devices.xbpm_feedback import XBPMFeedback -from dodal.devices.zebra.zebra import RotationDirection, Zebra +from dodal.devices.zebra.zebra import Zebra from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter from dodal.plan_stubs.check_topup import check_topup_and_wait_if_necessary from dodal.plans.preprocessors.verify_undulator_gap import ( @@ -47,22 +45,26 @@ oav_snapshot_plan, setup_beamline_for_OAV, ) -from mx_bluesky.common.parameters.components import WithSnapshot -from mx_bluesky.common.preprocessors.preprocessors import ( - transmission_and_xbpm_feedback_for_collection_decorator, +from mx_bluesky.common.experiment_plans.rotation.rotation_utils import ( + RotationMotionProfile, + calculate_motion_profile, ) -from mx_bluesky.common.utils.context import device_composite_from_context -from mx_bluesky.common.utils.log import LOGGER -from mx_bluesky.hyperion.device_setup_plans.setup_zebra import ( +from mx_bluesky.common.experiment_plans.setup_zebra import ( arm_zebra, setup_zebra_for_rotation, tidy_up_zebra_after_rotation_scan, ) -from mx_bluesky.hyperion.parameters.constants import CONST, I03Constants -from mx_bluesky.hyperion.parameters.rotation import ( +from mx_bluesky.common.parameters.components import WithSnapshot +from mx_bluesky.common.parameters.rotation import ( RotationScan, SingleRotationScan, ) +from mx_bluesky.common.preprocessors.preprocessors import ( + transmission_and_xbpm_feedback_for_collection_decorator, +) +from mx_bluesky.common.utils.context import device_composite_from_context +from mx_bluesky.common.utils.log import LOGGER +from mx_bluesky.hyperion.parameters.constants import CONST @pydantic.dataclasses.dataclass(config={"arbitrary_types_allowed": True}) @@ -94,120 +96,6 @@ def create_devices(context: BlueskyContext) -> RotationScanComposite: return device_composite_from_context(context, RotationScanComposite) -DEFAULT_DIRECTION = RotationDirection.NEGATIVE -DEFAULT_MAX_VELOCITY = 120 -# Use a slightly larger time to acceleration than EPICS as it's better to be cautious -ACCELERATION_MARGIN = 1.5 - - -@dataclasses.dataclass -class RotationMotionProfile: - start_scan_deg: float - start_motion_deg: float - scan_width_deg: float - shutter_time_s: float - direction: RotationDirection - speed_for_rotation_deg_s: float - acceleration_offset_deg: float - shutter_opening_deg: float - total_exposure_s: float - distance_to_move_deg: float - max_velocity_deg_s: float - - -def calculate_motion_profile( - params: SingleRotationScan, - motor_time_to_speed_s: float, - max_velocity_deg_s: float, -) -> RotationMotionProfile: - """Calculates the various numbers needed for motions in the rotation scan. - Rotates through "scan width" plus twice an "offset" to take into account - acceleration at the start and deceleration at the end, plus the number of extra - degrees of rotation needed to make sure the fast shutter has fully opened before the - detector trigger is sent. - See https://github.com/DiamondLightSource/hyperion/wiki/rotation-scan-geometry - for a simple pictorial explanation.""" - - assert params.rotation_increment_deg > 0 - - direction = params.rotation_direction - start_scan_deg = params.omega_start_deg - - if I03Constants.OMEGA_FLIP: - # If omega_flip is True then the motor omega axis is inverted with respect to the - # hyperion coordinate system. - start_scan_deg = -start_scan_deg - direction = ( - direction.POSITIVE - if direction == direction.NEGATIVE - else direction.NEGATIVE - ) - - num_images = params.num_images - shutter_time_s = params.shutter_opening_time_s - image_width_deg = params.rotation_increment_deg - exposure_time_s = params.exposure_time_s - motor_time_to_speed_s *= ACCELERATION_MARGIN - - LOGGER.info("Calculating rotation scan motion profile:") - LOGGER.info( - f"{num_images=}, {shutter_time_s=}, {image_width_deg=}, {exposure_time_s=}, {direction=}" - ) - - scan_width_deg = num_images * params.rotation_increment_deg - LOGGER.info(f"{scan_width_deg=} = {num_images=} * {params.rotation_increment_deg=}") - - speed_for_rotation_deg_s = image_width_deg / exposure_time_s - LOGGER.info("speed_for_rotation_deg_s = image_width_deg / exposure_time_s") - LOGGER.info( - f"{speed_for_rotation_deg_s=} = {image_width_deg=} / {exposure_time_s=}" - ) - - acceleration_offset_deg = motor_time_to_speed_s * speed_for_rotation_deg_s - LOGGER.info( - f"{acceleration_offset_deg=} = {motor_time_to_speed_s=} * {speed_for_rotation_deg_s=}" - ) - - start_motion_deg = start_scan_deg - (acceleration_offset_deg * direction.multiplier) - LOGGER.info( - f"{start_motion_deg=} = {start_scan_deg=} - ({acceleration_offset_deg=} * {direction.multiplier=})" - ) - - shutter_opening_deg = speed_for_rotation_deg_s * shutter_time_s - LOGGER.info( - f"{shutter_opening_deg=} = {speed_for_rotation_deg_s=} * {shutter_time_s=}" - ) - - shutter_opening_deg = speed_for_rotation_deg_s * shutter_time_s - LOGGER.info( - f"{shutter_opening_deg=} = {speed_for_rotation_deg_s=} * {shutter_time_s=}" - ) - - total_exposure_s = num_images * exposure_time_s - LOGGER.info(f"{total_exposure_s=} = {num_images=} * {exposure_time_s=}") - - distance_to_move_deg = ( - scan_width_deg + shutter_opening_deg + acceleration_offset_deg * 2 - ) * direction.multiplier - LOGGER.info( - f"{distance_to_move_deg=} = ({scan_width_deg=} + {shutter_opening_deg=} + {acceleration_offset_deg=} * 2) * {direction=})" - ) - - return RotationMotionProfile( - start_scan_deg=start_scan_deg, - start_motion_deg=start_motion_deg, - scan_width_deg=scan_width_deg, - shutter_time_s=shutter_time_s, - direction=direction, - speed_for_rotation_deg_s=speed_for_rotation_deg_s, - acceleration_offset_deg=acceleration_offset_deg, - shutter_opening_deg=shutter_opening_deg, - total_exposure_s=total_exposure_s, - distance_to_move_deg=distance_to_move_deg, - max_velocity_deg_s=max_velocity_deg_s, - ) - - def rotation_scan_plan( composite: RotationScanComposite, params: SingleRotationScan, diff --git a/src/mx_bluesky/hyperion/external_interaction/callbacks/rotation/ispyb_callback.py b/src/mx_bluesky/hyperion/external_interaction/callbacks/rotation/ispyb_callback.py index 3c10a2ccc4..98b2cc61a3 100644 --- a/src/mx_bluesky/hyperion/external_interaction/callbacks/rotation/ispyb_callback.py +++ b/src/mx_bluesky/hyperion/external_interaction/callbacks/rotation/ispyb_callback.py @@ -20,12 +20,14 @@ StoreInIspyb, ) from mx_bluesky.common.parameters.components import IspybExperimentType +from mx_bluesky.common.parameters.rotation import ( + SingleRotationScan, +) from mx_bluesky.common.utils.log import ISPYB_ZOCALO_CALLBACK_LOGGER, set_dcgid_tag from mx_bluesky.hyperion.external_interaction.callbacks.rotation.ispyb_mapping import ( populate_data_collection_info_for_rotation, ) from mx_bluesky.hyperion.parameters.constants import CONST -from mx_bluesky.hyperion.parameters.rotation import SingleRotationScan if TYPE_CHECKING: from event_model.documents import Event, RunStart, RunStop diff --git a/src/mx_bluesky/hyperion/external_interaction/callbacks/rotation/ispyb_mapping.py b/src/mx_bluesky/hyperion/external_interaction/callbacks/rotation/ispyb_mapping.py index 429efc6956..d1beeab94c 100644 --- a/src/mx_bluesky/hyperion/external_interaction/callbacks/rotation/ispyb_mapping.py +++ b/src/mx_bluesky/hyperion/external_interaction/callbacks/rotation/ispyb_mapping.py @@ -1,7 +1,9 @@ from __future__ import annotations from mx_bluesky.common.external_interaction.ispyb.data_model import DataCollectionInfo -from mx_bluesky.hyperion.parameters.rotation import SingleRotationScan +from mx_bluesky.common.parameters.rotation import ( + SingleRotationScan, +) def populate_data_collection_info_for_rotation(params: SingleRotationScan): diff --git a/src/mx_bluesky/hyperion/external_interaction/callbacks/rotation/nexus_callback.py b/src/mx_bluesky/hyperion/external_interaction/callbacks/rotation/nexus_callback.py index 869f221890..a7f1c39de0 100644 --- a/src/mx_bluesky/hyperion/external_interaction/callbacks/rotation/nexus_callback.py +++ b/src/mx_bluesky/hyperion/external_interaction/callbacks/rotation/nexus_callback.py @@ -14,9 +14,12 @@ vds_type_based_on_bit_depth, ) from mx_bluesky.common.external_interaction.nexus.write_nexus import NexusWriter +from mx_bluesky.common.parameters.constants import RotationParamConstants +from mx_bluesky.common.parameters.rotation import ( + SingleRotationScan, +) from mx_bluesky.common.utils.log import NEXUS_LOGGER -from mx_bluesky.hyperion.parameters.constants import CONST, I03Constants -from mx_bluesky.hyperion.parameters.rotation import SingleRotationScan +from mx_bluesky.hyperion.parameters.constants import CONST if TYPE_CHECKING: from event_model.documents import Event, EventDescriptor, RunStart @@ -103,6 +106,6 @@ def activity_gated_start(self, doc: RunStart): full_num_of_images=self.full_num_of_images, meta_data_run_number=self.meta_data_run_number, axis_direction=AxisDirection.NEGATIVE - if I03Constants.OMEGA_FLIP + if RotationParamConstants.OMEGA_FLIP else AxisDirection.POSITIVE, ) diff --git a/src/mx_bluesky/hyperion/external_interaction/config_server.py b/src/mx_bluesky/hyperion/external_interaction/config_server.py index 89525bfdb9..3187ee79c0 100644 --- a/src/mx_bluesky/hyperion/external_interaction/config_server.py +++ b/src/mx_bluesky/hyperion/external_interaction/config_server.py @@ -3,14 +3,14 @@ from mx_bluesky.common.external_interaction.config_server import MXConfigClient from mx_bluesky.hyperion.parameters.constants import ( HyperionFeatureSetting, - HyperionFeatureSettingources, + HyperionFeatureSettingSources, ) @cache def get_hyperion_config_client() -> MXConfigClient[HyperionFeatureSetting]: return MXConfigClient( - feature_sources=HyperionFeatureSettingources, + feature_sources=HyperionFeatureSettingSources, feature_dc=HyperionFeatureSetting, url="https://daq-config.diamond.ac.uk", ) diff --git a/src/mx_bluesky/hyperion/parameters/constants.py b/src/mx_bluesky/hyperion/parameters/constants.py index 6052d05a26..ee3acc6228 100644 --- a/src/mx_bluesky/hyperion/parameters/constants.py +++ b/src/mx_bluesky/hyperion/parameters/constants.py @@ -4,12 +4,10 @@ from pydantic.dataclasses import dataclass from mx_bluesky.common.parameters.constants import ( - DeviceSettingsConstants, DocDescriptorNames, EnvironmentConstants, - ExperimentParamConstants, FeatureSetting, - FeatureSettingources, + FeatureSettingSources, HardwareConstants, OavConstants, PlanGroupCheckpointConstants, @@ -27,12 +25,11 @@ class I03Constants: OAV_CENTRING_FILE = OavConstants.OAV_CONFIG_JSON SHUTTER_TIME_S = 0.06 USE_GPU_RESULTS = True - OMEGA_FLIP = True ALTERNATE_ROTATION_DIRECTION = True # These currently exist in GDA domain.properties -class HyperionFeatureSettingources(FeatureSettingources): +class HyperionFeatureSettingSources(FeatureSettingSources): USE_GPU_RESULTS = "gda.mx.hyperion.xrc.use_gpu_results" USE_PANDA_FOR_GRIDSCAN = "gda.mx.hyperion.use_panda_for_gridscans" SET_STUB_OFFSETS = "gda.mx.hyperion.do_stub_offsets" @@ -52,22 +49,14 @@ class HyperionFeatureSetting(FeatureSetting): class HyperionConstants: ZOCALO_ENV = EnvironmentConstants.ZOCALO_ENV HARDWARE = HardwareConstants() - I03 = I03Constants() - PARAM = ExperimentParamConstants() PLAN = PlanNameConstants() WAIT = PlanGroupCheckpointConstants() CALLBACK_0MQ_PROXY_PORTS = (5577, 5578) DESCRIPTORS = DocDescriptorNames() - CONFIG_SERVER_URL = ( - "http://fake-url-not-real" - if TEST_MODE - else "https://daq-config.diamond.ac.uk/api" - ) GRAYLOG_PORT = 12232 # Hyperion stream GRAYLOG_STREAM_ID = "66264f5519ccca6d1c9e4e03" PARAMETER_SCHEMA_DIRECTORY = "src/hyperion/parameters/schemas/" LOG_FILE_NAME = "hyperion.log" - DEVICE_SETTINGS_CONSTANTS = DeviceSettingsConstants() CONST = HyperionConstants() diff --git a/src/mx_bluesky/hyperion/parameters/load_centre_collect.py b/src/mx_bluesky/hyperion/parameters/load_centre_collect.py index 77c59f98e3..e1f2768c54 100644 --- a/src/mx_bluesky/hyperion/parameters/load_centre_collect.py +++ b/src/mx_bluesky/hyperion/parameters/load_centre_collect.py @@ -8,10 +8,12 @@ WithSample, WithVisit, ) +from mx_bluesky.common.parameters.rotation import ( + RotationScan, +) from mx_bluesky.hyperion.parameters.robot_load import ( RobotLoadThenCentre, ) -from mx_bluesky.hyperion.parameters.rotation import RotationScan T = TypeVar("T", bound=BaseModel) diff --git a/tests/conftest.py b/tests/conftest.py index a5d6be02d8..38f8e14779 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -85,6 +85,9 @@ PlanNameConstants, ) from mx_bluesky.common.parameters.gridscan import SpecifiedThreeDGridScan +from mx_bluesky.common.parameters.rotation import ( + RotationScan, +) from mx_bluesky.common.utils.exceptions import CrystalNotFoundException from mx_bluesky.common.utils.log import ( ALL_LOGGERS, @@ -104,7 +107,6 @@ HyperionFlyScanXRayCentreComposite, ) from mx_bluesky.hyperion.parameters.gridscan import HyperionSpecifiedThreeDGridScan -from mx_bluesky.hyperion.parameters.rotation import RotationScan i03.DAQ_CONFIGURATION_PATH = "tests/test_data/test_daq_configuration" diff --git a/tests/system_tests/hyperion/external_interaction/callbacks/test_external_callbacks.py b/tests/system_tests/hyperion/external_interaction/callbacks/test_external_callbacks.py index cb28d2627d..de3b21770f 100644 --- a/tests/system_tests/hyperion/external_interaction/callbacks/test_external_callbacks.py +++ b/tests/system_tests/hyperion/external_interaction/callbacks/test_external_callbacks.py @@ -23,6 +23,9 @@ from mx_bluesky.common.external_interaction.callbacks.xray_centre.ispyb_callback import ( ispyb_activation_decorator, ) +from mx_bluesky.common.parameters.rotation import ( + RotationScan, +) from mx_bluesky.common.utils.log import LOGGER from mx_bluesky.common.utils.utils import convert_angstrom_to_eV from mx_bluesky.hyperion.experiment_plans.hyperion_flyscan_xray_centre_plan import ( @@ -36,7 +39,6 @@ HyperionFlyScanXRayCentreComposite, ) from mx_bluesky.hyperion.parameters.gridscan import HyperionSpecifiedThreeDGridScan -from mx_bluesky.hyperion.parameters.rotation import RotationScan from .....conftest import fake_read from ..conftest import ( # noqa diff --git a/tests/system_tests/hyperion/external_interaction/conftest.py b/tests/system_tests/hyperion/external_interaction/conftest.py index e656c7b830..37d17da731 100644 --- a/tests/system_tests/hyperion/external_interaction/conftest.py +++ b/tests/system_tests/hyperion/external_interaction/conftest.py @@ -44,6 +44,9 @@ from workflows.recipe import RecipeWrapper from mx_bluesky.common.external_interaction.ispyb.ispyb_store import StoreInIspyb +from mx_bluesky.common.parameters.rotation import ( + RotationScan, +) from mx_bluesky.common.utils.utils import convert_angstrom_to_eV from mx_bluesky.hyperion.experiment_plans.rotation_scan_plan import ( RotationScanComposite, @@ -53,7 +56,6 @@ HyperionGridDetectThenXRayCentreComposite, ) from mx_bluesky.hyperion.parameters.gridscan import HyperionSpecifiedThreeDGridScan -from mx_bluesky.hyperion.parameters.rotation import RotationScan from ....conftest import ( TEST_RESULT_MEDIUM, diff --git a/tests/system_tests/hyperion/external_interaction/test_ispyb_dev_connection.py b/tests/system_tests/hyperion/external_interaction/test_ispyb_dev_connection.py index a4da02ae61..ec4ba0882a 100644 --- a/tests/system_tests/hyperion/external_interaction/test_ispyb_dev_connection.py +++ b/tests/system_tests/hyperion/external_interaction/test_ispyb_dev_connection.py @@ -35,6 +35,9 @@ StoreInIspyb, ) from mx_bluesky.common.parameters.components import IspybExperimentType +from mx_bluesky.common.parameters.rotation import ( + RotationScan, +) from mx_bluesky.hyperion.experiment_plans.hyperion_flyscan_xray_centre_plan import ( construct_hyperion_specific_features, ) @@ -56,7 +59,6 @@ GridScanWithEdgeDetect, HyperionSpecifiedThreeDGridScan, ) -from mx_bluesky.hyperion.parameters.rotation import RotationScan from ....conftest import SimConstants, replace_all_tmp_paths from ...conftest import ( diff --git a/tests/system_tests/hyperion/external_interaction/test_nexgen.py b/tests/system_tests/hyperion/external_interaction/test_nexgen.py index 420d66ee34..5adcd069db 100644 --- a/tests/system_tests/hyperion/external_interaction/test_nexgen.py +++ b/tests/system_tests/hyperion/external_interaction/test_nexgen.py @@ -12,6 +12,9 @@ from mx_bluesky.common.experiment_plans.inner_plans.read_hardware import ( standard_read_hardware_during_collection, ) +from mx_bluesky.common.parameters.rotation import ( + SingleRotationScan, +) from mx_bluesky.hyperion.experiment_plans.rotation_scan_plan import ( RotationScanComposite, ) @@ -19,7 +22,6 @@ RotationNexusFileCallback, ) from mx_bluesky.hyperion.parameters.constants import CONST -from mx_bluesky.hyperion.parameters.rotation import SingleRotationScan from ....conftest import extract_metafile, raw_params_from_file diff --git a/tests/unit_tests/common/external_interaction/callbacks/common/test_snapshot_callback.py b/tests/unit_tests/common/external_interaction/callbacks/common/test_snapshot_callback.py index bb2ae2ac3e..0796060931 100644 --- a/tests/unit_tests/common/external_interaction/callbacks/common/test_snapshot_callback.py +++ b/tests/unit_tests/common/external_interaction/callbacks/common/test_snapshot_callback.py @@ -18,11 +18,13 @@ from mx_bluesky.common.parameters.components import WithSnapshot from mx_bluesky.common.parameters.constants import DocDescriptorNames +from mx_bluesky.common.parameters.rotation import ( + SingleRotationScan, +) from mx_bluesky.hyperion.external_interaction.callbacks.snapshot_callback import ( BeamDrawingCallback, ) from mx_bluesky.hyperion.parameters.constants import CONST -from mx_bluesky.hyperion.parameters.rotation import SingleRotationScan from ......conftest import assert_images_pixelwise_equal, raw_params_from_file diff --git a/tests/unit_tests/common/external_interaction/test_config_server.py b/tests/unit_tests/common/external_interaction/test_config_server.py index 8325296db1..2886403cb3 100644 --- a/tests/unit_tests/common/external_interaction/test_config_server.py +++ b/tests/unit_tests/common/external_interaction/test_config_server.py @@ -8,7 +8,7 @@ from mx_bluesky.common.parameters.constants import ( GDA_DOMAIN_PROPERTIES_PATH, FeatureSetting, - FeatureSettingources, + FeatureSettingSources, OavConstants, ) from mx_bluesky.hyperion.external_interaction.config_server import ( @@ -18,14 +18,14 @@ def test_verify_feature_parameters(): - class BadHyperionFeatureSettingources(FeatureSettingources): + class BadHyperionFeatureSettingSources(FeatureSettingSources): USE_GPU_RESULTS = "gda.mx.hyperion.xrc.use_gpu_results" USE_ZEBRA_FOR_GRIDSCAN = "gda.mx.hyperion.use_panda_for_gridscans" SET_STUB_OFFSETS = "gda.mx.hyperion.do_stub_offsets" with pytest.raises(AssertionError): MXConfigClient( - feature_sources=BadHyperionFeatureSettingources, + feature_sources=BadHyperionFeatureSettingSources, feature_dc=HyperionFeatureSetting, ) @@ -123,7 +123,7 @@ def test_refresh_cache(): server.get_file_contents.assert_has_calls(call_list, any_order=True) -class BadFeatureSettingSources(FeatureSettingources): +class BadFeatureSettingSources(FeatureSettingSources): USE_GPU_RESULTS = "gda.mx.hyperion.xrc.use_gpu_results" USE_PANDA_FOR_GRIDSCAN = "gda.mx.hyperion.use_panda_for_gridscans" SET_STUB_OFFSETS = "gda.mx.hyperion.do_stub_offsets" diff --git a/tests/unit_tests/hyperion/conftest.py b/tests/unit_tests/hyperion/conftest.py index e7d7556500..7f01cea16f 100644 --- a/tests/unit_tests/hyperion/conftest.py +++ b/tests/unit_tests/hyperion/conftest.py @@ -8,12 +8,14 @@ from mx_bluesky.common.external_interaction.ispyb.data_model import ( DataCollectionGroupInfo, ) +from mx_bluesky.common.parameters.rotation import ( + RotationScan, +) from mx_bluesky.hyperion.parameters.gridscan import ( GridScanWithEdgeDetect, HyperionSpecifiedThreeDGridScan, ) from mx_bluesky.hyperion.parameters.load_centre_collect import LoadCentreCollect -from mx_bluesky.hyperion.parameters.rotation import RotationScan from tests.conftest import ( raw_params_from_file, ) diff --git a/tests/unit_tests/hyperion/device_setup_plans/test_zebra_setup.py b/tests/unit_tests/hyperion/device_setup_plans/test_zebra_setup.py index 2a9a2ad9f6..3345986c99 100644 --- a/tests/unit_tests/hyperion/device_setup_plans/test_zebra_setup.py +++ b/tests/unit_tests/hyperion/device_setup_plans/test_zebra_setup.py @@ -10,9 +10,9 @@ ZebraShutterControl, ) +from mx_bluesky.common.experiment_plans.setup_zebra import setup_zebra_for_rotation from mx_bluesky.hyperion.device_setup_plans.setup_zebra import ( setup_zebra_for_panda_flyscan, - setup_zebra_for_rotation, ) from mx_bluesky.phase1_zebra.device_setup_plans.setup_zebra import ( configure_zebra_and_shutter_for_auto_shutter, diff --git a/tests/unit_tests/hyperion/experiment_plans/test_load_centre_collect_full_plan.py b/tests/unit_tests/hyperion/experiment_plans/test_load_centre_collect_full_plan.py index 5b3d4722e0..55d5443438 100644 --- a/tests/unit_tests/hyperion/experiment_plans/test_load_centre_collect_full_plan.py +++ b/tests/unit_tests/hyperion/experiment_plans/test_load_centre_collect_full_plan.py @@ -18,6 +18,10 @@ from pydantic import ValidationError from mx_bluesky.common.parameters.components import TopNByMaxCountForEachSampleSelection +from mx_bluesky.common.parameters.rotation import ( + RotationScan, + RotationScanPerSweep, +) from mx_bluesky.common.utils.exceptions import ( CrystalNotFoundException, WarningException, @@ -35,10 +39,6 @@ from mx_bluesky.hyperion.parameters.constants import CONST from mx_bluesky.hyperion.parameters.load_centre_collect import LoadCentreCollect from mx_bluesky.hyperion.parameters.robot_load import RobotLoadAndEnergyChange -from mx_bluesky.hyperion.parameters.rotation import ( - RotationScan, - RotationScanPerSweep, -) from ....conftest import pin_tip_edge_data, raw_params_from_file from .conftest import ( diff --git a/tests/unit_tests/hyperion/experiment_plans/test_rotation_scan_plan.py b/tests/unit_tests/hyperion/experiment_plans/test_rotation_scan_plan.py index c63fe431b7..9c8bd11bf2 100644 --- a/tests/unit_tests/hyperion/experiment_plans/test_rotation_scan_plan.py +++ b/tests/unit_tests/hyperion/experiment_plans/test_rotation_scan_plan.py @@ -39,6 +39,10 @@ ) from mx_bluesky.common.external_interaction.nexus.nexus_utils import AxisDirection from mx_bluesky.common.parameters.constants import DocDescriptorNames +from mx_bluesky.common.parameters.rotation import ( + RotationScan, + SingleRotationScan, +) from mx_bluesky.common.utils.exceptions import ISPyBDepositionNotMade from mx_bluesky.hyperion.experiment_plans.rotation_scan_plan import ( RotationMotionProfile, @@ -57,7 +61,6 @@ RotationNexusFileCallback, ) from mx_bluesky.hyperion.parameters.constants import CONST -from mx_bluesky.hyperion.parameters.rotation import RotationScan, SingleRotationScan from ....conftest import ( DocumentCapturer, @@ -876,9 +879,7 @@ def test_rotation_scan_moves_beamstop_into_place( "mx_bluesky.common.external_interaction.callbacks.common.zocalo_callback.ZocaloTrigger", MagicMock(), ) -@patch( - "mx_bluesky.hyperion.experiment_plans.rotation_scan_plan.setup_zebra_for_rotation" -) +@patch("mx_bluesky.common.experiment_plans.setup_zebra.setup_zebra_for_rotation") def test_rotation_scan_plan_with_omega_flip_inverts_motor_movements_but_not_event_params( mock_setup_zebra_for_rotation: MagicMock, omega_flip: bool, diff --git a/tests/unit_tests/hyperion/external_interaction/callbacks/conftest.py b/tests/unit_tests/hyperion/external_interaction/callbacks/conftest.py index d1e076e5ae..319f801352 100644 --- a/tests/unit_tests/hyperion/external_interaction/callbacks/conftest.py +++ b/tests/unit_tests/hyperion/external_interaction/callbacks/conftest.py @@ -1,7 +1,9 @@ import pytest +from mx_bluesky.common.parameters.rotation import ( + RotationScan, +) from mx_bluesky.hyperion.parameters.constants import CONST -from mx_bluesky.hyperion.parameters.rotation import RotationScan @pytest.fixture diff --git a/tests/unit_tests/hyperion/external_interaction/callbacks/test_rotation_callbacks.py b/tests/unit_tests/hyperion/external_interaction/callbacks/test_rotation_callbacks.py index 5eb12f8d67..2283fed38c 100644 --- a/tests/unit_tests/hyperion/external_interaction/callbacks/test_rotation_callbacks.py +++ b/tests/unit_tests/hyperion/external_interaction/callbacks/test_rotation_callbacks.py @@ -12,6 +12,9 @@ StoreInIspyb, ) from mx_bluesky.common.parameters.components import IspybExperimentType +from mx_bluesky.common.parameters.rotation import ( + RotationScan, +) from mx_bluesky.hyperion.experiment_plans.rotation_scan_plan import rotation_scan from mx_bluesky.hyperion.external_interaction.callbacks.__main__ import ( create_rotation_callbacks, @@ -23,7 +26,6 @@ RotationNexusFileCallback, ) from mx_bluesky.hyperion.parameters.constants import CONST -from mx_bluesky.hyperion.parameters.rotation import RotationScan from .....conftest import raw_params_from_file diff --git a/tests/unit_tests/hyperion/external_interaction/conftest.py b/tests/unit_tests/hyperion/external_interaction/conftest.py index 7d077609c0..18b065de40 100644 --- a/tests/unit_tests/hyperion/external_interaction/conftest.py +++ b/tests/unit_tests/hyperion/external_interaction/conftest.py @@ -2,9 +2,11 @@ import pytest +from mx_bluesky.common.parameters.rotation import ( + SingleRotationScan, +) from mx_bluesky.common.utils.utils import convert_angstrom_to_eV from mx_bluesky.hyperion.parameters.gridscan import HyperionSpecifiedThreeDGridScan -from mx_bluesky.hyperion.parameters.rotation import SingleRotationScan from ....conftest import ( default_raw_gridscan_params, diff --git a/tests/unit_tests/hyperion/external_interaction/nexus/test_compare_nexus_to_gda_exhaustively.py b/tests/unit_tests/hyperion/external_interaction/nexus/test_compare_nexus_to_gda_exhaustively.py index 3df5ee2f7e..fcccf8b52a 100644 --- a/tests/unit_tests/hyperion/external_interaction/nexus/test_compare_nexus_to_gda_exhaustively.py +++ b/tests/unit_tests/hyperion/external_interaction/nexus/test_compare_nexus_to_gda_exhaustively.py @@ -14,6 +14,9 @@ from mx_bluesky.common.experiment_plans.inner_plans.read_hardware import ( standard_read_hardware_during_collection, ) +from mx_bluesky.common.parameters.rotation import ( + RotationScan, +) from mx_bluesky.hyperion.experiment_plans.rotation_scan_plan import ( RotationScanComposite, ) @@ -21,7 +24,6 @@ RotationNexusFileCallback, ) from mx_bluesky.hyperion.parameters.constants import CONST -from mx_bluesky.hyperion.parameters.rotation import RotationScan from .....conftest import extract_metafile, raw_params_from_file diff --git a/tests/unit_tests/hyperion/external_interaction/test_write_rotation_nexus.py b/tests/unit_tests/hyperion/external_interaction/test_write_rotation_nexus.py index 00c6339e19..9a912d31c6 100644 --- a/tests/unit_tests/hyperion/external_interaction/test_write_rotation_nexus.py +++ b/tests/unit_tests/hyperion/external_interaction/test_write_rotation_nexus.py @@ -15,6 +15,9 @@ standard_read_hardware_during_collection, ) from mx_bluesky.common.external_interaction.nexus.write_nexus import NexusWriter +from mx_bluesky.common.parameters.rotation import ( + SingleRotationScan, +) from mx_bluesky.common.utils.log import LOGGER from mx_bluesky.hyperion.experiment_plans.rotation_scan_plan import ( RotationScanComposite, @@ -23,7 +26,6 @@ RotationNexusFileCallback, ) from mx_bluesky.hyperion.parameters.constants import CONST -from mx_bluesky.hyperion.parameters.rotation import SingleRotationScan from ....conftest import extract_metafile, raw_params_from_file diff --git a/tests/unit_tests/hyperion/parameters/test_parameter_model.py b/tests/unit_tests/hyperion/parameters/test_parameter_model.py index f997358926..a0898ceccb 100644 --- a/tests/unit_tests/hyperion/parameters/test_parameter_model.py +++ b/tests/unit_tests/hyperion/parameters/test_parameter_model.py @@ -10,13 +10,15 @@ GridParamUpdate, ) from mx_bluesky.common.parameters.constants import GridscanParamConstants +from mx_bluesky.common.parameters.rotation import ( + SingleRotationScan, +) from mx_bluesky.hyperion.parameters.gridscan import ( HyperionSpecifiedThreeDGridScan, OddYStepsException, ) from mx_bluesky.hyperion.parameters.load_centre_collect import LoadCentreCollect from mx_bluesky.hyperion.parameters.robot_load import RobotLoadThenCentre -from mx_bluesky.hyperion.parameters.rotation import SingleRotationScan from ....conftest import raw_params_from_file From fdfc32fdc0a1163c6aa4fe938b64808bfc927792 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Thu, 4 Sep 2025 17:55:27 +0100 Subject: [PATCH 15/64] Fixes and add test --- .../callbacks/metadata_writer.py | 14 ++- .../i24/jungfrau_commissioning/composites.py | 37 ++++++++ ...on_scan_plans.py => rotation_scan_plan.py} | 94 +++++++------------ .../jungfrau_commissioning/utility_plans.py | 3 +- .../common/experiment_plans/setup_zebra.py | 17 ++-- .../common/parameters/components.py | 7 +- src/mx_bluesky/common/parameters/constants.py | 1 + .../common/parameters/device_composites.py | 6 +- .../device_setup_plans/setup_panda.py | 2 +- .../test_data/test_good_rotation_params.json | 16 ++++ .../jungfrau_commissioning/test_do_darks.py | 4 +- .../test_rotation_scan.py | 92 ++++++++++++++++++ 12 files changed, 214 insertions(+), 79 deletions(-) create mode 100644 src/mx_bluesky/beamlines/i24/jungfrau_commissioning/composites.py rename src/mx_bluesky/beamlines/i24/jungfrau_commissioning/{rotation_scan_plans.py => rotation_scan_plan.py} (73%) create mode 100644 tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_data/test_good_rotation_params.json create mode 100644 tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py index 627b3a1764..f19664e31f 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py @@ -3,6 +3,8 @@ from bluesky.callbacks import CallbackBase +from mx_bluesky.beamlines.i24.jungfrau_commissioning.utility_plans import METADATA_READ +from mx_bluesky.common.parameters.constants import PlanNameConstants from mx_bluesky.common.parameters.rotation import SingleRotationScan from mx_bluesky.common.utils.log import LOGGER @@ -25,6 +27,10 @@ class JsonMetadataWriter(CallbackBase): def __init__(self, beam_xy: tuple[float, float]): self.beam_xy = beam_xy + self.wavelength_in_a = None + self.energy_in_kev = None + self.detector_distance_mm = None + super().__init__() descriptors: dict[str, dict] = {} @@ -34,7 +40,7 @@ def __init__(self, beam_xy: tuple[float, float]): transmission: float | None = None def start(self, doc: dict): # type: ignore - if doc.get("subplan_name") == "rotation_scan_with_cleanup": + if doc.get("subplan_name") == PlanNameConstants.ROTATION_MAIN: LOGGER.info( "Metadata writer recieved start document with experiment parameters." ) @@ -50,15 +56,15 @@ def event(self, doc: dict): # type: ignore LOGGER.info("Nexus handler received event document.") event_descriptor = self.descriptors[doc["descriptor"]] - if event_descriptor.get("name") == "beam params": + if event_descriptor.get("name") == METADATA_READ: assert self.parameters is not None data = doc.get("data") assert data is not None self.wavelength_in_a = data.get("dcm-wavelength_in_a") self.energy_in_kev = data.get("dcm-energy_in_kev") - self.detector_distance_mm = data.get("detector_motion-z") + self.detector_distance_mm = data.get("det_stage-z") LOGGER.info( - f"Nexus handler received beam parameters, transmission: {self.transmission}, flux: {self.flux}, wavelength: {self.wavelength}, det distance: {self.detector_distance}, beam_xy: {self.beam_xy}" + f"Nexus handler received beam parameters, transmission: {self.transmission}, flux: {self.flux}, wavelength: {self.wavelength}, det distance: {self.detector_distance_mm}, beam_xy: {self.beam_xy}" ) LOGGER.info("") diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/composites.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/composites.py new file mode 100644 index 0000000000..55c7b88a0d --- /dev/null +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/composites.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import pydantic +from dodal.devices.attenuator.attenuator import EnumFilterAttenuator +from dodal.devices.hutch_shutter import HutchShutter +from dodal.devices.i24.aperture import Aperture +from dodal.devices.i24.beamstop import Beamstop +from dodal.devices.i24.dcm import DCM +from dodal.devices.i24.dual_backlight import DualBacklight +from dodal.devices.i24.vgonio import VerticalGoniometer +from dodal.devices.motors import YZStage +from dodal.devices.synchrotron import Synchrotron +from dodal.devices.xbpm_feedback import XBPMFeedback +from dodal.devices.zebra.zebra import Zebra +from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter +from ophyd_async.fastcs.jungfrau import ( + Jungfrau, +) + + +@pydantic.dataclasses.dataclass(config={"arbitrary_types_allowed": True}) +class RotationScanComposite: + """All devices which are directly or indirectly required by this plan""" + + aperture: Aperture + attenuator: EnumFilterAttenuator + jungfrau: Jungfrau + gonio: VerticalGoniometer + synchrotron: Synchrotron + sample_shutter: ZebraShutter + zebra: Zebra + xbpm_feedback: XBPMFeedback + hutch_shutter: HutchShutter + beamstop: Beamstop + det_stage: YZStage # TODO add JF position to det stage device + backlight: DualBacklight + dcm: DCM diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plans.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py similarity index 73% rename from src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plans.py rename to src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py index 760fc9406b..0077433359 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plans.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py @@ -2,38 +2,33 @@ import bluesky.plan_stubs as bps import bluesky.preprocessors as bpp -import pydantic -from dodal.devices.attenuator.attenuator import EnumFilterAttenuator -from dodal.devices.hutch_shutter import HutchShutter, ShutterState -from dodal.devices.i24.aperture import Aperture, AperturePositions -from dodal.devices.i24.beamstop import Beamstop, BeamstopPositions -from dodal.devices.i24.dcm import DCM -from dodal.devices.i24.dual_backlight import BacklightPositions, DualBacklight -from dodal.devices.motors import YZStage -from dodal.devices.synchrotron import Synchrotron -from dodal.devices.xbpm_feedback import XBPMFeedback +from dodal.devices.hutch_shutter import ShutterState +from dodal.devices.i24.aperture import AperturePositions +from dodal.devices.i24.beamstop import BeamstopPositions +from dodal.devices.i24.dual_backlight import BacklightPositions from dodal.devices.zebra.zebra import Zebra -from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter from dodal.plan_stubs.check_topup import check_topup_and_wait_if_necessary from ophyd_async.fastcs.jungfrau import ( - Jungfrau, create_jungfrau_external_triggering_info, ) from mx_bluesky.beamlines.i24.jungfrau_commissioning.callbacks.metadata_writer import ( JsonMetadataWriter, ) +from mx_bluesky.beamlines.i24.jungfrau_commissioning.composites import ( + RotationScanComposite, +) from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( JF_COMPLETE_GROUP, fly_jungfrau, override_file_name_and_path, ) +from mx_bluesky.beamlines.i24.jungfrau_commissioning.utility_plans import ( + read_devices_for_metadata, +) from mx_bluesky.beamlines.i24.serial.setup_beamline.setup_zebra_plans import ( disarm_zebra, ) -from mx_bluesky.common.experiment_plans.inner_plans.read_hardware import ( - read_hardware_plan, -) from mx_bluesky.common.experiment_plans.rotation.rotation_utils import ( RotationMotionProfile, calculate_motion_profile, @@ -43,11 +38,9 @@ setup_zebra_for_rotation, ) from mx_bluesky.common.parameters.constants import ( - DocDescriptorNames, PlanGroupCheckpointConstants, PlanNameConstants, ) -from mx_bluesky.common.parameters.device_composites import GonioWithXYZOmega from mx_bluesky.common.parameters.rotation import ( SingleRotationScan, ) @@ -59,28 +52,7 @@ JF_DET_STAGE_Y_POSITION = 0 # TODO find out what this is! -@pydantic.dataclasses.dataclass(config={"arbitrary_types_allowed": True}) -class RotationScanComposite: - """All devices which are directly or indirectly required by this plan""" - - aperture: Aperture - attenuator: EnumFilterAttenuator - jungfrau: Jungfrau - gonio: GonioWithXYZOmega - synchrotron: Synchrotron - sample_shutter: ZebraShutter - zebra: Zebra - xbpm_feedback: XBPMFeedback - hutch_shutter: HutchShutter - beamstop: Beamstop - det_stage: YZStage # TODO add JF position to det stage device - backlight: DualBacklight - dcm: DCM - - -def set_up_beamline_for_rotation( - composite: RotationScanComposite, -): +def set_up_beamline_for_rotation(composite: RotationScanComposite, det_z_mm: float): """Check hutch is open, move backlight in, then, in parallel, move aperture in, move backlight out and move det stage in""" @@ -90,21 +62,21 @@ def set_up_beamline_for_rotation( LOGGER.info(f"Hutch shutter: {hutch_shutter_state}") if hutch_shutter_state != ShutterState.OPEN: LOGGER.error(f"Hutch shutter is not open! State is {hutch_shutter_state}") - raise Exception() + raise Exception(f"Hutch shutter is not open! State is {hutch_shutter_state}") LOGGER.info("Making sure backlight is moved out...") yield from bps.mv(composite.backlight.backlight_position, BacklightPositions.OUT) LOGGER.info( - "Making sure aperture and beamstop are in, and detector stage is in position" + "Making sure aperture and beamstop are in, detector stage is in position, and detector distance is correct." ) yield from bps.mv( - composite.aperture, - AperturePositions, + composite.aperture.position, + AperturePositions.IN, composite.beamstop.pos_select, - composite.beamstop, BeamstopPositions.DATA_COLLECTION, composite.det_stage.y, JF_DET_STAGE_Y_POSITION, + composite.det_stage.z, ) @@ -119,9 +91,9 @@ def single_rotation_plan( # This should be somewhere more sensible - like in the parameter model if not params.detector_distance_mm: raise ValueError("Must specify detector distance in mm") - beam_xy = params.detector_params.get_beam_position_mm(params.detector_distance_mm) - yield from set_up_beamline_for_rotation(composite) + yield from set_up_beamline_for_rotation(composite, params.detector_distance_mm) + beam_xy = params.detector_params.get_beam_position_mm(params.detector_distance_mm) LOGGER.info( f"Moving detector Z stage to specified {params.detector_distance_mm} mm..." ) @@ -144,14 +116,18 @@ def single_rotation_plan( md={ "subplan_name": PlanNameConstants.ROTATION_MAIN, "scan_points": [params.scan_points], + "rotation_scan_params": params.model_dump_json(), } ) def _rotation_scan_plan( motion_values: RotationMotionProfile, composite: RotationScanComposite, ): + # Use smallest safe deadtime and neglect from motion calcualtions + _deadtime = 2e-5 + _jf_trigger_info = create_jungfrau_external_triggering_info( - params.num_images, params.detector_params.exposure_time_s + params.num_images, params.detector_params.exposure_time_s, _deadtime ) axis = composite.gonio.omega @@ -175,7 +151,7 @@ def _rotation_scan_plan( direction=motion_values.direction, shutter_opening_deg=motion_values.shutter_opening_deg, shutter_opening_s=motion_values.shutter_time_s, - group="setup_zebra", + group=PlanGroupCheckpointConstants.SETUP_ZEBRA_FOR_ROTATION, ) LOGGER.info("Wait for any previous moves...") @@ -183,17 +159,12 @@ def _rotation_scan_plan( yield from bps.wait(PlanGroupCheckpointConstants.ROTATION_READY_FOR_DC) yield from bps.wait(PlanGroupCheckpointConstants.MOVE_GONIO_TO_START) - yield from read_hardware_plan( - [composite.synchrotron, composite.gonio], - DocDescriptorNames.HARDWARE_READ_PRE, - ) - # Get ready for the actual scan yield from bps.abs_set( axis.velocity, motion_values.speed_for_rotation_deg_s, wait=True ) - yield from bps.wait("setup_zebra") + yield from bps.wait(PlanGroupCheckpointConstants.SETUP_ZEBRA_FOR_ROTATION) yield from arm_zebra(composite.zebra) # Check topup gate @@ -204,9 +175,12 @@ def _rotation_scan_plan( ) # See #https://github.com/DiamondLightSource/hyperion/issues/932 override_file_name_and_path( - composite.jungfrau, params.detector_params.full_filename + composite.jungfrau, + f"{params.storage_directory}/{params.detector_params.full_filename}", ) + yield from read_devices_for_metadata(composite) + yield from fly_jungfrau( composite.jungfrau, _jf_trigger_info, @@ -217,20 +191,16 @@ def _rotation_scan_plan( LOGGER.info("Executing rotation scan") yield from bps.rel_set(axis, motion_values.distance_to_move_deg, wait=True) - yield from read_hardware_plan( - [composite.attenuator, composite.jungfrau], - DocDescriptorNames.HARDWARE_READ_DURING, - ) - yield from bps.wait(group=JF_COMPLETE_GROUP) - # TODO check bluesky doesnt do this for us + # TODO check bluesky doesnt already do this for us yield from bpp.contingency_wrapper( _rotation_scan_plan(motion_values, composite), - except_plan=lambda: (yield from bps.unstage(composite.jungfrau)), + except_plan=lambda _: (yield from bps.unstage(composite.jungfrau)), ) yield from _rotation_scan_plan(motion_values, composite) + print("test") def cleanup_plan(zebra: Zebra, group="cleanup"): diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/utility_plans.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/utility_plans.py index 68df83baa8..7267f99644 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/utility_plans.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/utility_plans.py @@ -1,7 +1,7 @@ import bluesky.plan_stubs as bps from dodal.devices.attenuator.attenuator import EnumFilterAttenuator -from mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plans import ( +from mx_bluesky.beamlines.i24.jungfrau_commissioning.composites import ( RotationScanComposite, ) from mx_bluesky.common.utils.log import LOGGER @@ -32,3 +32,4 @@ def read_devices_for_metadata(composite: RotationScanComposite): yield from bps.read(composite.dcm.energy_in_kev) yield from bps.read(composite.dcm.wavelength_in_a) yield from bps.read(composite.det_stage.z) + yield from bps.save() diff --git a/src/mx_bluesky/common/experiment_plans/setup_zebra.py b/src/mx_bluesky/common/experiment_plans/setup_zebra.py index 0093040d80..7ef9c2847a 100644 --- a/src/mx_bluesky/common/experiment_plans/setup_zebra.py +++ b/src/mx_bluesky/common/experiment_plans/setup_zebra.py @@ -6,6 +6,7 @@ RotationDirection, Zebra, ) +from dodal.devices.zebra.zebra_constants_mapping import UnmappedZebraException from dodal.devices.zebra.zebra_controlled_shutter import ( ZebraShutter, ZebraShutterControl, @@ -101,12 +102,16 @@ def setup_zebra_for_rotation( zebra.mapping.sources.PC_PULSE, group=group, ) - # Don't use the fluorescence detector - yield from bps.abs_set( - zebra.output.out_pvs[zebra.mapping.outputs.TTL_XSPRESS3], - zebra.mapping.sources.DISCONNECT, - group=group, - ) + + # Don't use the fluorescence detector if connected + try: + yield from bps.abs_set( + zebra.output.out_pvs[zebra.mapping.outputs.TTL_XSPRESS3], + zebra.mapping.sources.DISCONNECT, + group=group, + ) + except UnmappedZebraException: + ... yield from bps.abs_set( zebra.output.pulse_1.input, zebra.mapping.sources.DISCONNECT, group=group ) diff --git a/src/mx_bluesky/common/parameters/components.py b/src/mx_bluesky/common/parameters/components.py index 2513eb5eb7..fc9f81a3ff 100644 --- a/src/mx_bluesky/common/parameters/components.py +++ b/src/mx_bluesky/common/parameters/components.py @@ -12,6 +12,7 @@ DetectorParams, TriggerMode, ) +from dodal.utils import BeamlinePrefix, get_beamline_name from pydantic import ( BaseModel, ConfigDict, @@ -31,6 +32,8 @@ PARAMETER_VERSION = Version.parse("5.3.0") +BL = get_beamline_name("i03") + class RotationAxis(StrEnum): OMEGA = "omega" @@ -152,7 +155,9 @@ class WithVisit(BaseModel): det_dist_to_beam_converter_path: str = Field( default=DetectorParamConstants.BEAM_XY_LUT_PATH ) - insertion_prefix: str = "SR03S" if TEST_MODE else "SR03I" + insertion_prefix: str = ( + f"{BeamlinePrefix(BL).insertion_prefix}" if TEST_MODE else "SR03I" + ) detector_distance_mm: float | None = Field(default=None, gt=0) diff --git a/src/mx_bluesky/common/parameters/constants.py b/src/mx_bluesky/common/parameters/constants.py index 42986b7623..ab43764ed1 100644 --- a/src/mx_bluesky/common/parameters/constants.py +++ b/src/mx_bluesky/common/parameters/constants.py @@ -136,6 +136,7 @@ class PlanGroupCheckpointConstants: MOVE_GONIO_TO_START = "move_gonio_to_start" READY_FOR_OAV = "ready_for_oav" PREPARE_APERTURE = "prepare_aperture" + SETUP_ZEBRA_FOR_ROTATION = "setup_zebra_for_rotation" # Eventually replace below with https://github.com/DiamondLightSource/mx-bluesky/issues/798 diff --git a/src/mx_bluesky/common/parameters/device_composites.py b/src/mx_bluesky/common/parameters/device_composites.py index 1d8e2379f0..42e8990c4e 100644 --- a/src/mx_bluesky/common/parameters/device_composites.py +++ b/src/mx_bluesky/common/parameters/device_composites.py @@ -1,4 +1,7 @@ +from typing import Protocol + import pydantic +from bluesky.protocols import Readable from dodal.devices.aperturescatterguard import ( ApertureScatterguard, ) @@ -23,7 +26,6 @@ from dodal.devices.zebra.zebra import Zebra from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter from dodal.devices.zocalo import ZocaloResults -from ophyd_async.core import StandardReadable from ophyd_async.epics.motor import Motor @@ -67,7 +69,7 @@ class GridDetectThenXRayCentreComposite(FlyScanEssentialDevices): sample_shutter: ZebraShutter -class GonioWithXYZOmega(StandardReadable): +class GonioWithXYZOmega(Readable, Protocol): omega: Motor x: Motor y: Motor diff --git a/src/mx_bluesky/hyperion/device_setup_plans/setup_panda.py b/src/mx_bluesky/hyperion/device_setup_plans/setup_panda.py index 30eafe10d7..f38ddfb2ed 100644 --- a/src/mx_bluesky/hyperion/device_setup_plans/setup_panda.py +++ b/src/mx_bluesky/hyperion/device_setup_plans/setup_panda.py @@ -16,8 +16,8 @@ ) from mx_bluesky.common.device_setup_plans.setup_panda import load_panda_from_yaml +from mx_bluesky.common.parameters.constants import DeviceSettingsConstants from mx_bluesky.common.utils.log import LOGGER -from mx_bluesky.hyperion.parameters.constants import DeviceSettingsConstants MM_TO_ENCODER_COUNTS = 200000 GENERAL_TIMEOUT = 60 diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_data/test_good_rotation_params.json b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_data/test_good_rotation_params.json new file mode 100644 index 0000000000..ffc703417b --- /dev/null +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_data/test_good_rotation_params.json @@ -0,0 +1,16 @@ +{ + "parameter_model_version": "5.0.0", + "storage_directory": "{tmp_data}/123456/", + "detector_distance_mm": 100.0, + "exposure_time_s": 0.1, + "omega_start_deg": 45, + "file_name": "file_name", + "rotation_increment_deg": 0.1, + "sample_id": 123456, + "shutter_opening_time_s": 0.5, + "visit": "cm31105-4", + "transmission_frac": 0.1 +} + + + diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py index a6c4156e3d..8ce6e800bc 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py @@ -53,7 +53,7 @@ def test_plan(): jungfrau._controller.arm = AsyncMock() assert await jungfrau.drv.acquisition_type.get_value() == AcquisitionType.STANDARD await jungfrau.drv.gain_mode.set(GainMode.FIX_G2) - await jungfrau.drv.pedestal_mode.set(PedestalMode.OFF) + await jungfrau.drv.pedestal_mode_state.set(PedestalMode.OFF) monitor_tracker = CheckMonitor( [ "jungfrau-drv-acquisition_type", @@ -67,7 +67,7 @@ def test_plan(): [ jungfrau.drv.acquisition_type, jungfrau.drv.gain_mode, - jungfrau.drv.pedestal_mode, + jungfrau.drv.pedestal_mode_state, ], ) ) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py new file mode 100644 index 0000000000..fd3dcad9a5 --- /dev/null +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py @@ -0,0 +1,92 @@ +import pytest +from bluesky.run_engine import RunEngine +from dodal.beamlines.i24 import VerticalGoniometer +from dodal.beamlines.i24 import attenuator as i24_attenuator +from dodal.devices.hutch_shutter import HutchShutter, ShutterState +from dodal.devices.i24.aperture import Aperture +from dodal.devices.i24.beamstop import Beamstop +from dodal.devices.i24.dcm import DCM +from dodal.devices.i24.dual_backlight import DualBacklight +from dodal.devices.motors import YZStage +from dodal.devices.synchrotron import Synchrotron +from dodal.devices.util.test_utils import patch_all_motors +from dodal.devices.xbpm_feedback import XBPMFeedback +from dodal.devices.zebra.zebra import Zebra +from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter +from ophyd_async.core import init_devices +from ophyd_async.fastcs.jungfrau import Jungfrau +from ophyd_async.testing import set_mock_value +from tests.conftest import raw_params_from_file + +from mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan import ( + RotationScanComposite, + single_rotation_plan, +) +from mx_bluesky.common.parameters.rotation import SingleRotationScan + + +def get_good_rotation_params(tmp_path): + params = raw_params_from_file( + "tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_data/test_good_rotation_params.json", + tmp_path, + ) + + return SingleRotationScan(**params) + + +@pytest.fixture +def rotation_composite(jungfrau: Jungfrau, zebra: Zebra) -> RotationScanComposite: + with init_devices(mock=True): + aperture = Aperture("") + attenuator = i24_attenuator() + gonio = VerticalGoniometer("") + synchrotron = Synchrotron("") + sample_shutter = ZebraShutter("") + xbpm_feedback = XBPMFeedback("") + hutch_shutter = HutchShutter("") + beamstop = Beamstop("") + det_stage = YZStage("") # TODO add JF position to det stage device + backlight = DualBacklight("") + dcm = DCM("") + + patch_all_motors(det_stage) + patch_all_motors(sample_shutter) + patch_all_motors(gonio) + + composite = RotationScanComposite( + aperture, + attenuator, + jungfrau, + gonio, + synchrotron, + sample_shutter, + zebra, + xbpm_feedback, + hutch_shutter, + beamstop, + det_stage, + backlight, + dcm, + ) + + return composite + + +def test_single_rotation_plan_in_re( + RE: RunEngine, tmp_path, rotation_composite: RotationScanComposite +): + params = get_good_rotation_params(tmp_path) + set_mock_value( + rotation_composite.jungfrau._writer._drv.num_captured, params.num_images + ) + set_mock_value(rotation_composite.hutch_shutter.status, ShutterState.OPEN) + RE(single_rotation_plan(rotation_composite, params)) + + +def test_metadata_writer_produces_correct_json_after_plan(): ... + + +def test_set_up_beamline_for_rotation(): ... + + +def test_single_rotation_plan_error_if_no_det_distance(): ... From dc680a5033117ba535e405d97133aa33d145c05c Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Mon, 22 Sep 2025 13:00:51 +0000 Subject: [PATCH 16/64] Refactor external acquisiton plan into reusable plan utils --- .../do_external_acquisition.py | 31 ++++------ .../i24/jungfrau_commissioning/plan_utils.py | 24 +++++--- .../test_do_external_acquisition.py | 44 ++++---------- .../jungfrau_commissioning/test_plan_utils.py | 58 +++++++++++++++++++ 4 files changed, 98 insertions(+), 59 deletions(-) create mode 100644 tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py index c2911285b8..e2eec9f14c 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py @@ -1,28 +1,26 @@ -from pathlib import Path - from bluesky.utils import MsgGenerator from dodal.common import inject - +from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau from ophyd_async.core import ( - AutoIncrementFilenameProvider, - StaticPathProvider, WatchableAsyncStatus, ) from ophyd_async.fastcs.jungfrau import ( - Jungfrau, create_jungfrau_external_triggering_info, ) from pydantic import PositiveInt -from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import fly_jungfrau +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( + fly_jungfrau, + override_file_name, +) def do_external_acquisition( exp_time_s: float, total_triggers: PositiveInt = 1, - jungfrau: Jungfrau = inject("jungfrau"), - path_of_output_file: str | None = None, + output_file_name: str | None = None, wait: bool = False, + jungfrau: CommissioningJungfrau = inject("commissioning_jungfrau"), ) -> MsgGenerator[WatchableAsyncStatus]: """ Kickoff external triggering on the Jungfrau, and optionally wait for completion. @@ -33,21 +31,14 @@ def do_external_acquisition( exp_time_s: Length of detector exposure for each frame. total_triggers: Number of external triggers recieved before acquisition is marked as complete. jungfrau: Jungfrau device - path_of_output_file: Absolute path of the detector file output, including file name. If None, then use the PathProvider + output_file_name: Absolute path of the detector file output, including file name. If None, then use the PathProvider set during jungfrau device instantiation wait: Optionally block until data collection is complete. """ - # While we should generally use device instantiation to set the path, - # this will be useful during commissioning - if path_of_output_file: - _file_path = Path(path_of_output_file) - filename_provider = AutoIncrementFilenameProvider(_file_path.name) - path_provider = StaticPathProvider(filename_provider, _file_path.parent) - jungfrau._writer._path_provider = path_provider # noqa: SLF001 + if output_file_name: + override_file_name(jungfrau, output_file_name) - trigger_info = create_jungfrau_external_triggering_info( - total_triggers, exp_time_s - ) + trigger_info = create_jungfrau_external_triggering_info(total_triggers, exp_time_s) status = yield from fly_jungfrau(jungfrau, trigger_info, wait) return status diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py index 7ac14ae6c9..ef10e7f776 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py @@ -1,26 +1,29 @@ +from pathlib import Path from typing import cast import bluesky.plan_stubs as bps from bluesky.utils import MsgGenerator from dodal.common.watcher_utils import log_on_percentage_complete +from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau from ophyd_async.core import ( + StaticFilenameProvider, TriggerInfo, WatchableAsyncStatus, ) -from ophyd_async.fastcs.jungfrau import ( - Jungfrau, -) from mx_bluesky.common.utils.log import LOGGER +JF_COMPLETE_GROUP = "JF complete" + def fly_jungfrau( - jungfrau: Jungfrau, trigger_info: TriggerInfo, wait: bool = False + jungfrau: CommissioningJungfrau, trigger_info: TriggerInfo, wait: bool = False ) -> MsgGenerator[WatchableAsyncStatus]: """Stage, prepare, and kickoff Jungfrau with a configured TriggerInfo. Optionally wait for completion. - Note that this plan doesn't include unstaging of the Jungfrau. + Note that this plan doesn't include unstaging of the Jungfrau, and a run must be open + before this plan is called. Args: jungfrau: Jungfrau device. @@ -35,12 +38,19 @@ def fly_jungfrau( LOGGER.info("Detector prepared. Starting acquisition") yield from bps.kickoff(jungfrau, wait=True) LOGGER.info("Waiting for acquisition to complete...") - status = yield from bps.complete(jungfrau, group="jf_complete") + status = yield from bps.complete(jungfrau, group=JF_COMPLETE_GROUP) # StandardDetector.complete converts regular status to watchable status, # but bluesky plan stubs can't see this currently status = cast(WatchableAsyncStatus, status) log_on_percentage_complete(status, "Jungfrau data collection triggers recieved", 10) if wait: - yield from bps.wait("jf_complete") + yield from bps.wait(JF_COMPLETE_GROUP) return status + + +# While we should generally use device instantiation to set the path, +# this will be useful during commissioning +def override_file_name(jungfrau: CommissioningJungfrau, path_of_output_file: str): + _file_path = Path(path_of_output_file) + jungfrau.provider._filename_provider = StaticFilenameProvider(_file_path.name) # noqa: SLF001 diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py index b261dda596..157445f249 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py @@ -7,27 +7,29 @@ from bluesky.preprocessors import run_decorator from bluesky.run_engine import RunEngine from bluesky.simulators import RunEngineSimulator, assert_message_and_return_remaining -from ophyd_async.core import AutoIncrementFilenameProvider, StaticPathProvider -from ophyd_async.fastcs.jungfrau import Jungfrau +from dodal.beamlines.i24 import CommissioningJungfrau from ophyd_async.testing import set_mock_value from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_external_acquisition import ( do_external_acquisition, ) +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import JF_COMPLETE_GROUP @pytest.mark.skip( reason="Waiting on ophyd-async PR https://github.com/bluesky/ophyd-async/pull/1038/files" ) -def test_full_do_external_acquisition(jungfrau: Jungfrau, RE: RunEngine, caplog): +def test_full_do_external_acquisition( + jungfrau: CommissioningJungfrau, RE: RunEngine, caplog +): @run_decorator() def test_plan(): - status = yield from do_external_acquisition(0.001, 5, jungfrau) + status = yield from do_external_acquisition(0.001, 5, jungfrau=jungfrau) assert not status.done val = 0 while not status.done: val += 1 - set_mock_value(jungfrau._writer._drv.num_captured, val) + set_mock_value(jungfrau._writer.frame_counter, val) # Let status update yield from bps.wait_for([partial(asyncio.sleep, 0)]) @@ -40,39 +42,17 @@ def test_plan(): @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.do_external_acquisition.log_on_percentage_complete" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils.log_on_percentage_complete" ) def test_do_external_acquisition_does_wait( mock_log_on_percent_complete: MagicMock, sim_run_engine: RunEngineSimulator, - jungfrau: Jungfrau, + jungfrau: CommissioningJungfrau, ): msgs = sim_run_engine.simulate_plan( - do_external_acquisition(0.01, 1, jungfrau, wait=True) + do_external_acquisition(0.01, 1, wait=True, jungfrau=jungfrau) ) assert_message_and_return_remaining( - msgs, lambda msg: msg.command == "wait" and msg.kwargs["group"] == "jf_complete" - ) - - -@patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.do_external_acquisition.log_on_percentage_complete" -) -def test_do_external_acquisition_setting_path( - mock_log_on_percent_complete: MagicMock, - sim_run_engine: RunEngineSimulator, - jungfrau: Jungfrau, - tmpdir, -): - test_path = f"{tmpdir}/test_file" - sim_run_engine.simulate_plan( - do_external_acquisition(0.01, 1, jungfrau, path_of_output_file=test_path) - ) - real_path_provider = jungfrau._writer._path_provider - assert isinstance(real_path_provider, StaticPathProvider) - assert isinstance( - real_path_provider._filename_provider, - AutoIncrementFilenameProvider, + msgs, + lambda msg: msg.command == "wait" and msg.kwargs["group"] == JF_COMPLETE_GROUP, ) - assert real_path_provider._filename_provider._base_filename == "test_file" - assert (real_path_provider._directory_path) == tmpdir diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py new file mode 100644 index 0000000000..99645fdae8 --- /dev/null +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py @@ -0,0 +1,58 @@ +import asyncio +from functools import partial +from pathlib import Path, PurePath + +import bluesky.plan_stubs as bps +import pytest +from bluesky.preprocessors import run_decorator +from bluesky.run_engine import RunEngine +from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau +from ophyd_async.core import ( + AutoIncrementingPathProvider, + StaticFilenameProvider, + TriggerInfo, + init_devices, +) +from ophyd_async.testing import ( + set_mock_value, +) + +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( + JF_COMPLETE_GROUP, + fly_jungfrau, +) + +JF_FILENAME = "jf_out" + + +@pytest.fixture +def jungfrau(tmpdir: Path) -> CommissioningJungfrau: + with init_devices(mock=True): + name = StaticFilenameProvider(JF_FILENAME) + path = AutoIncrementingPathProvider(name, PurePath(tmpdir)) + detector = CommissioningJungfrau("", "", path) + + return detector + + +def test_fly_jungfrau(jungfrau: CommissioningJungfrau, RE: RunEngine, tmpdir: Path): + set_mock_value(jungfrau._writer.writer_ready, 1) + set_mock_value(jungfrau._writer.frame_counter, 10) + + @run_decorator() + def _open_run_and_fly(): + frames = 5 + status = yield from fly_jungfrau( + jungfrau, TriggerInfo(livetime=1e-3, exposures_per_event=frames) + ) + val = 0 + while not status.done: + val += 1 + set_mock_value(jungfrau._writer.frame_counter, val) + yield from bps.wait_for([partial(asyncio.sleep, 0)]) + yield from bps.wait(JF_COMPLETE_GROUP) + assert val == frames + assert (yield from bps.rd(jungfrau._writer.file_path)) == tmpdir + assert (yield from bps.rd(jungfrau._writer.file_name)) == JF_FILENAME + + RE(_open_run_and_fly()) From 9eea8d30c8170dd755a0f0a7776570c93299df86 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Mon, 22 Sep 2025 13:51:14 +0000 Subject: [PATCH 17/64] Improve tests --- .../mx-bluesky-dev-container.code-workspace | 5 ++- .../do_internal_acquisition.py | 20 ++++------ .../i24/jungfrau_commissioning/plan_utils.py | 7 ++-- .../test_do_external_acquisition.py | 1 + .../test_do_internal_acquisition.py | 40 +++++-------------- .../jungfrau_commissioning/test_plan_utils.py | 31 +++++--------- tests/unit_tests/conftest.py | 34 ++++++++-------- 7 files changed, 51 insertions(+), 87 deletions(-) diff --git a/.vscode/mx-bluesky-dev-container.code-workspace b/.vscode/mx-bluesky-dev-container.code-workspace index 45844927b8..7e49e5fbc7 100644 --- a/.vscode/mx-bluesky-dev-container.code-workspace +++ b/.vscode/mx-bluesky-dev-container.code-workspace @@ -5,12 +5,15 @@ }, { "path": "../../dodal" + }, + { + "path": "../../ophyd-async" } ], "settings": { "python.languageServer": "Pylance", "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter" + "editor.defaultFormatter": "charliermarsh.ruff" }, "python.defaultInterpreterPath": "venv/bin/python", "python.analysis.extraPaths": [ diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py index 567b126cc7..608f7f5711 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py @@ -1,25 +1,24 @@ -from pathlib import Path - from bluesky.utils import MsgGenerator +from dodal.beamlines.i24 import CommissioningJungfrau from dodal.common import inject from ophyd_async.core import ( - AutoIncrementFilenameProvider, - StaticPathProvider, WatchableAsyncStatus, ) from ophyd_async.fastcs.jungfrau import ( - Jungfrau, create_jungfrau_internal_triggering_info, ) from pydantic import PositiveInt -from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import fly_jungfrau +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( + fly_jungfrau, + override_file_name, +) def do_internal_acquisition( exp_time_s: float, total_frames: PositiveInt = 1, - jungfrau: Jungfrau = inject("jungfrau"), + jungfrau: CommissioningJungfrau = inject("jungfrau"), path_of_output_file: str | None = None, wait: bool = False, ) -> MsgGenerator[WatchableAsyncStatus]: @@ -39,13 +38,8 @@ def do_internal_acquisition( wait: Optionally block until data collection is complete. """ - # While we should generally use device instantiation to set the path, - # this will be useful during commissioning if path_of_output_file: - _file_path = Path(path_of_output_file) - filename_provider = AutoIncrementFilenameProvider(_file_path.name) - path_provider = StaticPathProvider(filename_provider, _file_path.parent) - jungfrau._writer._path_provider = path_provider # noqa: SLF001 + override_file_name(jungfrau, path_of_output_file) trigger_info = create_jungfrau_internal_triggering_info(total_frames, exp_time_s) status = yield from fly_jungfrau(jungfrau, trigger_info, wait) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py index ef10e7f776..e13ce121ca 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py @@ -1,4 +1,3 @@ -from pathlib import Path from typing import cast import bluesky.plan_stubs as bps @@ -51,6 +50,6 @@ def fly_jungfrau( # While we should generally use device instantiation to set the path, # this will be useful during commissioning -def override_file_name(jungfrau: CommissioningJungfrau, path_of_output_file: str): - _file_path = Path(path_of_output_file) - jungfrau.provider._filename_provider = StaticFilenameProvider(_file_path.name) # noqa: SLF001 +def override_file_name(jungfrau: CommissioningJungfrau, file_name: str): + jungfrau.provider._filename_provider = StaticFilenameProvider(file_name) # noqa: SLF001 + yield from bps.abs_set(jungfrau._writer.file_name, file_name) # noqa: SLF001 diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py index 157445f249..69b3193ea4 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py @@ -47,6 +47,7 @@ def test_plan(): def test_do_external_acquisition_does_wait( mock_log_on_percent_complete: MagicMock, sim_run_engine: RunEngineSimulator, + RE: RunEngine, jungfrau: CommissioningJungfrau, ): msgs = sim_run_engine.simulate_plan( diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py index ae44b0cf14..71c73127d7 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py @@ -6,16 +6,18 @@ from bluesky.preprocessors import run_decorator from bluesky.run_engine import RunEngine from bluesky.simulators import RunEngineSimulator, assert_message_and_return_remaining -from ophyd_async.core import AutoIncrementFilenameProvider, StaticPathProvider -from ophyd_async.fastcs.jungfrau import Jungfrau +from dodal.beamlines.i24 import CommissioningJungfrau from ophyd_async.testing import set_mock_value from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_internal_acquisition import ( do_internal_acquisition, ) +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import JF_COMPLETE_GROUP -def test_full_do_internal_acquisition(jungfrau: Jungfrau, RE: RunEngine, caplog): +def test_full_do_internal_acquisition( + RE: RunEngine, jungfrau: CommissioningJungfrau, caplog +): @run_decorator() def test_plan(): status = yield from do_internal_acquisition(0.001, 5, jungfrau) @@ -23,9 +25,9 @@ def test_plan(): val = 0 while not status.done: val += 1 - set_mock_value(jungfrau._writer._drv.num_captured, val) + set_mock_value(jungfrau._writer.frame_counter, val) yield from bps.wait_for([partial(asyncio.sleep, 0)]) - yield from bps.wait("jf_complete") + yield from bps.wait(JF_COMPLETE_GROUP) jungfrau._controller.arm = AsyncMock() RE(test_plan()) @@ -38,34 +40,12 @@ def test_plan(): def test_do_internal_acquisition_does_wait( mock_log_on_percent_complete: MagicMock, sim_run_engine: RunEngineSimulator, - jungfrau: Jungfrau, + jungfrau: CommissioningJungfrau, ): msgs = sim_run_engine.simulate_plan( do_internal_acquisition(0.01, 1, jungfrau, wait=True) ) assert_message_and_return_remaining( - msgs, lambda msg: msg.command == "wait" and msg.kwargs["group"] == "jf_complete" - ) - - -@patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils.log_on_percentage_complete" -) -def test_do_internal_acquisition_setting_path( - mock_log_on_percent_complete: MagicMock, - sim_run_engine: RunEngineSimulator, - jungfrau: Jungfrau, - tmpdir, -): - test_path = f"{tmpdir}/test_file" - sim_run_engine.simulate_plan( - do_internal_acquisition(0.01, 1, jungfrau, path_of_output_file=test_path) - ) - real_path_provider = jungfrau._writer._path_provider - assert isinstance(real_path_provider, StaticPathProvider) - assert isinstance( - real_path_provider._filename_provider, - AutoIncrementFilenameProvider, + msgs, + lambda msg: msg.command == "wait" and msg.kwargs["group"] == JF_COMPLETE_GROUP, ) - assert real_path_provider._filename_provider._base_filename == "test_file" - assert (real_path_provider._directory_path) == tmpdir diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py index 99645fdae8..20bc80efd9 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py @@ -1,17 +1,13 @@ import asyncio from functools import partial -from pathlib import Path, PurePath +from pathlib import Path import bluesky.plan_stubs as bps -import pytest from bluesky.preprocessors import run_decorator from bluesky.run_engine import RunEngine from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau from ophyd_async.core import ( - AutoIncrementingPathProvider, - StaticFilenameProvider, TriggerInfo, - init_devices, ) from ophyd_async.testing import ( set_mock_value, @@ -20,23 +16,11 @@ from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( JF_COMPLETE_GROUP, fly_jungfrau, + override_file_name, ) -JF_FILENAME = "jf_out" - -@pytest.fixture -def jungfrau(tmpdir: Path) -> CommissioningJungfrau: - with init_devices(mock=True): - name = StaticFilenameProvider(JF_FILENAME) - path = AutoIncrementingPathProvider(name, PurePath(tmpdir)) - detector = CommissioningJungfrau("", "", path) - - return detector - - -def test_fly_jungfrau(jungfrau: CommissioningJungfrau, RE: RunEngine, tmpdir: Path): - set_mock_value(jungfrau._writer.writer_ready, 1) +def test_fly_jungfrau(RE: RunEngine, jungfrau: CommissioningJungfrau, tmp_path: Path): set_mock_value(jungfrau._writer.frame_counter, 10) @run_decorator() @@ -52,7 +36,12 @@ def _open_run_and_fly(): yield from bps.wait_for([partial(asyncio.sleep, 0)]) yield from bps.wait(JF_COMPLETE_GROUP) assert val == frames - assert (yield from bps.rd(jungfrau._writer.file_path)) == tmpdir - assert (yield from bps.rd(jungfrau._writer.file_name)) == JF_FILENAME + assert (yield from bps.rd(jungfrau._writer.file_path)) == f"{tmp_path}/00001" RE(_open_run_and_fly()) + + +async def test_override_file_name(jungfrau: CommissioningJungfrau, RE: RunEngine): + new_file_name = "test_file_name" + RE(override_file_name(jungfrau, new_file_name)) + assert await jungfrau._writer.file_name.get_value() == new_file_name diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index d68083d335..bab52db7ac 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -4,6 +4,7 @@ import time from collections.abc import Callable from functools import partial +from pathlib import Path, PurePath from typing import cast from unittest.mock import MagicMock, patch @@ -18,6 +19,7 @@ from dodal.devices.fast_grid_scan import PandAFastGridScan, ZebraFastGridScan from dodal.devices.flux import Flux from dodal.devices.i03 import Beamstop +from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau from dodal.devices.oav.oav_detector import OAV from dodal.devices.oav.pin_image_recognition import PinTipDetection from dodal.devices.robot import BartRobot @@ -26,10 +28,16 @@ from dodal.devices.synchrotron import Synchrotron, SynchrotronMode from dodal.devices.zocalo import ZocaloResults, ZocaloTrigger from event_model.documents import Event -from ophyd_async.core import AsyncStatus, init_devices -from ophyd_async.fastcs.jungfrau import Jungfrau +from ophyd_async.core import ( + AsyncStatus, + AutoIncrementingPathProvider, + StaticFilenameProvider, + init_devices, +) from ophyd_async.fastcs.panda import HDFPanda -from ophyd_async.testing import callback_on_mock_put, set_mock_value +from ophyd_async.testing import ( + set_mock_value, +) from mx_bluesky.common.experiment_plans.common_flyscan_xray_centre_plan import ( BeamlineSpecificFGSFeatures, @@ -479,21 +487,11 @@ async def hyperion_grid_detect_xrc_devices(grid_detect_xrc_devices): # See https://github.com/DiamondLightSource/dodal/issues/1455 @pytest.fixture -def jungfrau(RE: RunEngine): - """The extra logic here prevents exceptions during data collection unit tests""" - +def jungfrau(tmp_path: Path, RE: RunEngine) -> CommissioningJungfrau: with init_devices(mock=True): - detector = Jungfrau("prefix", MagicMock(), "", "", 4, "jungfrau") - - def set_meta_filename_and_id(value, *args, **kwargs): - set_mock_value(detector.odin.meta_file_name, value) - set_mock_value(detector.odin.id, value) - - callback_on_mock_put(detector.odin.file_name, set_meta_filename_and_id) - - detector._writer._path_provider.return_value.filename = "filename.h5" # type: ignore + name = StaticFilenameProvider("jf_out") + path = AutoIncrementingPathProvider(name, PurePath(tmp_path)) + detector = CommissioningJungfrau("", "", path) + set_mock_value(detector._writer.writer_ready, 1) - set_mock_value(detector.odin.meta_active, "Active") - set_mock_value(detector.odin.capture_rbv, "Capturing") - set_mock_value(detector.odin.meta_writing, "Writing") return detector From 529128943a08f8fb9d35583998f909d9fe902a13 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Mon, 22 Sep 2025 13:56:35 +0000 Subject: [PATCH 18/64] pin dodal --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9e49d02e0e..a8faad6018 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "ophyd >= 1.10.5", "ophyd-async >= 0.10.0a2", "bluesky >= 1.13.1", - "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@main", + "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@ab127a2abfde9c52f61292f5825d50b07e385143", ] From 56cb21483bd357bceaeccf193c40403c3065e1a8 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Mon, 22 Sep 2025 14:14:39 +0000 Subject: [PATCH 19/64] update dodal pin --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a8faad6018..a21ed84b0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "ophyd >= 1.10.5", "ophyd-async >= 0.10.0a2", "bluesky >= 1.13.1", - "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@ab127a2abfde9c52f61292f5825d50b07e385143", + "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@872f7e19df76d75a55c5f8b10e3bf4bb90aba5a8", ] From 594046c4d2812c49040e05c003e5e274333e0a3e Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Mon, 22 Sep 2025 14:22:23 +0000 Subject: [PATCH 20/64] update path provider of the writer in override plan --- .../beamlines/i24/jungfrau_commissioning/plan_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py index e13ce121ca..f09e3624ef 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py @@ -52,4 +52,5 @@ def fly_jungfrau( # this will be useful during commissioning def override_file_name(jungfrau: CommissioningJungfrau, file_name: str): jungfrau.provider._filename_provider = StaticFilenameProvider(file_name) # noqa: SLF001 + jungfrau._writer._path_info().filename = file_name # noqa: SLF001 yield from bps.abs_set(jungfrau._writer.file_name, file_name) # noqa: SLF001 From 090a4295b2a5b68fbee7412f74a73dc065a8e2e3 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Mon, 22 Sep 2025 14:56:48 +0000 Subject: [PATCH 21/64] Add stop JF in contingency wrapper for fly plan --- .../i24/jungfrau_commissioning/plan_utils.py | 37 +++++++++++-------- .../jungfrau_commissioning/test_plan_utils.py | 22 +++++++++++ 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py index f09e3624ef..150940c2a3 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py @@ -1,6 +1,7 @@ from typing import cast import bluesky.plan_stubs as bps +import bluesky.preprocessors as bpp from bluesky.utils import MsgGenerator from dodal.common.watcher_utils import log_on_percentage_complete from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau @@ -31,21 +32,27 @@ def fly_jungfrau( wait: Optionally block until data collection is complete. """ - yield from bps.stage(jungfrau) - LOGGER.info("Setting up detector...") - yield from bps.prepare(jungfrau, trigger_info, wait=True) - LOGGER.info("Detector prepared. Starting acquisition") - yield from bps.kickoff(jungfrau, wait=True) - LOGGER.info("Waiting for acquisition to complete...") - status = yield from bps.complete(jungfrau, group=JF_COMPLETE_GROUP) - - # StandardDetector.complete converts regular status to watchable status, - # but bluesky plan stubs can't see this currently - status = cast(WatchableAsyncStatus, status) - log_on_percentage_complete(status, "Jungfrau data collection triggers recieved", 10) - if wait: - yield from bps.wait(JF_COMPLETE_GROUP) - return status + @bpp.contingency_decorator(except_plan=lambda _: (yield from bps.unstage(jungfrau))) + def _fly_with_unstage_contingency(): + yield from bps.stage(jungfrau) + LOGGER.info("Setting up detector...") + yield from bps.prepare(jungfrau, trigger_info, wait=True) + LOGGER.info("Detector prepared. Starting acquisition") + yield from bps.kickoff(jungfrau, wait=True) + LOGGER.info("Waiting for acquisition to complete...") + status = yield from bps.complete(jungfrau, group=JF_COMPLETE_GROUP) + + # StandardDetector.complete converts regular status to watchable status, + # but bluesky plan stubs can't see this currently + status = cast(WatchableAsyncStatus, status) + log_on_percentage_complete( + status, "Jungfrau data collection triggers recieved", 10 + ) + if wait: + yield from bps.wait(JF_COMPLETE_GROUP) + return status + + return (yield from _fly_with_unstage_contingency()) # While we should generally use device instantiation to set the path, diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py index 20bc80efd9..170c90e068 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py @@ -1,10 +1,13 @@ import asyncio from functools import partial from pathlib import Path +from unittest.mock import AsyncMock import bluesky.plan_stubs as bps +import pytest from bluesky.preprocessors import run_decorator from bluesky.run_engine import RunEngine +from bluesky.utils import FailedStatus from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau from ophyd_async.core import ( TriggerInfo, @@ -22,6 +25,8 @@ def test_fly_jungfrau(RE: RunEngine, jungfrau: CommissioningJungfrau, tmp_path: Path): set_mock_value(jungfrau._writer.frame_counter, 10) + mock_stop = AsyncMock() + jungfrau.drv.acquisition_stop.trigger = mock_stop @run_decorator() def _open_run_and_fly(): @@ -39,6 +44,23 @@ def _open_run_and_fly(): assert (yield from bps.rd(jungfrau._writer.file_path)) == f"{tmp_path}/00001" RE(_open_run_and_fly()) + assert mock_stop.await_count == 2 # once when staging, once after run complete + + +def test_fly_jungfrau_stops_if_exception_after_stage( + RE: RunEngine, jungfrau: CommissioningJungfrau +): + mock_stop = AsyncMock() + jungfrau.drv.acquisition_stop.trigger = mock_stop + bad_trigger_info = TriggerInfo() + + @run_decorator() + def do_fly(): + yield from fly_jungfrau(jungfrau, bad_trigger_info) + + with pytest.raises(FailedStatus): + RE(do_fly()) + assert mock_stop.await_count == 2 # once when staging, once on exception async def test_override_file_name(jungfrau: CommissioningJungfrau, RE: RunEngine): From 15416c0ea928141c1930c7338a8699d3839e098d Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Mon, 22 Sep 2025 16:01:56 +0000 Subject: [PATCH 22/64] Fix race condition in test --- .../beamlines/i24/jungfrau_commissioning/test_plan_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py index 170c90e068..8415f6c53d 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py @@ -1,5 +1,3 @@ -import asyncio -from functools import partial from pathlib import Path from unittest.mock import AsyncMock @@ -38,7 +36,7 @@ def _open_run_and_fly(): while not status.done: val += 1 set_mock_value(jungfrau._writer.frame_counter, val) - yield from bps.wait_for([partial(asyncio.sleep, 0)]) + yield from bps.sleep(0.001) yield from bps.wait(JF_COMPLETE_GROUP) assert val == frames assert (yield from bps.rd(jungfrau._writer.file_path)) == f"{tmp_path}/00001" From 824be80d0d5c21d329bc1015dc63584d2af97d49 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Wed, 24 Sep 2025 09:21:32 +0000 Subject: [PATCH 23/64] Fix override_file_path --- .../do_external_acquisition.py | 4 ++-- .../do_internal_acquisition.py | 4 ++-- .../i24/jungfrau_commissioning/plan_utils.py | 20 +++++++++++++------ .../jungfrau_commissioning/test_plan_utils.py | 15 ++++++++++---- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py index e2eec9f14c..d336089d2d 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py @@ -11,7 +11,7 @@ from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( fly_jungfrau, - override_file_name, + override_file_path, ) @@ -37,7 +37,7 @@ def do_external_acquisition( """ if output_file_name: - override_file_name(jungfrau, output_file_name) + override_file_path(jungfrau, output_file_name) trigger_info = create_jungfrau_external_triggering_info(total_triggers, exp_time_s) status = yield from fly_jungfrau(jungfrau, trigger_info, wait) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py index 608f7f5711..e97eb61135 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py @@ -11,7 +11,7 @@ from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( fly_jungfrau, - override_file_name, + override_file_path, ) @@ -39,7 +39,7 @@ def do_internal_acquisition( """ if path_of_output_file: - override_file_name(jungfrau, path_of_output_file) + override_file_path(jungfrau, path_of_output_file) trigger_info = create_jungfrau_internal_triggering_info(total_frames, exp_time_s) status = yield from fly_jungfrau(jungfrau, trigger_info, wait) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py index 150940c2a3..dca5911241 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py @@ -1,3 +1,4 @@ +from pathlib import PurePath from typing import cast import bluesky.plan_stubs as bps @@ -7,6 +8,7 @@ from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau from ophyd_async.core import ( StaticFilenameProvider, + StaticPathProvider, TriggerInfo, WatchableAsyncStatus, ) @@ -55,9 +57,15 @@ def _fly_with_unstage_contingency(): return (yield from _fly_with_unstage_contingency()) -# While we should generally use device instantiation to set the path, -# this will be useful during commissioning -def override_file_name(jungfrau: CommissioningJungfrau, file_name: str): - jungfrau.provider._filename_provider = StaticFilenameProvider(file_name) # noqa: SLF001 - jungfrau._writer._path_info().filename = file_name # noqa: SLF001 - yield from bps.abs_set(jungfrau._writer.file_name, file_name) # noqa: SLF001 +def override_file_path(jungfrau: CommissioningJungfrau, path_of_output_file: str): + """While we should generally use device instantiation to set the path, + during commissioning, it is useful to be able to explicitly set the filename + and path. + + This function must be called before the Jungfrau is prepared. + """ + _file_path = PurePath(path_of_output_file) + _new_filename_provider = StaticFilenameProvider(_file_path.name) + jungfrau._writer._path_info = StaticPathProvider( # noqa: SLF001 + _new_filename_provider, _file_path.parent + ) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py index 8415f6c53d..d6870a0430 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py @@ -17,7 +17,7 @@ from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( JF_COMPLETE_GROUP, fly_jungfrau, - override_file_name, + override_file_path, ) @@ -39,7 +39,7 @@ def _open_run_and_fly(): yield from bps.sleep(0.001) yield from bps.wait(JF_COMPLETE_GROUP) assert val == frames - assert (yield from bps.rd(jungfrau._writer.file_path)) == f"{tmp_path}/00001" + assert (yield from bps.rd(jungfrau._writer.file_path)) == f"{tmp_path}/00000" RE(_open_run_and_fly()) assert mock_stop.await_count == 2 # once when staging, once after run complete @@ -61,7 +61,14 @@ def do_fly(): assert mock_stop.await_count == 2 # once when staging, once on exception -async def test_override_file_name(jungfrau: CommissioningJungfrau, RE: RunEngine): +async def test_override_file_path( + jungfrau: CommissioningJungfrau, RE: RunEngine, tmp_path: Path +): new_file_name = "test_file_name" - RE(override_file_name(jungfrau, new_file_name)) + new_path = f"{tmp_path}/{new_file_name}" + override_file_path(jungfrau, new_path) + assert await jungfrau._writer.file_name.get_value() == "" + assert await jungfrau._writer.file_path.get_value() == "" + await jungfrau._writer.open("") assert await jungfrau._writer.file_name.get_value() == new_file_name + assert await jungfrau._writer.file_path.get_value() == str(tmp_path) From 020d5fe60e02332978333bb5277dc3b35974fae4 Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Wed, 24 Sep 2025 10:35:40 +0100 Subject: [PATCH 24/64] dodal pin --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a21ed84b0b..6fef59abd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "ophyd >= 1.10.5", "ophyd-async >= 0.10.0a2", "bluesky >= 1.13.1", - "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@872f7e19df76d75a55c5f8b10e3bf4bb90aba5a8", + "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@4fc18977c54cbe77e8aed8aa6c58402049cb385b", ] From 92874c717d9e3e02c8ab1a768f155a5bac65a82b Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Thu, 25 Sep 2025 08:08:32 +0000 Subject: [PATCH 25/64] Fix test --- .../i24/jungfrau_commissioning/test_plan_utils.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py index d6870a0430..2b5a4a8aa8 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py @@ -1,3 +1,4 @@ +import asyncio from pathlib import Path from unittest.mock import AsyncMock @@ -21,7 +22,9 @@ ) -def test_fly_jungfrau(RE: RunEngine, jungfrau: CommissioningJungfrau, tmp_path: Path): +async def test_fly_jungfrau( + RE: RunEngine, jungfrau: CommissioningJungfrau, tmp_path: Path +): set_mock_value(jungfrau._writer.frame_counter, 10) mock_stop = AsyncMock() jungfrau.drv.acquisition_stop.trigger = mock_stop @@ -36,12 +39,13 @@ def _open_run_and_fly(): while not status.done: val += 1 set_mock_value(jungfrau._writer.frame_counter, val) - yield from bps.sleep(0.001) + yield from bps.sleep(0) yield from bps.wait(JF_COMPLETE_GROUP) assert val == frames assert (yield from bps.rd(jungfrau._writer.file_path)) == f"{tmp_path}/00000" RE(_open_run_and_fly()) + await asyncio.sleep(0) assert mock_stop.await_count == 2 # once when staging, once after run complete From 53dffe2237bf3fdddfd1a0a4b98e1bcc858eb5a0 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Thu, 25 Sep 2025 08:22:04 +0000 Subject: [PATCH 26/64] unpin dodal --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6fef59abd0..2e38c80ef3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "ophyd >= 1.10.5", "ophyd-async >= 0.10.0a2", "bluesky >= 1.13.1", - "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@4fc18977c54cbe77e8aed8aa6c58402049cb385b", + "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git", ] From 7ea94c99c11b81b0adba62766bda591db86f60e7 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Thu, 25 Sep 2025 09:04:27 +0000 Subject: [PATCH 27/64] Maybe fix test again --- .../beamlines/i24/jungfrau_commissioning/test_plan_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py index 2b5a4a8aa8..7f911f9f3e 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py @@ -45,7 +45,7 @@ def _open_run_and_fly(): assert (yield from bps.rd(jungfrau._writer.file_path)) == f"{tmp_path}/00000" RE(_open_run_and_fly()) - await asyncio.sleep(0) + await asyncio.sleep(0.01) assert mock_stop.await_count == 2 # once when staging, once after run complete From 370139409094dadb67b60f3af89950026a26301b Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Thu, 25 Sep 2025 10:11:43 +0100 Subject: [PATCH 28/64] Add non-zero sleep to test --- .../beamlines/i24/jungfrau_commissioning/test_plan_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py index 7f911f9f3e..18557ab89e 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py @@ -1,4 +1,3 @@ -import asyncio from pathlib import Path from unittest.mock import AsyncMock @@ -39,13 +38,12 @@ def _open_run_and_fly(): while not status.done: val += 1 set_mock_value(jungfrau._writer.frame_counter, val) - yield from bps.sleep(0) + yield from bps.sleep(0.001) yield from bps.wait(JF_COMPLETE_GROUP) assert val == frames assert (yield from bps.rd(jungfrau._writer.file_path)) == f"{tmp_path}/00000" RE(_open_run_and_fly()) - await asyncio.sleep(0.01) assert mock_stop.await_count == 2 # once when staging, once after run complete From e8286219c126b4d169411ff8f71586ce7e86d49c Mon Sep 17 00:00:00 2001 From: Oliver Silvester Date: Thu, 25 Sep 2025 13:07:02 +0100 Subject: [PATCH 29/64] Revert changes to vscode workspace settings --- .vscode/mx-bluesky-dev-container.code-workspace | 5 +---- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.vscode/mx-bluesky-dev-container.code-workspace b/.vscode/mx-bluesky-dev-container.code-workspace index 7e49e5fbc7..bf54d51dcd 100644 --- a/.vscode/mx-bluesky-dev-container.code-workspace +++ b/.vscode/mx-bluesky-dev-container.code-workspace @@ -6,14 +6,11 @@ { "path": "../../dodal" }, - { - "path": "../../ophyd-async" - } ], "settings": { "python.languageServer": "Pylance", "[python]": { - "editor.defaultFormatter": "charliermarsh.ruff" + "editor.defaultFormatter": "ms-python.black-formatter" }, "python.defaultInterpreterPath": "venv/bin/python", "python.analysis.extraPaths": [ diff --git a/pyproject.toml b/pyproject.toml index 2e38c80ef3..9e49d02e0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "ophyd >= 1.10.5", "ophyd-async >= 0.10.0a2", "bluesky >= 1.13.1", - "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git", + "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@main", ] From 0bcf53606cf20c08b9bc56db3397ae54a66877e9 Mon Sep 17 00:00:00 2001 From: Dominic Oram Date: Thu, 25 Sep 2025 17:44:32 +0100 Subject: [PATCH 30/64] Wait for JF to unstage --- .../beamlines/i24/jungfrau_commissioning/plan_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py index dca5911241..139bdd172c 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py @@ -34,7 +34,9 @@ def fly_jungfrau( wait: Optionally block until data collection is complete. """ - @bpp.contingency_decorator(except_plan=lambda _: (yield from bps.unstage(jungfrau))) + @bpp.contingency_decorator( + except_plan=lambda _: (yield from bps.unstage(jungfrau, wait=True)) + ) def _fly_with_unstage_contingency(): yield from bps.stage(jungfrau) LOGGER.info("Setting up detector...") From dbef0cf680ac2086b568312a451da9c1b6a94055 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 26 Sep 2025 10:18:12 +0000 Subject: [PATCH 31/64] Update plan for comissioning jungfrau device --- .../i24/jungfrau_commissioning/do_darks.py | 91 ++++--------------- .../i24/jungfrau_commissioning/plan_utils.py | 8 +- .../jungfrau_commissioning/test_do_darks.py | 70 +++++--------- 3 files changed, 44 insertions(+), 125 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py index 661b21e78e..521a740f26 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py @@ -1,31 +1,29 @@ -import asyncio -from functools import partial - import bluesky.plan_stubs as bps import bluesky.preprocessors as bpp from bluesky.utils import MsgGenerator from dodal.common import inject +from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau from ophyd_async.core import WatchableAsyncStatus from ophyd_async.fastcs.jungfrau import ( AcquisitionType, GainMode, - Jungfrau, - create_jungfrau_internal_triggering_info, + PedestalMode, create_jungfrau_pedestal_triggering_info, ) from pydantic import PositiveInt from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( fly_jungfrau, - override_file_name_and_path, + override_file_path, ) +from mx_bluesky.common.utils.log import LOGGER def do_pedestal_darks( exp_time_s: float = 0.001, pedestal_frames: PositiveInt = 20, pedestal_loops: PositiveInt = 200, - jungfrau: Jungfrau = inject("jungfrau"), + jungfrau: CommissioningJungfrau = inject("jungfrau"), path_of_output_file: str | None = None, wait: bool = False, ) -> MsgGenerator[WatchableAsyncStatus]: @@ -42,11 +40,8 @@ def do_pedestal_darks( wait: Optionally block until data collection is complete. """ - prev_acq_type = yield from bps.rd(jungfrau.drv.acquisition_type) - prev_pedestal_mode = yield from bps.rd(jungfrau.drv.pedestal_mode) - if path_of_output_file: - override_file_name_and_path(jungfrau, path_of_output_file) + override_file_path(jungfrau, path_of_output_file) yield from bps.mv( jungfrau.drv.acquisition_type, @@ -55,82 +50,28 @@ def do_pedestal_darks( GainMode.DYNAMIC, ) - yield from bps.wait_for([partial(asyncio.sleep, 0.5)]) - trigger_info = create_jungfrau_pedestal_triggering_info( exp_time_s, pedestal_frames, pedestal_loops ) - # Revert pedestal soft signal and pedestal hard signal to whatever they were before running the plan - def _revert_acq_type_and_gain(): - yield from bps.mv( - jungfrau.drv.acquisition_type, - prev_acq_type, - jungfrau.drv.pedestal_mode, - prev_pedestal_mode, - ) - - @bpp.finalize_decorator(final_plan=lambda: _revert_acq_type_and_gain()) - def _fly_then_revert_acquisition_type_and_gain(): + @bpp.finalize_decorator(final_plan=lambda: _revert_pedestal_mode(jungfrau)) + def _fly_then_revert_acquisition_type(): status = yield from fly_jungfrau( jungfrau, trigger_info, wait, - log_on_percentage_message="Jungfrau pedestal dynamic gain mode darks triggers recieved", + log_on_percentage_prefix="Jungfrau pedestal dynamic gain mode darks triggers recieved", ) return status - return (yield from _fly_then_revert_acquisition_type_and_gain()) - + return (yield from _fly_then_revert_acquisition_type()) -def do_darks_for_dynamic_gain_switching( - exp_time_s: float = 0.001, - triggers_per_dark_scan: PositiveInt = 1000, - jungfrau: Jungfrau = inject("jungfrau"), - path_of_output_file: str | None = None, -) -> MsgGenerator: - """Internally take a set of images at dynamic gain, forced gain 1, and forced gain 2. - Blocks until all 3 collections are complete. - - Args: - exp_time_s: Length of detector exposure for each frame. - triggers_per_dark_scan: Number of frames acquired for each of the 3 dark scans. - jungfrau: Jungfrau device - path_of_output_file: Absolute path of the detector file output, including file name. If None, then use the PathProvider - set during Jungfrau device instantiation - """ - - wait = True - - if path_of_output_file: - override_file_name_and_path(jungfrau, path_of_output_file) - - trigger_info = create_jungfrau_internal_triggering_info( - triggers_per_dark_scan, exp_time_s - ) +def _revert_pedestal_mode(jungfrau: CommissioningJungfrau): + LOGGER.info("Moving Jungfrau out of pedestal mode...") yield from bps.mv( - jungfrau.drv.gain_mode, - GainMode.DYNAMIC, - ) - - yield from fly_jungfrau( - jungfrau, - trigger_info, - wait, - log_on_percentage_message=f"Jungfrau {GainMode.DYNAMIC} gain mode darks triggers recieved", - ) - yield from bps.mv(jungfrau.drv.gain_mode, GainMode.FORCE_SWITCH_G1) - yield from fly_jungfrau( - jungfrau, - trigger_info, - wait, - log_on_percentage_message=f"Jungfrau {GainMode.FORCE_SWITCH_G1} gain mode darks triggers recieved", - ) - yield from bps.mv(jungfrau.drv.gain_mode, GainMode.FORCE_SWITCH_G2) - yield from fly_jungfrau( - jungfrau, - trigger_info, - wait, - log_on_percentage_message=f"Jungfrau {GainMode.FORCE_SWITCH_G2} gain mode darks triggers recieved", + jungfrau.drv.acquisition_type, + AcquisitionType.STANDARD, + jungfrau.drv.pedestal_mode_state, + PedestalMode.OFF, ) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py index e3bd4e511f..a888dbdedb 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py @@ -19,7 +19,10 @@ def fly_jungfrau( - jungfrau: CommissioningJungfrau, trigger_info: TriggerInfo, wait: bool = False + jungfrau: CommissioningJungfrau, + trigger_info: TriggerInfo, + wait: bool = False, + log_on_percentage_prefix="Jungfrau data collection triggers recieved", ) -> MsgGenerator[WatchableAsyncStatus]: """Stage, prepare, and kickoff Jungfrau with a configured TriggerInfo. Optionally wait for completion. @@ -29,9 +32,10 @@ def fly_jungfrau( Args: jungfrau: Jungfrau device. - trigger_info: TriggerInfo which should be acquired using jungfrau util functions create_jungfrau_internal_triggering_info + trigger_info: TriggerInfo which should be acquired using jungfrau util functions create_jungfrau_internal_triggering_info. or create_jungfrau_external_triggering_info. wait: Optionally block until data collection is complete. + log_on_percentage_prefix: String that will be appended to the "percentage completion" logging message. """ @bpp.contingency_decorator( diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py index a6c4156e3d..4e99019132 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py @@ -6,21 +6,27 @@ from bluesky.callbacks import CallbackBase from bluesky.preprocessors import monitor_during_wrapper, run_decorator from bluesky.run_engine import RunEngine +from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau from ophyd_async.fastcs.jungfrau import ( AcquisitionType, GainMode, - Jungfrau, PedestalMode, ) from ophyd_async.testing import set_mock_value from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks import ( - do_darks_for_dynamic_gain_switching, do_pedestal_darks, ) class CheckMonitor(CallbackBase): + """Store the order and values of updates to specified signals + + Usage: Instantiate this callback with list of signals to track, and subscribe the RE to this + callback. Run your plan using Bluesky's monitor_during decorator or wrapper, specifing the same signals + in the monitor. + """ + def __init__(self, signals_to_track: list[str]): self.signals_and_values = {signal: [] for signal in signals_to_track} @@ -30,11 +36,9 @@ def event(self, doc): return doc -@patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks.override_file_name_and_path" -) +@patch("mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks.override_file_path") async def test_full_do_pedestal_darks( - mock_override_path: MagicMock, jungfrau: Jungfrau, RE: RunEngine + mock_override_path: MagicMock, jungfrau: CommissioningJungfrau, RE: RunEngine ): # Test that plan succeeds in RunEngine and pedestal-specific signals are changed as expected test_path = "path" @@ -46,18 +50,19 @@ def test_plan(): val = 0 while not status.done: val += 1 - set_mock_value(jungfrau._writer._drv.num_captured, val) + set_mock_value(jungfrau._writer.frame_counter, val) # Let status update yield from bps.wait_for([partial(asyncio.sleep, 0)]) jungfrau._controller.arm = AsyncMock() assert await jungfrau.drv.acquisition_type.get_value() == AcquisitionType.STANDARD await jungfrau.drv.gain_mode.set(GainMode.FIX_G2) - await jungfrau.drv.pedestal_mode.set(PedestalMode.OFF) + await jungfrau.drv.pedestal_mode_state.set(PedestalMode.OFF) monitor_tracker = CheckMonitor( [ - "jungfrau-drv-acquisition_type", - "jungfrau-drv-pedestal_mode", + "detector-drv-acquisition_type", + "detector-drv-pedestal_mode_state", + "detector-drv-gain_mode", ] ) RE.subscribe(monitor_tracker) @@ -66,56 +71,25 @@ def test_plan(): test_plan(), [ jungfrau.drv.acquisition_type, + jungfrau.drv.pedestal_mode_state, jungfrau.drv.gain_mode, - jungfrau.drv.pedestal_mode, ], ) ) - assert monitor_tracker.signals_and_values["jungfrau-drv-acquisition_type"] == [ + assert monitor_tracker.signals_and_values["detector-drv-acquisition_type"] == [ AcquisitionType.STANDARD, AcquisitionType.PEDESTAL, AcquisitionType.STANDARD, ] - assert monitor_tracker.signals_and_values["jungfrau-drv-pedestal_mode"] == [ + assert monitor_tracker.signals_and_values["detector-drv-pedestal_mode_state"] == [ PedestalMode.OFF, PedestalMode.ON, PedestalMode.OFF, ] - mock_override_path.assert_called_once_with(jungfrau, test_path) - - -@patch("mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks.fly_jungfrau") -async def test_full_do_darks_for_dynamic_gain_switching( - mock_do_darks: MagicMock, - jungfrau: Jungfrau, - RE: RunEngine, -): - monitor_tracker = CheckMonitor( - [ - "jungfrau-drv-gain_mode", - ] - ) - RE.subscribe(monitor_tracker) - @run_decorator() - def test_plan(): - yield from do_darks_for_dynamic_gain_switching( - triggers_per_dark_scan=5, jungfrau=jungfrau, path_of_output_file="test" - ) - - jungfrau._controller.arm = AsyncMock() - RE( - monitor_during_wrapper( - test_plan(), - [ - jungfrau.drv.gain_mode, - ], - ) - ) - assert monitor_tracker.signals_and_values["jungfrau-drv-gain_mode"] == [ - GainMode.DYNAMIC, + # pedestal darks plan leaves gain mode in dynamic + assert monitor_tracker.signals_and_values["detector-drv-gain_mode"] == [ + GainMode.FIX_G2, GainMode.DYNAMIC, - GainMode.FORCE_SWITCH_G1, - GainMode.FORCE_SWITCH_G2, ] - assert mock_do_darks.call_count == 3 + mock_override_path.assert_called_once_with(jungfrau, test_path) From 5f8a352ab543aa73f02d001543499940637f9f64 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Fri, 26 Sep 2025 10:40:51 +0000 Subject: [PATCH 32/64] Improve docstring to explain pedestal mode --- .../beamlines/i24/jungfrau_commissioning/do_darks.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py index 521a740f26..1e7d1603c9 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py @@ -30,6 +30,14 @@ def do_pedestal_darks( """Acquire darks in pedestal mode, using dynamic gain mode. This calibrates the offsets for the jungfrau, and must be performed before acquiring real data in dynamic gain mode. + When Bluesky triggers the detector in pedestal mode, with pedestal frames F and pedestal loops L, + the acquisition is managed at the driver level to: + 1. Acquire F-1 frames in dynamic gain mode + 2. Acquire 1 frame in ForceSwitchG1 gain mode + 3. Repeat steps 1-2 L times + 4. Do the first three steps a second time, except use ForceSwitchG2 instead of ForceSwitchG1 + during step 2. + Args: exp_time_s: Length of detector exposure for each frame. pedestal_frames: Number of frames acquired per pedestal loop. From 5d7624da152b0f138a8db15aee3fa612219bd3a5 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Fri, 26 Sep 2025 10:43:41 +0000 Subject: [PATCH 33/64] Add another comment --- .../beamlines/i24/jungfrau_commissioning/test_do_darks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py index 4e99019132..10ac7fd774 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py @@ -87,7 +87,8 @@ def test_plan(): PedestalMode.OFF, ] - # pedestal darks plan leaves gain mode in dynamic + # When using the real detector, the switching of gain mode is a bit more complicated, + # see the docstring for the do_pedestal_darks plan. assert monitor_tracker.signals_and_values["detector-drv-gain_mode"] == [ GainMode.FIX_G2, GainMode.DYNAMIC, From d4bf17db7d68ba26579aab43ec216fed452b5dae Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Fri, 26 Sep 2025 16:45:49 +0000 Subject: [PATCH 34/64] Implement revised jungfrau rotation scans --- .../callbacks/metadata_writer.py | 23 ++++--- .../i24/jungfrau_commissioning/composites.py | 8 +-- .../rotation_scan_plan.py | 68 +++++++++++++------ .../jungfrau_commissioning/utility_plans.py | 25 +------ .../beamlines/i24/parameters/constants.py | 6 ++ 5 files changed, 73 insertions(+), 57 deletions(-) create mode 100644 src/mx_bluesky/beamlines/i24/parameters/constants.py diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py index f19664e31f..2ed639e895 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py @@ -3,8 +3,7 @@ from bluesky.callbacks import CallbackBase -from mx_bluesky.beamlines.i24.jungfrau_commissioning.utility_plans import METADATA_READ -from mx_bluesky.common.parameters.constants import PlanNameConstants +from mx_bluesky.beamlines.i24.parameters.constants import PlanNameConstants from mx_bluesky.common.parameters.rotation import SingleRotationScan from mx_bluesky.common.utils.log import LOGGER @@ -25,8 +24,8 @@ class JsonMetadataWriter(CallbackBase): """ - def __init__(self, beam_xy: tuple[float, float]): - self.beam_xy = beam_xy + def __init__(self): + self.beam_xy = None self.wavelength_in_a = None self.energy_in_kev = None self.detector_distance_mm = None @@ -40,7 +39,7 @@ def __init__(self, beam_xy: tuple[float, float]): transmission: float | None = None def start(self, doc: dict): # type: ignore - if doc.get("subplan_name") == PlanNameConstants.ROTATION_MAIN: + if doc.get("subplan_name") == PlanNameConstants.ROTATION_META_READ: LOGGER.info( "Metadata writer recieved start document with experiment parameters." ) @@ -53,20 +52,24 @@ def descriptor(self, doc: dict): # type: ignore self.descriptors[doc["uid"]] = doc def event(self, doc: dict): # type: ignore - LOGGER.info("Nexus handler received event document.") event_descriptor = self.descriptors[doc["descriptor"]] - if event_descriptor.get("name") == METADATA_READ: + if event_descriptor.get("name") == PlanNameConstants.ROTATION_META_READ: assert self.parameters is not None data = doc.get("data") assert data is not None self.wavelength_in_a = data.get("dcm-wavelength_in_a") self.energy_in_kev = data.get("dcm-energy_in_kev") - self.detector_distance_mm = data.get("det_stage-z") + self.detector_distance_mm = data.get("det-stage_z") + + if self.detector_distance_mm: + self.beam_xy = self.parameters.detector_params.get_beam_position_mm( + self.detector_distance_mm + ) + LOGGER.info( - f"Nexus handler received beam parameters, transmission: {self.transmission}, flux: {self.flux}, wavelength: {self.wavelength}, det distance: {self.detector_distance_mm}, beam_xy: {self.beam_xy}" + f"Metadata writer received parameters, transmission: {self.transmission}, flux: {self.flux}, wavelength: {self.wavelength}, det distance: {self.detector_distance_mm}, beam_xy: {self.beam_xy}" ) - LOGGER.info("") def stop(self, doc: dict): # type: ignore if ( diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/composites.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/composites.py index 55c7b88a0d..39a669572f 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/composites.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/composites.py @@ -5,6 +5,7 @@ from dodal.devices.hutch_shutter import HutchShutter from dodal.devices.i24.aperture import Aperture from dodal.devices.i24.beamstop import Beamstop +from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau from dodal.devices.i24.dcm import DCM from dodal.devices.i24.dual_backlight import DualBacklight from dodal.devices.i24.vgonio import VerticalGoniometer @@ -13,9 +14,6 @@ from dodal.devices.xbpm_feedback import XBPMFeedback from dodal.devices.zebra.zebra import Zebra from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter -from ophyd_async.fastcs.jungfrau import ( - Jungfrau, -) @pydantic.dataclasses.dataclass(config={"arbitrary_types_allowed": True}) @@ -24,7 +22,7 @@ class RotationScanComposite: aperture: Aperture attenuator: EnumFilterAttenuator - jungfrau: Jungfrau + jungfrau: CommissioningJungfrau gonio: VerticalGoniometer synchrotron: Synchrotron sample_shutter: ZebraShutter @@ -32,6 +30,6 @@ class RotationScanComposite: xbpm_feedback: XBPMFeedback hutch_shutter: HutchShutter beamstop: Beamstop - det_stage: YZStage # TODO add JF position to det stage device + det_stage: YZStage backlight: DualBacklight dcm: DCM diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py index 0077433359..6e214915c1 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py @@ -1,12 +1,15 @@ from __future__ import annotations +from functools import partial + import bluesky.plan_stubs as bps import bluesky.preprocessors as bpp from dodal.devices.hutch_shutter import ShutterState from dodal.devices.i24.aperture import AperturePositions from dodal.devices.i24.beamstop import BeamstopPositions +from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau from dodal.devices.i24.dual_backlight import BacklightPositions -from dodal.devices.zebra.zebra import Zebra +from dodal.devices.zebra.zebra import I24Axes, Zebra from dodal.plan_stubs.check_topup import check_topup_and_wait_if_necessary from ophyd_async.fastcs.jungfrau import ( create_jungfrau_external_triggering_info, @@ -21,11 +24,14 @@ from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( JF_COMPLETE_GROUP, fly_jungfrau, - override_file_name_and_path, + override_file_path, ) from mx_bluesky.beamlines.i24.jungfrau_commissioning.utility_plans import ( read_devices_for_metadata, ) +from mx_bluesky.beamlines.i24.parameters.constants import ( + PlanNameConstants as I24PlanNameConstants, +) from mx_bluesky.beamlines.i24.serial.setup_beamline.setup_zebra_plans import ( disarm_zebra, ) @@ -49,12 +55,13 @@ EXPERIMENT_PARAM_DUMP_FILENAME = "experiment_params.json" READING_DUMP_FILENAME = "collection_info.json" -JF_DET_STAGE_Y_POSITION = 0 # TODO find out what this is! +JF_DET_STAGE_Y_POSITION_MM = 730 def set_up_beamline_for_rotation(composite: RotationScanComposite, det_z_mm: float): """Check hutch is open, move backlight in, then, in parallel, - move aperture in, move backlight out and move det stage in""" + move aperture in, move backlight out and move det stage in. Wait for this parallel + move to finish.""" hutch_shutter_state: ShutterState = yield from bps.rd( composite.hutch_shutter.status @@ -66,6 +73,9 @@ def set_up_beamline_for_rotation(composite: RotationScanComposite, det_z_mm: flo LOGGER.info("Making sure backlight is moved out...") yield from bps.mv(composite.backlight.backlight_position, BacklightPositions.OUT) + # Remove sleep once we confirm we get feedback on backlight out here + yield from bps.sleep(20) + LOGGER.info( "Making sure aperture and beamstop are in, detector stage is in position, and detector distance is correct." ) @@ -75,7 +85,7 @@ def set_up_beamline_for_rotation(composite: RotationScanComposite, det_z_mm: flo composite.beamstop.pos_select, BeamstopPositions.DATA_COLLECTION, composite.det_stage.y, - JF_DET_STAGE_Y_POSITION, + JF_DET_STAGE_Y_POSITION_MM, composite.det_stage.z, ) @@ -88,17 +98,22 @@ def single_rotation_plan( about a fixed axis - for now this axis is limited to omega. Needs additional setup of the sample environment and a wrapper to clean up.""" - # This should be somewhere more sensible - like in the parameter model + # TODO This should be somewhere more sensible - like in the parameter model if not params.detector_distance_mm: raise ValueError("Must specify detector distance in mm") yield from set_up_beamline_for_rotation(composite, params.detector_distance_mm) - beam_xy = params.detector_params.get_beam_position_mm(params.detector_distance_mm) LOGGER.info( f"Moving detector Z stage to specified {params.detector_distance_mm} mm..." ) - # This can probably be done in parallel with other stuff, but will do wait for now until tested - yield from bps.mv(composite.det_stage.z, params.detector_distance_mm) + # TODO don't block on these moves, and wait on completion alongside waiting for jungfrau to prepare. + # make a separate group for JF prepare completion. + yield from bps.mv( + composite.det_stage.z, + params.detector_distance_mm, + composite.attenuator, + params.transmission_frac, + ) # This value isn't actually used, see https://github.com/DiamondLightSource/mx-bluesky/issues/1224 _motor_time_to_speed = 1 @@ -108,9 +123,6 @@ def single_rotation_plan( params, _motor_time_to_speed, _max_velocity_deg_s ) - metadata_writer = JsonMetadataWriter(beam_xy) - - @bpp.subs_decorator([metadata_writer]) @bpp.set_run_key_decorator(PlanNameConstants.ROTATION_MAIN) @bpp.run_decorator( md={ @@ -146,6 +158,7 @@ def _rotation_scan_plan( yield from setup_zebra_for_rotation( composite.zebra, composite.sample_shutter, + axis=I24Axes.OMEGA, start_angle=motion_values.start_scan_deg, scan_width=motion_values.scan_width_deg, direction=motion_values.direction, @@ -174,18 +187,34 @@ def _rotation_scan_plan( ops_time=10.0, # Additional time to account for rotation, is s ) # See #https://github.com/DiamondLightSource/hyperion/issues/932 - override_file_name_and_path( + override_file_path( composite.jungfrau, f"{params.storage_directory}/{params.detector_params.full_filename}", ) - yield from read_devices_for_metadata(composite) + # Write metadata json file + + metadata_writer = JsonMetadataWriter() + + @bpp.subs_decorator([metadata_writer]) + @bpp.set_run_key_decorator(I24PlanNameConstants.ROTATION_META_READ) + @bpp.run_decorator( + md={ + "subplan_name": I24PlanNameConstants.ROTATION_META_READ, + "scan_points": [params.scan_points], + "rotation_scan_params": params.model_dump_json(), + } + ) + def _do_read(): + yield from read_devices_for_metadata(composite) + + yield from _do_read() yield from fly_jungfrau( composite.jungfrau, _jf_trigger_info, wait=False, - log_on_percentage_message="Jungfrau rotation scan triggers received", + log_on_percentage_prefix="Jungfrau rotation scan triggers received", ) LOGGER.info("Executing rotation scan") @@ -193,17 +222,18 @@ def _rotation_scan_plan( yield from bps.wait(group=JF_COMPLETE_GROUP) - # TODO check bluesky doesnt already do this for us - yield from bpp.contingency_wrapper( + yield from bpp.finalize_wrapper( _rotation_scan_plan(motion_values, composite), - except_plan=lambda _: (yield from bps.unstage(composite.jungfrau)), + final_plan=partial(_cleanup_plan, composite.zebra, composite.jungfrau), ) yield from _rotation_scan_plan(motion_values, composite) print("test") -def cleanup_plan(zebra: Zebra, group="cleanup"): +def _cleanup_plan(zebra: Zebra, jf: CommissioningJungfrau, group="cleanup"): + LOGGER.info("Tidying up zebra and Jungfrau...") + yield from bps.unstage(jf) yield from bps.abs_set(zebra.inputs.soft_in_1, 0, group=group) yield from disarm_zebra(zebra) yield from bps.wait("cleanup") diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/utility_plans.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/utility_plans.py index 7267f99644..63ab8428d0 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/utility_plans.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/utility_plans.py @@ -1,34 +1,13 @@ import bluesky.plan_stubs as bps -from dodal.devices.attenuator.attenuator import EnumFilterAttenuator from mx_bluesky.beamlines.i24.jungfrau_commissioning.composites import ( RotationScanComposite, ) -from mx_bluesky.common.utils.log import LOGGER - -METADATA_READ = "metadata read" - - -# Long term this should be done by adding a set function to the attenuator device. -# See https://github.com/DiamondLightSource/dodal/issues/972 -def set_transmission( - set_attenuator: EnumFilterAttenuator, transmission_fraction: float -): - LOGGER.info(f"Setting transmission to {transmission_fraction:.3f}") - yield from bps.abs_set( - set_attenuator.transmission_setpoint, transmission_fraction, wait=True - ) - f1_inpos = yield from bps.rd(set_attenuator.filters[0]) - f2_inpos = yield from bps.rd(set_attenuator.filters[1]) - while not (f1_inpos and f2_inpos): - LOGGER.info(f"Waiting for filters: {f1_inpos=}, {f2_inpos=}...") - f1_inpos = yield from bps.rd(set_attenuator.filters[0]) - f2_inpos = yield from bps.rd(set_attenuator.filters[1]) - yield from bps.sleep(0.5) +from mx_bluesky.beamlines.i24.parameters.constants import PlanNameConstants def read_devices_for_metadata(composite: RotationScanComposite): - yield from bps.create(METADATA_READ) + yield from bps.create(PlanNameConstants.ROTATION_META_READ) yield from bps.read(composite.dcm.energy_in_kev) yield from bps.read(composite.dcm.wavelength_in_a) yield from bps.read(composite.det_stage.z) diff --git a/src/mx_bluesky/beamlines/i24/parameters/constants.py b/src/mx_bluesky/beamlines/i24/parameters/constants.py new file mode 100644 index 0000000000..14ca349581 --- /dev/null +++ b/src/mx_bluesky/beamlines/i24/parameters/constants.py @@ -0,0 +1,6 @@ +from pydantic.dataclasses import dataclass + + +@dataclass(frozen=True) +class PlanNameConstants: + ROTATION_META_READ = "ROTATION_META_READ" From a4b289f803f1126642a3a85cd5a6a499538bb0de Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Tue, 30 Sep 2025 16:06:37 +0000 Subject: [PATCH 35/64] Make zebra and shutter setup plans more generic --- pyproject.toml | 10 --- .../i04_grid_detect_then_xray_centre_plan.py | 15 ++-- .../setup_zebra_and_shutter.py} | 52 ++++++++++---- .../device_setup_plans/setup_zebra.py | 6 +- .../hyperion_flyscan_xray_centre_plan.py | 15 ++-- .../test_setup_zebra_and_shutter.py | 70 +++++++++++++++++++ .../device_setup_plans/test_zebra_setup.py | 50 ------------- 7 files changed, 132 insertions(+), 86 deletions(-) rename src/mx_bluesky/{phase1_zebra/device_setup_plans/setup_zebra.py => common/device_setup_plans/setup_zebra_and_shutter.py} (59%) create mode 100644 tests/unit_tests/common/device_setup_plans/test_setup_zebra_and_shutter.py diff --git a/pyproject.toml b/pyproject.toml index 9e49d02e0e..7e2b4b5341 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -254,13 +254,3 @@ name = "Beamlines do not import from hyperion" type = "forbidden" source_modules = "mx_bluesky.beamlines.**" forbidden_modules = "mx_bluesky.hyperion.**" - -[[tool.importlinter.contracts]] -name = "Only Hyperion and i04 can import from phase1_zebra" -type = "forbidden" -source_modules = ["mx_bluesky.beamlines.**", "mx_bluesky.common.**"] -forbidden_modules = ["mx_bluesky.phase1_zebra.**"] -ignore_imports = [ - "mx_bluesky.beamlines.i04.** -> mx_bluesky.phase1_zebra.**", - "mx_bluesky.hyperion.** -> mx_bluesky.phase1_zebra.**", -] diff --git a/src/mx_bluesky/beamlines/i04/experiment_plans/i04_grid_detect_then_xray_centre_plan.py b/src/mx_bluesky/beamlines/i04/experiment_plans/i04_grid_detect_then_xray_centre_plan.py index 3e8dc999c6..95c7eb8caa 100644 --- a/src/mx_bluesky/beamlines/i04/experiment_plans/i04_grid_detect_then_xray_centre_plan.py +++ b/src/mx_bluesky/beamlines/i04/experiment_plans/i04_grid_detect_then_xray_centre_plan.py @@ -33,6 +33,10 @@ verify_undulator_gap_before_run_decorator, ) +from mx_bluesky.common.device_setup_plans.setup_zebra_and_shutter import ( + setup_zebra_for_gridscan, + tidy_up_zebra_after_gridscan, +) from mx_bluesky.common.experiment_plans.common_flyscan_xray_centre_plan import ( BeamlineSpecificFGSFeatures, construct_beamline_specific_FGS_features, @@ -66,10 +70,6 @@ transmission_and_xbpm_feedback_for_collection_decorator, ) from mx_bluesky.common.utils.log import LOGGER -from mx_bluesky.phase1_zebra.device_setup_plans.setup_zebra import ( - setup_zebra_for_gridscan, - tidy_up_zebra_after_gridscan, -) # See https://github.com/DiamondLightSource/blueapi/issues/506 for using device composites @@ -244,7 +244,12 @@ def construct_i04_specific_features( ) fgs_motors = xrc_composite.zebra_fast_grid_scan return construct_beamline_specific_FGS_features( - setup_zebra_for_gridscan, + partial( + setup_zebra_for_gridscan, + zebra_output_to_disconnect=xrc_composite.zebra.output.out_pvs[ + xrc_composite.zebra.mapping.outputs.TTL_XSPRESS3 + ], + ), tidy_plan, set_flyscan_params_plan, fgs_motors, diff --git a/src/mx_bluesky/phase1_zebra/device_setup_plans/setup_zebra.py b/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py similarity index 59% rename from src/mx_bluesky/phase1_zebra/device_setup_plans/setup_zebra.py rename to src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py index 8caa3d8730..9fe21de48b 100644 --- a/src/mx_bluesky/phase1_zebra/device_setup_plans/setup_zebra.py +++ b/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py @@ -9,10 +9,17 @@ ZebraShutter, ZebraShutterControl, ) +from ophyd_async.core import SignalRW from mx_bluesky.common.parameters.constants import ZEBRA_STATUS_TIMEOUT from mx_bluesky.common.utils.log import LOGGER +"""Plans in this file will work as intended if the zebra has the following configuration: +- A fast shutter is connected through TTL inputs from the Zebra. +- When the zebra shutter is set to auto mode, the IOC sets the Zebra's SOFT_IN1 signal high. +- When the zebra shutter is set to manual mode, the IOC sets the Zebra's SOFT_IN1 signal low. +""" + @runtime_checkable class GridscanSetupDevices(Protocol): @@ -24,23 +31,33 @@ def setup_zebra_for_gridscan( composite: GridscanSetupDevices, # XRC gridscan's generic trigger setup expects a composite rather than individual devices group="setup_zebra_for_gridscan", wait=True, + ttl_input_for_detector_to_use: None | int = None, + zebra_output_to_disconnect: None | SignalRW = None, ) -> MsgGenerator: + """This plan assumes that the motion controller will send triggers to the zebra's IN4_TTL whenever the fast shutter + should open throughout the gridscan. + + If the Zebra has multiple detectors connected, you must manually specify which TTL input connects to your desired detector + in the ttl_input_for_detector_to_use parameter. + """ zebra = composite.zebra + ttl_detector = ttl_input_for_detector_to_use or zebra.mapping.outputs.TTL_DETECTOR # Set shutter to automatic and to trigger via motion controller GPIO signal (IN4_TTL) yield from configure_zebra_and_shutter_for_auto_shutter( zebra, composite.sample_shutter, zebra.mapping.sources.IN4_TTL, group=group ) yield from bps.abs_set( - zebra.output.out_pvs[zebra.mapping.outputs.TTL_DETECTOR], + zebra.output.out_pvs[ttl_detector], zebra.mapping.sources.IN3_TTL, group=group, ) - yield from bps.abs_set( - zebra.output.out_pvs[zebra.mapping.outputs.TTL_XSPRESS3], - zebra.mapping.sources.DISCONNECT, - group=group, - ) + + if zebra_output_to_disconnect: + yield from bps.abs_set( + zebra_output_to_disconnect, zebra.mapping.sources.DISCONNECT, group + ) + yield from bps.abs_set( zebra.output.pulse_1.input, zebra.mapping.sources.DISCONNECT, group=group ) @@ -51,7 +68,7 @@ def setup_zebra_for_gridscan( def set_shutter_auto_input(zebra: Zebra, input: int, group="set_shutter_trigger"): """Set the signal that controls the shutter. We use the second input to the - Zebra's AND2 gate for this input. ZebraShutter control mode must be in auto for this input to take control + Zebra's AND_GATE_FOR_AUTO_SHUTTER for this input. ZebraShutter control mode must be in auto for this input to take control For more details see the ZebraShutter device.""" auto_gate = zebra.mapping.AND_GATE_FOR_AUTO_SHUTTER @@ -66,10 +83,9 @@ def configure_zebra_and_shutter_for_auto_shutter( an input source. For the input, use one of the source constants in zebra.py When the shutter is in auto/manual, logic in EPICS sets the Zebra's - SOFT_IN1 to low/high respectively. The Zebra's AND2 gate should be used to control the shutter while in auto mode. - To do this, we need (AND2 = SOFT_IN1 AND input), where input is the zebra signal we want to control the shutter when in auto mode. + SOFT_IN1 to low/high respectively. The Zebra's AND_GATE_FOR_AUTO_SHUTTER should be used to control the shutter while in auto mode. + To do this, we need (AND_GATE_FOR_AUTO_SHUTTER = SOFT_IN1 AND input), where input is the zebra signal we want to control the shutter when in auto mode. """ - # See https://github.com/DiamondLightSource/dodal/issues/813 for better typing here. # Set shutter to auto mode yield from bps.abs_set( @@ -78,7 +94,7 @@ def configure_zebra_and_shutter_for_auto_shutter( auto_gate = zebra.mapping.AND_GATE_FOR_AUTO_SHUTTER - # Set first input of AND2 gate to SOFT_IN1, which is high when shutter is in auto mode + # Set first input of AND gate to SOFT_IN1, which is high when shutter is in auto mode # Note the Zebra should ALWAYS be setup this way. See https://github.com/DiamondLightSource/mx-bluesky/issues/551 yield from bps.abs_set( zebra.logic_gates.and_gates[auto_gate].sources[1], @@ -86,7 +102,7 @@ def configure_zebra_and_shutter_for_auto_shutter( group=group, ) - # Set the second input of AND2 gate to the requested zebra input source + # Set the second input of AND_GATE_FOR_AUTO_SHUTTER to the requested zebra input source yield from set_shutter_auto_input(zebra, input, group=group) @@ -95,11 +111,21 @@ def tidy_up_zebra_after_gridscan( zebra_shutter: ZebraShutter, group="tidy_up_zebra_after_gridscan", wait=True, + ttl_input_for_detector_to_use=None, ) -> MsgGenerator: + """ + Set the zebra back to a state which is expected by GDA + + If the Zebra has multiple detectors connected, you must manually specify which TTL input connects to your desired detector + in the ttl_input_for_detector_to_use parameter. + """ + LOGGER.info("Tidying up Zebra") + ttl_detector = ttl_input_for_detector_to_use or zebra.mapping.outputs.TTL_DETECTOR + yield from bps.abs_set( - zebra.output.out_pvs[zebra.mapping.outputs.TTL_DETECTOR], + zebra.output.out_pvs[ttl_detector], zebra.mapping.sources.PC_PULSE, group=group, ) diff --git a/src/mx_bluesky/hyperion/device_setup_plans/setup_zebra.py b/src/mx_bluesky/hyperion/device_setup_plans/setup_zebra.py index e2efd12a01..db0cf91017 100644 --- a/src/mx_bluesky/hyperion/device_setup_plans/setup_zebra.py +++ b/src/mx_bluesky/hyperion/device_setup_plans/setup_zebra.py @@ -11,11 +11,11 @@ ZebraShutterControl, ) -from mx_bluesky.common.parameters.constants import ZEBRA_STATUS_TIMEOUT -from mx_bluesky.common.utils.log import LOGGER -from mx_bluesky.phase1_zebra.device_setup_plans.setup_zebra import ( +from mx_bluesky.common.device_setup_plans.setup_zebra_and_shutter import ( configure_zebra_and_shutter_for_auto_shutter, ) +from mx_bluesky.common.parameters.constants import ZEBRA_STATUS_TIMEOUT +from mx_bluesky.common.utils.log import LOGGER def arm_zebra(zebra: Zebra): diff --git a/src/mx_bluesky/hyperion/experiment_plans/hyperion_flyscan_xray_centre_plan.py b/src/mx_bluesky/hyperion/experiment_plans/hyperion_flyscan_xray_centre_plan.py index e3990ddd79..18b377924e 100755 --- a/src/mx_bluesky/hyperion/experiment_plans/hyperion_flyscan_xray_centre_plan.py +++ b/src/mx_bluesky/hyperion/experiment_plans/hyperion_flyscan_xray_centre_plan.py @@ -10,6 +10,10 @@ set_fast_grid_scan_params, ) +from mx_bluesky.common.device_setup_plans.setup_zebra_and_shutter import ( + setup_zebra_for_gridscan, + tidy_up_zebra_after_gridscan, +) from mx_bluesky.common.experiment_plans.common_flyscan_xray_centre_plan import ( construct_beamline_specific_FGS_features, ) @@ -29,10 +33,6 @@ HyperionFlyScanXRayCentreComposite, ) from mx_bluesky.hyperion.parameters.gridscan import HyperionSpecifiedThreeDGridScan -from mx_bluesky.phase1_zebra.device_setup_plans.setup_zebra import ( - setup_zebra_for_gridscan, - tidy_up_zebra_after_gridscan, -) class SmargonSpeedException(Exception): @@ -78,7 +78,12 @@ def construct_hyperion_specific_features( fgs_motors = xrc_composite.panda_fast_grid_scan else: - setup_trigger_plan = setup_zebra_for_gridscan + setup_trigger_plan = partial( + setup_zebra_for_gridscan, + zebra_output_to_disconnect=xrc_composite.zebra.output.out_pvs[ + xrc_composite.zebra.mapping.outputs.TTL_XSPRESS3 + ], + ) tidy_plan = partial( tidy_up_zebra_after_gridscan, xrc_composite.zebra, diff --git a/tests/unit_tests/common/device_setup_plans/test_setup_zebra_and_shutter.py b/tests/unit_tests/common/device_setup_plans/test_setup_zebra_and_shutter.py new file mode 100644 index 0000000000..3a0200c5e5 --- /dev/null +++ b/tests/unit_tests/common/device_setup_plans/test_setup_zebra_and_shutter.py @@ -0,0 +1,70 @@ +import dataclasses + +from dodal.devices.zebra.zebra import ( + Zebra, +) +from dodal.devices.zebra.zebra_controlled_shutter import ( + ZebraShutter, + ZebraShutterControl, +) + +from mx_bluesky.common.device_setup_plans.setup_zebra_and_shutter import ( + configure_zebra_and_shutter_for_auto_shutter, + setup_zebra_for_gridscan, + tidy_up_zebra_after_gridscan, +) + + +async def _get_shutter_input_2(zebra: Zebra): + return ( + await zebra.logic_gates.and_gates[zebra.mapping.AND_GATE_FOR_AUTO_SHUTTER] + .sources[2] + .get_value() + ) + + +async def _get_shutter_input_1(zebra: Zebra): + return ( + await zebra.logic_gates.and_gates[zebra.mapping.AND_GATE_FOR_AUTO_SHUTTER] + .sources[1] + .get_value() + ) + + +async def test_configure_zebra_and_shutter_for_auto( + RE, zebra: Zebra, zebra_shutter: ZebraShutter +): + RE( + configure_zebra_and_shutter_for_auto_shutter( + zebra, zebra_shutter, zebra.mapping.sources.IN4_TTL + ) + ) + assert await zebra_shutter.control_mode.get_value() == ZebraShutterControl.AUTO + assert await _get_shutter_input_1(zebra) == zebra.mapping.sources.SOFT_IN1 + assert await _get_shutter_input_2(zebra) == zebra.mapping.sources.IN4_TTL + + +async def test_zebra_cleanup(RE, zebra: Zebra, zebra_shutter: ZebraShutter): + RE(tidy_up_zebra_after_gridscan(zebra, zebra_shutter, wait=True)) + assert ( + await zebra.output.out_pvs[zebra.mapping.outputs.TTL_DETECTOR].get_value() + == zebra.mapping.sources.PC_PULSE + ) + assert await _get_shutter_input_2(zebra) == zebra.mapping.sources.PC_GATE + + +async def test_zebra_set_up_for_gridscan(RE, zebra: Zebra, zebra_shutter: ZebraShutter): + @dataclasses.dataclass + class Composite: + zebra: Zebra + sample_shutter: ZebraShutter + + composite = Composite(zebra, zebra_shutter) + RE(setup_zebra_for_gridscan(composite, wait=True)) + assert ( + await zebra.output.out_pvs[zebra.mapping.outputs.TTL_DETECTOR].get_value() + == zebra.mapping.sources.IN3_TTL + ) + assert await _get_shutter_input_2(zebra) == zebra.mapping.sources.IN4_TTL + assert await zebra_shutter.control_mode.get_value() == ZebraShutterControl.AUTO + assert await _get_shutter_input_1(zebra) == zebra.mapping.sources.SOFT_IN1 diff --git a/tests/unit_tests/hyperion/device_setup_plans/test_zebra_setup.py b/tests/unit_tests/hyperion/device_setup_plans/test_zebra_setup.py index 2a9a2ad9f6..5d7d8555a6 100644 --- a/tests/unit_tests/hyperion/device_setup_plans/test_zebra_setup.py +++ b/tests/unit_tests/hyperion/device_setup_plans/test_zebra_setup.py @@ -1,5 +1,3 @@ -import dataclasses - import pytest from dodal.devices.zebra.zebra import ( I03Axes, @@ -14,11 +12,6 @@ setup_zebra_for_panda_flyscan, setup_zebra_for_rotation, ) -from mx_bluesky.phase1_zebra.device_setup_plans.setup_zebra import ( - configure_zebra_and_shutter_for_auto_shutter, - setup_zebra_for_gridscan, - tidy_up_zebra_after_gridscan, -) async def _get_shutter_input_2(zebra: Zebra): @@ -54,52 +47,9 @@ async def test_zebra_set_up_for_panda_gridscan( assert await _get_shutter_input_1(zebra) == zebra.mapping.sources.SOFT_IN1 -async def test_zebra_set_up_for_gridscan(RE, zebra: Zebra, zebra_shutter: ZebraShutter): - @dataclasses.dataclass - class Composite: - zebra: Zebra - sample_shutter: ZebraShutter - - composite = Composite(zebra, zebra_shutter) - RE(setup_zebra_for_gridscan(composite, wait=True)) - assert ( - await zebra.output.out_pvs[zebra.mapping.outputs.TTL_DETECTOR].get_value() - == zebra.mapping.sources.IN3_TTL - ) - assert await _get_shutter_input_2(zebra) == zebra.mapping.sources.IN4_TTL - assert await zebra_shutter.control_mode.get_value() == ZebraShutterControl.AUTO - assert await _get_shutter_input_1(zebra) == zebra.mapping.sources.SOFT_IN1 - - async def test_zebra_set_up_for_rotation(RE, zebra: Zebra, zebra_shutter: ZebraShutter): RE(setup_zebra_for_rotation(zebra, zebra_shutter, wait=True)) assert await zebra.pc.gate_trigger.get_value() == I03Axes.OMEGA.value assert await zebra.pc.gate_width.get_value() == pytest.approx(360, 0.01) assert await zebra_shutter.control_mode.get_value() == ZebraShutterControl.AUTO assert await _get_shutter_input_1(zebra) == zebra.mapping.sources.SOFT_IN1 - - -async def test_zebra_cleanup(RE, zebra: Zebra, zebra_shutter: ZebraShutter): - RE(tidy_up_zebra_after_gridscan(zebra, zebra_shutter, wait=True)) - assert ( - await zebra.output.out_pvs[zebra.mapping.outputs.TTL_DETECTOR].get_value() - == zebra.mapping.sources.PC_PULSE - ) - assert await _get_shutter_input_2(zebra) == zebra.mapping.sources.PC_GATE - - -class MyException(Exception): - pass - - -async def test_configure_zebra_and_shutter_for_auto( - RE, zebra: Zebra, zebra_shutter: ZebraShutter -): - RE( - configure_zebra_and_shutter_for_auto_shutter( - zebra, zebra_shutter, zebra.mapping.sources.IN4_TTL - ) - ) - assert await zebra_shutter.control_mode.get_value() == ZebraShutterControl.AUTO - assert await _get_shutter_input_1(zebra) == zebra.mapping.sources.SOFT_IN1 - assert await _get_shutter_input_2(zebra) == zebra.mapping.sources.IN4_TTL From 55a1f3a8f32ca70a3c92989373a182cec53bd736 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Wed, 1 Oct 2025 09:32:13 +0000 Subject: [PATCH 36/64] Improve docstring --- .../setup_zebra_and_shutter.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py b/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py index 9fe21de48b..1b56306ed4 100644 --- a/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py +++ b/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py @@ -34,11 +34,22 @@ def setup_zebra_for_gridscan( ttl_input_for_detector_to_use: None | int = None, zebra_output_to_disconnect: None | SignalRW = None, ) -> MsgGenerator: - """This plan assumes that the motion controller will send triggers to the zebra's IN4_TTL whenever the fast shutter - should open throughout the gridscan. + """ + Configure the zebra for an MX XRC gridscan by allowing the zebra to trigger the fast shutter and detector via signals + sent from the motion controller. + + Args: + composite: Composite device containing a zebra and zebra shutter + group: Bluesky group to use when waiting on completion + wait: If true, block until completion + ttl_input_for_detector_to_use: If the zebra isn't using the TTL_DETECTOR zebra input, manually + specify which TTL input is being used for the desired detector + zebra_output_to_disconnect: Optionally specify a TTL output which should be unmapped (disconnected) from the Zebras inputs + before the gridscan begins. + + This plan assumes that the motion controller, as part of its gridscan PLC, will send triggers as required to the zebra's + IN4_TTL and IN3_TTL to control the fast_shutter and detector respectively - If the Zebra has multiple detectors connected, you must manually specify which TTL input connects to your desired detector - in the ttl_input_for_detector_to_use parameter. """ zebra = composite.zebra ttl_detector = ttl_input_for_detector_to_use or zebra.mapping.outputs.TTL_DETECTOR From 37ffe91a0b1a51a1260699fad00b6420c5c23de8 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Wed, 1 Oct 2025 09:40:24 +0000 Subject: [PATCH 37/64] Add another docstring --- .../device_setup_plans/setup_zebra_and_shutter.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py b/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py index 1b56306ed4..37bb0fb6f3 100644 --- a/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py +++ b/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py @@ -105,7 +105,7 @@ def configure_zebra_and_shutter_for_auto_shutter( auto_gate = zebra.mapping.AND_GATE_FOR_AUTO_SHUTTER - # Set first input of AND gate to SOFT_IN1, which is high when shutter is in auto mode + # Set first input of AND_GATE_FOR_AUTO_SHUTTER to SOFT_IN1, which is high when shutter is in auto mode # Note the Zebra should ALWAYS be setup this way. See https://github.com/DiamondLightSource/mx-bluesky/issues/551 yield from bps.abs_set( zebra.logic_gates.and_gates[auto_gate].sources[1], @@ -125,10 +125,15 @@ def tidy_up_zebra_after_gridscan( ttl_input_for_detector_to_use=None, ) -> MsgGenerator: """ - Set the zebra back to a state which is expected by GDA + Set the zebra back to a state which is expected by GDA. - If the Zebra has multiple detectors connected, you must manually specify which TTL input connects to your desired detector - in the ttl_input_for_detector_to_use parameter. + Args: + zebra: Zebra device + zebra_shutter: Zebra shutter device + group: Bluesky group to use when waiting on completion + wait: If true, block until completion + ttl_input_for_detector_to_use: If the zebra isn't using the TTL_DETECTOR zebra input, manually + specify which TTL input is being used for the desired detector """ LOGGER.info("Tidying up Zebra") From dabdcfe438bdd3696c133a4e9dc40ef23b73113e Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Wed, 1 Oct 2025 14:01:14 +0000 Subject: [PATCH 38/64] Make setup and tidy up zebra rotation plans common --- .../setup_zebra_and_shutter.py | 105 +++++++++++++++++- .../device_setup_plans/setup_zebra.py | 98 ---------------- .../experiment_plans/rotation_scan_plan.py | 9 +- .../test_setup_zebra_and_shutter.py | 11 ++ .../device_setup_plans/test_zebra_setup.py | 11 -- .../test_rotation_scan_plan.py | 3 + 6 files changed, 125 insertions(+), 112 deletions(-) diff --git a/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py b/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py index 37bb0fb6f3..cad5b8ea26 100644 --- a/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py +++ b/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py @@ -3,6 +3,10 @@ import bluesky.plan_stubs as bps from bluesky.utils import MsgGenerator from dodal.devices.zebra.zebra import ( + ArmDemand, + EncEnum, + I03Axes, + RotationDirection, Zebra, ) from dodal.devices.zebra.zebra_controlled_shutter import ( @@ -122,7 +126,7 @@ def tidy_up_zebra_after_gridscan( zebra_shutter: ZebraShutter, group="tidy_up_zebra_after_gridscan", wait=True, - ttl_input_for_detector_to_use=None, + ttl_input_for_detector_to_use: int | None = None, ) -> MsgGenerator: """ Set the zebra back to a state which is expected by GDA. @@ -152,3 +156,102 @@ def tidy_up_zebra_after_gridscan( if wait: yield from bps.wait(group, timeout=ZEBRA_STATUS_TIMEOUT) + + +def tidy_up_zebra_after_rotation_scan( + zebra: Zebra, + zebra_shutter: ZebraShutter, + group="tidy_up_zebra_after_rotation", + wait=True, +): + yield from bps.abs_set(zebra.pc.arm, ArmDemand.DISARM, group=group) + yield from bps.abs_set( + zebra_shutter.control_mode, ZebraShutterControl.MANUAL, group=group + ) + if wait: + yield from bps.wait(group, timeout=ZEBRA_STATUS_TIMEOUT) + + +def setup_zebra_for_rotation( + zebra: Zebra, + zebra_shutter: ZebraShutter, + axis: EncEnum = I03Axes.OMEGA, + start_angle: float = 0, + scan_width: float = 360, + shutter_opening_deg: float = 2.5, + shutter_opening_s: float = 0.04, + direction: RotationDirection = RotationDirection.POSITIVE, + group: str = "setup_zebra_for_rotation", + wait: bool = True, + ttl_input_for_detector_to_use: int | None = None, + zebra_output_to_disconnect: None | SignalRW = None, +): + """Set up the Zebra to collect a rotation dataset. Any plan using this is + responsible for setting the smargon velocity appropriately so that the desired + image width is achieved with the exposure time given here. + + Parameters: + zebra: The zebra device to use + axis: Encoder enum representing which axis to use for position + compare. Currently always omega. + start_angle: Position at which the scan should begin, in degrees. + scan_width: Total angle through which to collect, in degrees. + shutter_opening_deg:How many degrees of rotation it takes for the fast shutter + to open. Increases the gate width. + shutter_opening_s: How many seconds it takes for the fast shutter to open. The + detector pulse is delayed after the shutter signal by this + amount. + direction: RotationDirection enum for positive or negative. + Defaults to Positive. + group: A name for the group of statuses generated + wait: Block until all the settings have completed + ttl_input_for_detector_to_use: If the zebra isn't using the TTL_DETECTOR zebra input, + manually specify which TTL input is being used for the desired detector + zebra_output_to_disconnect: Optionally specify a TTL output which should be unmapped + (disconnected) from the Zebras inputs before the gridscan begins. + """ + + ttl_detector = ttl_input_for_detector_to_use or zebra.mapping.outputs.TTL_DETECTOR + + if not isinstance(direction, RotationDirection): + raise ValueError( + "Disallowed rotation direction provided to Zebra setup plan. " + "Use RotationDirection.POSITIVE or RotationDirection.NEGATIVE." + ) + yield from bps.abs_set(zebra.pc.dir, direction.value, group=group) + LOGGER.info("ZEBRA SETUP: START") + # Set gate start, adjust for shutter opening time if necessary + LOGGER.info(f"ZEBRA SETUP: degrees to adjust for shutter = {shutter_opening_deg}") + LOGGER.info(f"ZEBRA SETUP: start angle start: {start_angle}") + LOGGER.info(f"ZEBRA SETUP: start angle adjusted, gate start set to: {start_angle}") + yield from bps.abs_set(zebra.pc.gate_start, start_angle, group=group) + # set gate width to total width + yield from bps.abs_set( + zebra.pc.gate_width, scan_width + shutter_opening_deg, group=group + ) + LOGGER.info( + f"Pulse start set to shutter open time, set to: {abs(shutter_opening_s)}" + ) + yield from bps.abs_set(zebra.pc.pulse_start, abs(shutter_opening_s), group=group) + # Set gate position to be angle of interest + yield from bps.abs_set(zebra.pc.gate_trigger, axis.value, group=group) + # Set shutter to automatic and to trigger via PC_GATE + yield from configure_zebra_and_shutter_for_auto_shutter( + zebra, zebra_shutter, zebra.mapping.sources.PC_GATE, group=group + ) + # Trigger the detector with a pulse + yield from bps.abs_set( + zebra.output.out_pvs[ttl_detector], + zebra.mapping.sources.PC_PULSE, + group=group, + ) + if zebra_output_to_disconnect: + yield from bps.abs_set( + zebra_output_to_disconnect, zebra.mapping.sources.DISCONNECT, group + ) + yield from bps.abs_set( + zebra.output.pulse_1.input, zebra.mapping.sources.DISCONNECT, group=group + ) + LOGGER.info(f"ZEBRA SETUP: END - {'' if wait else 'not'} waiting for completion") + if wait: + yield from bps.wait(group, timeout=ZEBRA_STATUS_TIMEOUT) diff --git a/src/mx_bluesky/hyperion/device_setup_plans/setup_zebra.py b/src/mx_bluesky/hyperion/device_setup_plans/setup_zebra.py index db0cf91017..6d695bbca8 100644 --- a/src/mx_bluesky/hyperion/device_setup_plans/setup_zebra.py +++ b/src/mx_bluesky/hyperion/device_setup_plans/setup_zebra.py @@ -1,120 +1,22 @@ import bluesky.plan_stubs as bps from dodal.devices.zebra.zebra import ( ArmDemand, - EncEnum, - I03Axes, - RotationDirection, Zebra, ) from dodal.devices.zebra.zebra_controlled_shutter import ( ZebraShutter, - ZebraShutterControl, ) from mx_bluesky.common.device_setup_plans.setup_zebra_and_shutter import ( configure_zebra_and_shutter_for_auto_shutter, ) from mx_bluesky.common.parameters.constants import ZEBRA_STATUS_TIMEOUT -from mx_bluesky.common.utils.log import LOGGER def arm_zebra(zebra: Zebra): yield from bps.abs_set(zebra.pc.arm, ArmDemand.ARM, wait=True) -def tidy_up_zebra_after_rotation_scan( - zebra: Zebra, - zebra_shutter: ZebraShutter, - group="tidy_up_zebra_after_rotation", - wait=True, -): - yield from bps.abs_set(zebra.pc.arm, ArmDemand.DISARM, group=group) - yield from bps.abs_set( - zebra_shutter.control_mode, ZebraShutterControl.MANUAL, group=group - ) - if wait: - yield from bps.wait(group, timeout=ZEBRA_STATUS_TIMEOUT) - - -def setup_zebra_for_rotation( - zebra: Zebra, - zebra_shutter: ZebraShutter, - axis: EncEnum = I03Axes.OMEGA, - start_angle: float = 0, - scan_width: float = 360, - shutter_opening_deg: float = 2.5, - shutter_opening_s: float = 0.04, - direction: RotationDirection = RotationDirection.POSITIVE, - group: str = "setup_zebra_for_rotation", - wait: bool = True, -): - """Set up the Zebra to collect a rotation dataset. Any plan using this is - responsible for setting the smargon velocity appropriately so that the desired - image width is achieved with the exposure time given here. - - Parameters: - zebra: The zebra device to use - axis: I03 axes enum representing which axis to use for position - compare. Currently always omega. - start_angle: Position at which the scan should begin, in degrees. - scan_width: Total angle through which to collect, in degrees. - shutter_opening_deg:How many degrees of rotation it takes for the fast shutter - to open. Increases the gate width. - shutter_opening_s: How many seconds it takes for the fast shutter to open. The - detector pulse is delayed after the shutter signal by this - amount. - direction: RotationDirection enum for positive or negative. - Defaults to Positive. - group: A name for the group of statuses generated - wait: Block until all the settings have completed - """ - - if not isinstance(direction, RotationDirection): - raise ValueError( - "Disallowed rotation direction provided to Zebra setup plan. " - "Use RotationDirection.POSITIVE or RotationDirection.NEGATIVE." - ) - yield from bps.abs_set(zebra.pc.dir, direction.value, group=group) - LOGGER.info("ZEBRA SETUP: START") - # Set gate start, adjust for shutter opening time if necessary - LOGGER.info(f"ZEBRA SETUP: degrees to adjust for shutter = {shutter_opening_deg}") - LOGGER.info(f"ZEBRA SETUP: start angle start: {start_angle}") - LOGGER.info(f"ZEBRA SETUP: start angle adjusted, gate start set to: {start_angle}") - yield from bps.abs_set(zebra.pc.gate_start, start_angle, group=group) - # set gate width to total width - yield from bps.abs_set( - zebra.pc.gate_width, scan_width + shutter_opening_deg, group=group - ) - LOGGER.info( - f"Pulse start set to shutter open time, set to: {abs(shutter_opening_s)}" - ) - yield from bps.abs_set(zebra.pc.pulse_start, abs(shutter_opening_s), group=group) - # Set gate position to be angle of interest - yield from bps.abs_set(zebra.pc.gate_trigger, axis.value, group=group) - # Set shutter to automatic and to trigger via PC_GATE - yield from configure_zebra_and_shutter_for_auto_shutter( - zebra, zebra_shutter, zebra.mapping.sources.PC_GATE, group=group - ) - # Trigger the detector with a pulse - yield from bps.abs_set( - zebra.output.out_pvs[zebra.mapping.outputs.TTL_DETECTOR], - zebra.mapping.sources.PC_PULSE, - group=group, - ) - # Don't use the fluorescence detector - yield from bps.abs_set( - zebra.output.out_pvs[zebra.mapping.outputs.TTL_XSPRESS3], - zebra.mapping.sources.DISCONNECT, - group=group, - ) - yield from bps.abs_set( - zebra.output.pulse_1.input, zebra.mapping.sources.DISCONNECT, group=group - ) - LOGGER.info(f"ZEBRA SETUP: END - {'' if wait else 'not'} waiting for completion") - if wait: - yield from bps.wait(group, timeout=ZEBRA_STATUS_TIMEOUT) - - def setup_zebra_for_panda_flyscan( zebra: Zebra, zebra_shutter: ZebraShutter, diff --git a/src/mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py b/src/mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py index 1402973a69..ef560c7ec6 100644 --- a/src/mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py +++ b/src/mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py @@ -34,6 +34,10 @@ cleanup_sample_environment, setup_sample_environment, ) +from mx_bluesky.common.device_setup_plans.setup_zebra_and_shutter import ( + setup_zebra_for_rotation, + tidy_up_zebra_after_rotation_scan, +) from mx_bluesky.common.device_setup_plans.utils import ( start_preparing_data_collection_then_do_plan, ) @@ -55,8 +59,6 @@ from mx_bluesky.common.utils.log import LOGGER from mx_bluesky.hyperion.device_setup_plans.setup_zebra import ( arm_zebra, - setup_zebra_for_rotation, - tidy_up_zebra_after_rotation_scan, ) from mx_bluesky.hyperion.parameters.constants import CONST, I03Constants from mx_bluesky.hyperion.parameters.rotation import ( @@ -250,6 +252,9 @@ def _rotation_scan_plan( shutter_opening_deg=motion_values.shutter_opening_deg, shutter_opening_s=motion_values.shutter_time_s, group="setup_zebra", + zebra_output_to_disconnect=composite.zebra.output.out_pvs[ + composite.zebra.mapping.outputs.TTL_XSPRESS3 + ], ) yield from setup_sample_environment( diff --git a/tests/unit_tests/common/device_setup_plans/test_setup_zebra_and_shutter.py b/tests/unit_tests/common/device_setup_plans/test_setup_zebra_and_shutter.py index 3a0200c5e5..e2b37c6875 100644 --- a/tests/unit_tests/common/device_setup_plans/test_setup_zebra_and_shutter.py +++ b/tests/unit_tests/common/device_setup_plans/test_setup_zebra_and_shutter.py @@ -1,6 +1,8 @@ import dataclasses +import pytest from dodal.devices.zebra.zebra import ( + I03Axes, Zebra, ) from dodal.devices.zebra.zebra_controlled_shutter import ( @@ -11,6 +13,7 @@ from mx_bluesky.common.device_setup_plans.setup_zebra_and_shutter import ( configure_zebra_and_shutter_for_auto_shutter, setup_zebra_for_gridscan, + setup_zebra_for_rotation, tidy_up_zebra_after_gridscan, ) @@ -68,3 +71,11 @@ class Composite: assert await _get_shutter_input_2(zebra) == zebra.mapping.sources.IN4_TTL assert await zebra_shutter.control_mode.get_value() == ZebraShutterControl.AUTO assert await _get_shutter_input_1(zebra) == zebra.mapping.sources.SOFT_IN1 + + +async def test_zebra_set_up_for_rotation(RE, zebra: Zebra, zebra_shutter: ZebraShutter): + RE(setup_zebra_for_rotation(zebra, zebra_shutter, wait=True)) + assert await zebra.pc.gate_trigger.get_value() == I03Axes.OMEGA.value + assert await zebra.pc.gate_width.get_value() == pytest.approx(360, 0.01) + assert await zebra_shutter.control_mode.get_value() == ZebraShutterControl.AUTO + assert await _get_shutter_input_1(zebra) == zebra.mapping.sources.SOFT_IN1 diff --git a/tests/unit_tests/hyperion/device_setup_plans/test_zebra_setup.py b/tests/unit_tests/hyperion/device_setup_plans/test_zebra_setup.py index 5d7d8555a6..f4acaa89f8 100644 --- a/tests/unit_tests/hyperion/device_setup_plans/test_zebra_setup.py +++ b/tests/unit_tests/hyperion/device_setup_plans/test_zebra_setup.py @@ -1,6 +1,4 @@ -import pytest from dodal.devices.zebra.zebra import ( - I03Axes, Zebra, ) from dodal.devices.zebra.zebra_controlled_shutter import ( @@ -10,7 +8,6 @@ from mx_bluesky.hyperion.device_setup_plans.setup_zebra import ( setup_zebra_for_panda_flyscan, - setup_zebra_for_rotation, ) @@ -45,11 +42,3 @@ async def test_zebra_set_up_for_panda_gridscan( assert await zebra_shutter.control_mode.get_value() == ZebraShutterControl.AUTO assert await _get_shutter_input_2(zebra) == zebra.mapping.sources.IN4_TTL assert await _get_shutter_input_1(zebra) == zebra.mapping.sources.SOFT_IN1 - - -async def test_zebra_set_up_for_rotation(RE, zebra: Zebra, zebra_shutter: ZebraShutter): - RE(setup_zebra_for_rotation(zebra, zebra_shutter, wait=True)) - assert await zebra.pc.gate_trigger.get_value() == I03Axes.OMEGA.value - assert await zebra.pc.gate_width.get_value() == pytest.approx(360, 0.01) - assert await zebra_shutter.control_mode.get_value() == ZebraShutterControl.AUTO - assert await _get_shutter_input_1(zebra) == zebra.mapping.sources.SOFT_IN1 diff --git a/tests/unit_tests/hyperion/experiment_plans/test_rotation_scan_plan.py b/tests/unit_tests/hyperion/experiment_plans/test_rotation_scan_plan.py index c63fe431b7..73fd109da3 100644 --- a/tests/unit_tests/hyperion/experiment_plans/test_rotation_scan_plan.py +++ b/tests/unit_tests/hyperion/experiment_plans/test_rotation_scan_plan.py @@ -937,6 +937,9 @@ def test_rotation_scan_plan_with_omega_flip_inverts_motor_movements_but_not_even shutter_opening_deg=ANY, shutter_opening_s=ANY, group="setup_zebra", + zebra_output_to_disconnect=fake_create_rotation_devices.zebra.output.out_pvs[ + fake_create_rotation_devices.zebra.mapping.outputs.TTL_XSPRESS3 + ], ) rotation_outer_start_event = next( dropwhile( From 23fab94389d5c3523aca2e4a9ff6958a278ad87e Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Wed, 1 Oct 2025 16:51:08 +0000 Subject: [PATCH 39/64] Do all setup in parallel, add plan to do multi rotations by transmission --- .../callbacks/metadata_writer.py | 1 - .../rotation_scan_plan.py | 99 +++++++++++-------- .../beamlines/i24/parameters/rotation.py | 20 ++++ .../common/experiment_plans/setup_zebra.py | 7 +- 4 files changed, 81 insertions(+), 46 deletions(-) create mode 100644 src/mx_bluesky/beamlines/i24/parameters/rotation.py diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py index 2ed639e895..3ce2d27507 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py @@ -7,7 +7,6 @@ from mx_bluesky.common.parameters.rotation import SingleRotationScan from mx_bluesky.common.utils.log import LOGGER -EXPERIMENT_PARAM_DUMP_FILENAME = "experiment_params.json" READING_DUMP_FILENAME = "collection_info.json" diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py index 6e214915c1..7ca484b8e9 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py @@ -1,5 +1,6 @@ from __future__ import annotations +from copy import deepcopy from functools import partial import bluesky.plan_stubs as bps @@ -10,6 +11,7 @@ from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau from dodal.devices.i24.dual_backlight import BacklightPositions from dodal.devices.zebra.zebra import I24Axes, Zebra +from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter from dodal.plan_stubs.check_topup import check_topup_and_wait_if_necessary from ophyd_async.fastcs.jungfrau import ( create_jungfrau_external_triggering_info, @@ -32,8 +34,11 @@ from mx_bluesky.beamlines.i24.parameters.constants import ( PlanNameConstants as I24PlanNameConstants, ) -from mx_bluesky.beamlines.i24.serial.setup_beamline.setup_zebra_plans import ( - disarm_zebra, +from mx_bluesky.beamlines.i24.parameters.rotation import ( + MultiRotationScanByTransmissions, +) +from mx_bluesky.common.device_setup_plans.setup_zebra_and_shutter import ( + tidy_up_zebra_after_rotation_scan, ) from mx_bluesky.common.experiment_plans.rotation.rotation_utils import ( RotationMotionProfile, @@ -52,15 +57,21 @@ ) from mx_bluesky.common.utils.log import LOGGER +# todo move constants somewhere more sensible EXPERIMENT_PARAM_DUMP_FILENAME = "experiment_params.json" READING_DUMP_FILENAME = "collection_info.json" - JF_DET_STAGE_Y_POSITION_MM = 730 +DEFAULT_DETECTOR_DISTANCE_MM = 200 +PREPARE_BEAMLINE_GROUP = "prepare beamline" -def set_up_beamline_for_rotation(composite: RotationScanComposite, det_z_mm: float): - """Check hutch is open, move backlight in, then, in parallel, - move aperture in, move backlight out and move det stage in. Wait for this parallel +def set_up_beamline_for_rotation( + composite: RotationScanComposite, + det_z_mm: float, + transmission_frac: float, +): + """Check hutch is open, then, in parallel, move backlight in, + move aperture in, move backlight out and move det stages in. Wait for this parallel move to finish.""" hutch_shutter_state: ShutterState = yield from bps.rd( @@ -70,15 +81,11 @@ def set_up_beamline_for_rotation(composite: RotationScanComposite, det_z_mm: flo if hutch_shutter_state != ShutterState.OPEN: LOGGER.error(f"Hutch shutter is not open! State is {hutch_shutter_state}") raise Exception(f"Hutch shutter is not open! State is {hutch_shutter_state}") - LOGGER.info("Making sure backlight is moved out...") - yield from bps.mv(composite.backlight.backlight_position, BacklightPositions.OUT) - - # Remove sleep once we confirm we get feedback on backlight out here - yield from bps.sleep(20) LOGGER.info( - "Making sure aperture and beamstop are in, detector stage is in position, and detector distance is correct." + "Making sure aperture and beamstop are in, detector stages are in position, backlight is out, and transmission is set..." ) + yield from bps.abs_set(composite.det_stage.y, JF_DET_STAGE_Y_POSITION_MM) yield from bps.mv( composite.aperture.position, AperturePositions.IN, @@ -86,10 +93,27 @@ def set_up_beamline_for_rotation(composite: RotationScanComposite, det_z_mm: flo BeamstopPositions.DATA_COLLECTION, composite.det_stage.y, JF_DET_STAGE_Y_POSITION_MM, + composite.backlight.backlight_position, + BacklightPositions.OUT, composite.det_stage.z, + det_z_mm, + composite.attenuator, + transmission_frac, ) +def multi_rotation_plan_varying_transmission( + composite: RotationScanComposite, + params: MultiRotationScanByTransmissions, +): + for transmission in params.transmission_fractions: + param_copy = deepcopy(params).model_dump() + del param_copy["transmission_fractions"] + param_copy["transmission_frac"] = transmission + single_rotation_params = SingleRotationScan(**param_copy) + yield from single_rotation_plan(composite, single_rotation_params) + + def single_rotation_plan( composite: RotationScanComposite, params: SingleRotationScan, @@ -98,21 +122,14 @@ def single_rotation_plan( about a fixed axis - for now this axis is limited to omega. Needs additional setup of the sample environment and a wrapper to clean up.""" - # TODO This should be somewhere more sensible - like in the parameter model if not params.detector_distance_mm: - raise ValueError("Must specify detector distance in mm") + LOGGER.info( + f"Using default detector distance of {DEFAULT_DETECTOR_DISTANCE_MM} mm" + ) + params.detector_distance_mm = DEFAULT_DETECTOR_DISTANCE_MM - yield from set_up_beamline_for_rotation(composite, params.detector_distance_mm) - LOGGER.info( - f"Moving detector Z stage to specified {params.detector_distance_mm} mm..." - ) - # TODO don't block on these moves, and wait on completion alongside waiting for jungfrau to prepare. - # make a separate group for JF prepare completion. - yield from bps.mv( - composite.det_stage.z, - params.detector_distance_mm, - composite.attenuator, - params.transmission_frac, + yield from set_up_beamline_for_rotation( + composite, params.detector_distance_mm, params.transmission_frac ) # This value isn't actually used, see https://github.com/DiamondLightSource/mx-bluesky/issues/1224 @@ -139,7 +156,7 @@ def _rotation_scan_plan( _deadtime = 2e-5 _jf_trigger_info = create_jungfrau_external_triggering_info( - params.num_images, params.detector_params.exposure_time_s, _deadtime + params.num_images, params.detector_params.exposure_time_s ) axis = composite.gonio.omega @@ -148,7 +165,7 @@ def _rotation_scan_plan( yield from bps.abs_set( axis.velocity, motion_values.max_velocity_deg_s, wait=True ) - LOGGER.info(f"Moving omega to beginning, {motion_values.start_scan_deg=}") + LOGGER.info(f"Moving omega to start value, {motion_values.start_scan_deg=}") yield from bps.abs_set( axis, motion_values.start_motion_deg, @@ -167,17 +184,12 @@ def _rotation_scan_plan( group=PlanGroupCheckpointConstants.SETUP_ZEBRA_FOR_ROTATION, ) - LOGGER.info("Wait for any previous moves...") - # wait for all the setup tasks at once yield from bps.wait(PlanGroupCheckpointConstants.ROTATION_READY_FOR_DC) - yield from bps.wait(PlanGroupCheckpointConstants.MOVE_GONIO_TO_START) # Get ready for the actual scan yield from bps.abs_set( axis.velocity, motion_values.speed_for_rotation_deg_s, wait=True ) - - yield from bps.wait(PlanGroupCheckpointConstants.SETUP_ZEBRA_FOR_ROTATION) yield from arm_zebra(composite.zebra) # Check topup gate @@ -192,8 +204,6 @@ def _rotation_scan_plan( f"{params.storage_directory}/{params.detector_params.full_filename}", ) - # Write metadata json file - metadata_writer = JsonMetadataWriter() @bpp.subs_decorator([metadata_writer]) @@ -205,11 +215,11 @@ def _rotation_scan_plan( "rotation_scan_params": params.model_dump_json(), } ) + # Write metadata json file def _do_read(): yield from read_devices_for_metadata(composite) yield from _do_read() - yield from fly_jungfrau( composite.jungfrau, _jf_trigger_info, @@ -224,16 +234,21 @@ def _do_read(): yield from bpp.finalize_wrapper( _rotation_scan_plan(motion_values, composite), - final_plan=partial(_cleanup_plan, composite.zebra, composite.jungfrau), + final_plan=partial( + _cleanup_plan, composite.zebra, composite.jungfrau, composite.sample_shutter + ), ) yield from _rotation_scan_plan(motion_values, composite) - print("test") -def _cleanup_plan(zebra: Zebra, jf: CommissioningJungfrau, group="cleanup"): +def _cleanup_plan( + zebra: Zebra, + jf: CommissioningJungfrau, + zebra_shutter: ZebraShutter, + group="rotation cleanup", +): LOGGER.info("Tidying up zebra and Jungfrau...") - yield from bps.unstage(jf) - yield from bps.abs_set(zebra.inputs.soft_in_1, 0, group=group) - yield from disarm_zebra(zebra) - yield from bps.wait("cleanup") + yield from bps.unstage(jf, group=group) + yield from tidy_up_zebra_after_rotation_scan(zebra, zebra_shutter) + yield from bps.wait(group=group) diff --git a/src/mx_bluesky/beamlines/i24/parameters/rotation.py b/src/mx_bluesky/beamlines/i24/parameters/rotation.py new file mode 100644 index 0000000000..fd53a257ad --- /dev/null +++ b/src/mx_bluesky/beamlines/i24/parameters/rotation.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from pydantic import field_validator +from sqlalchemy import Float + +from mx_bluesky.common.parameters.rotation import SingleRotationScan + + +class MultiRotationScanByTransmissions(SingleRotationScan): + transmission_fractions: list[Float] + transmission_frac: float = -1 + + @field_validator("transmission_frac") + @classmethod + def validate_transmission_frac(cls, val): + if val != -1: + raise ValueError( + "The transmission_fractions field must be specified instead of the transmission_frac when using MultiRotationScanByTransmissions parameters" + ) + return val diff --git a/src/mx_bluesky/common/experiment_plans/setup_zebra.py b/src/mx_bluesky/common/experiment_plans/setup_zebra.py index 7ef9c2847a..b0b30e2d34 100644 --- a/src/mx_bluesky/common/experiment_plans/setup_zebra.py +++ b/src/mx_bluesky/common/experiment_plans/setup_zebra.py @@ -12,14 +12,15 @@ ZebraShutterControl, ) -from mx_bluesky.common.parameters.constants import ZEBRA_STATUS_TIMEOUT -from mx_bluesky.common.utils.log import LOGGER -from mx_bluesky.phase1_zebra.device_setup_plans.setup_zebra import ( +from mx_bluesky.common.device_setup_plans.setup_zebra_and_shutter import ( configure_zebra_and_shutter_for_auto_shutter, ) +from mx_bluesky.common.parameters.constants import ZEBRA_STATUS_TIMEOUT +from mx_bluesky.common.utils.log import LOGGER def arm_zebra(zebra: Zebra): + LOGGER.info("Arming zebra position compare...") yield from bps.abs_set(zebra.pc.arm, ArmDemand.ARM, wait=True) From 01b063d2cecc44d21a2b1a7dcb8992235cac9bb6 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Wed, 1 Oct 2025 16:57:57 +0000 Subject: [PATCH 40/64] Make metadata writer less weird --- .../callbacks/metadata_writer.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py index 3ce2d27507..6970ace612 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py @@ -28,15 +28,13 @@ def __init__(self): self.wavelength_in_a = None self.energy_in_kev = None self.detector_distance_mm = None + self.descriptors: dict[str, dict] = {} + self.flux: float | None = None + self.transmission: float | None = None + self.parameters: SingleRotationScan | None = None super().__init__() - descriptors: dict[str, dict] = {} - parameters: SingleRotationScan - wavelength: float | None = None - flux: float | None = None - transmission: float | None = None - def start(self, doc: dict): # type: ignore if doc.get("subplan_name") == PlanNameConstants.ROTATION_META_READ: LOGGER.info( @@ -67,10 +65,11 @@ def event(self, doc: dict): # type: ignore ) LOGGER.info( - f"Metadata writer received parameters, transmission: {self.transmission}, flux: {self.flux}, wavelength: {self.wavelength}, det distance: {self.detector_distance_mm}, beam_xy: {self.beam_xy}" + f"Metadata writer received parameters, transmission: {self.transmission}, flux: {self.flux}, wavelength: {self.wavelength_in_a}, det distance: {self.detector_distance_mm}, beam_xy: {self.beam_xy}" ) def stop(self, doc: dict): # type: ignore + assert self.parameters is not None if ( self.run_start_uid is not None and doc.get("run_start") == self.run_start_uid From 4711e3697da991d5f12be48380abe3a5edac0162 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Wed, 1 Oct 2025 17:20:54 +0000 Subject: [PATCH 41/64] More tidy up and test stubs --- .../callbacks/metadata_writer.py | 6 +- .../rotation_scan_plan.py | 6 - .../beamlines/i24/parameters/rotation.py | 3 +- .../test_rotation_scan.py | 25 +- .../test_load_centre_collect_full_plan.py | 1772 ++++++++--------- 5 files changed, 847 insertions(+), 965 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py index 6970ace612..1eb601dea0 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py @@ -37,11 +37,11 @@ def __init__(self): def start(self, doc: dict): # type: ignore if doc.get("subplan_name") == PlanNameConstants.ROTATION_META_READ: - LOGGER.info( - "Metadata writer recieved start document with experiment parameters." - ) json_params = doc.get("rotation_scan_params") assert json_params is not None + LOGGER.info( + f"Metadata writer recieved start document with experiment parameters {json_params}" + ) self.parameters = SingleRotationScan(**json.loads(json_params)) self.run_start_uid = doc.get("uid") diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py index 7ca484b8e9..0c652146c5 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py @@ -57,12 +57,9 @@ ) from mx_bluesky.common.utils.log import LOGGER -# todo move constants somewhere more sensible -EXPERIMENT_PARAM_DUMP_FILENAME = "experiment_params.json" READING_DUMP_FILENAME = "collection_info.json" JF_DET_STAGE_Y_POSITION_MM = 730 DEFAULT_DETECTOR_DISTANCE_MM = 200 -PREPARE_BEAMLINE_GROUP = "prepare beamline" def set_up_beamline_for_rotation( @@ -152,9 +149,6 @@ def _rotation_scan_plan( motion_values: RotationMotionProfile, composite: RotationScanComposite, ): - # Use smallest safe deadtime and neglect from motion calcualtions - _deadtime = 2e-5 - _jf_trigger_info = create_jungfrau_external_triggering_info( params.num_images, params.detector_params.exposure_time_s ) diff --git a/src/mx_bluesky/beamlines/i24/parameters/rotation.py b/src/mx_bluesky/beamlines/i24/parameters/rotation.py index fd53a257ad..7ec9deb561 100644 --- a/src/mx_bluesky/beamlines/i24/parameters/rotation.py +++ b/src/mx_bluesky/beamlines/i24/parameters/rotation.py @@ -1,13 +1,12 @@ from __future__ import annotations from pydantic import field_validator -from sqlalchemy import Float from mx_bluesky.common.parameters.rotation import SingleRotationScan class MultiRotationScanByTransmissions(SingleRotationScan): - transmission_fractions: list[Float] + transmission_fractions: list[float] transmission_frac: float = -1 @field_validator("transmission_frac") diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py index fd3dcad9a5..a164c95746 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py @@ -5,16 +5,16 @@ from dodal.devices.hutch_shutter import HutchShutter, ShutterState from dodal.devices.i24.aperture import Aperture from dodal.devices.i24.beamstop import Beamstop +from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau from dodal.devices.i24.dcm import DCM from dodal.devices.i24.dual_backlight import DualBacklight from dodal.devices.motors import YZStage from dodal.devices.synchrotron import Synchrotron -from dodal.devices.util.test_utils import patch_all_motors from dodal.devices.xbpm_feedback import XBPMFeedback from dodal.devices.zebra.zebra import Zebra from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter +from dodal.testing import patch_all_motors from ophyd_async.core import init_devices -from ophyd_async.fastcs.jungfrau import Jungfrau from ophyd_async.testing import set_mock_value from tests.conftest import raw_params_from_file @@ -35,7 +35,9 @@ def get_good_rotation_params(tmp_path): @pytest.fixture -def rotation_composite(jungfrau: Jungfrau, zebra: Zebra) -> RotationScanComposite: +def rotation_composite( + jungfrau: CommissioningJungfrau, zebra: Zebra +) -> RotationScanComposite: with init_devices(mock=True): aperture = Aperture("") attenuator = i24_attenuator() @@ -47,7 +49,7 @@ def rotation_composite(jungfrau: Jungfrau, zebra: Zebra) -> RotationScanComposit beamstop = Beamstop("") det_stage = YZStage("") # TODO add JF position to det stage device backlight = DualBacklight("") - dcm = DCM("") + dcm = DCM("", "") patch_all_motors(det_stage) patch_all_motors(sample_shutter) @@ -76,9 +78,7 @@ def test_single_rotation_plan_in_re( RE: RunEngine, tmp_path, rotation_composite: RotationScanComposite ): params = get_good_rotation_params(tmp_path) - set_mock_value( - rotation_composite.jungfrau._writer._drv.num_captured, params.num_images - ) + set_mock_value(rotation_composite.jungfrau._writer.frame_counter, params.num_images) set_mock_value(rotation_composite.hutch_shutter.status, ShutterState.OPEN) RE(single_rotation_plan(rotation_composite, params)) @@ -86,7 +86,16 @@ def test_single_rotation_plan_in_re( def test_metadata_writer_produces_correct_json_after_plan(): ... -def test_set_up_beamline_for_rotation(): ... +def test_set_up_beamline_for_rotation_in_re(): ... + + +def test_set_up_beamline_for_rotation_in_simulator(): ... def test_single_rotation_plan_error_if_no_det_distance(): ... + + +def test_multi_rotation_plan_varying_transmission(): ... + + +def test_cleanup_plan(): ... diff --git a/tests/unit_tests/hyperion/experiment_plans/test_load_centre_collect_full_plan.py b/tests/unit_tests/hyperion/experiment_plans/test_load_centre_collect_full_plan.py index c3649c659d..9d57255a48 100644 --- a/tests/unit_tests/hyperion/experiment_plans/test_load_centre_collect_full_plan.py +++ b/tests/unit_tests/hyperion/experiment_plans/test_load_centre_collect_full_plan.py @@ -1,40 +1,30 @@ -from __future__ import annotations - -import os -from collections.abc import Callable, Generator, Iterable -from contextlib import nullcontext -from functools import partial -from pathlib import Path +import dataclasses +from collections.abc import Sequence from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch -import bluesky.plan_stubs as bps import pytest +from bluesky.protocols import Location from bluesky.run_engine import RunEngine +from bluesky.simulators import RunEngineSimulator, assert_message_and_return_remaining +from bluesky.utils import Msg +from dodal.devices.baton import Baton +from dodal.devices.mx_phase1.beamstop import BeamstopPositions from dodal.devices.oav.oav_parameters import OAVParameters from dodal.devices.oav.pin_image_recognition import PinTipDetection from dodal.devices.synchrotron import SynchrotronMode -from ispyb.sqlalchemy import BLSample +from dodal.devices.zebra.zebra import RotationDirection from ophyd.sim import NullStatus -from ophyd_async.core import AsyncStatus from ophyd_async.testing import set_mock_value +from pydantic import ValidationError -from mx_bluesky.common.experiment_plans.common_grid_detect_then_xray_centre_plan import ( - detect_grid_and_do_gridscan, -) -from mx_bluesky.common.external_interaction.callbacks.common.grid_detection_callback import ( - GridParamUpdate, -) -from mx_bluesky.common.external_interaction.callbacks.common.ispyb_mapping import ( - get_proposal_and_session_from_visit_string, +from mx_bluesky.common.parameters.components import ( + TopNByMaxCountForEachSampleSelection, ) -from mx_bluesky.common.external_interaction.callbacks.sample_handling.sample_handling_callback import ( - SampleHandlingCallback, +from mx_bluesky.common.parameters.rotation import ( + RotationScan, + RotationScanPerSweep, ) -from mx_bluesky.common.external_interaction.callbacks.xray_centre.ispyb_callback import ( - GridscanISPyBCallback, -) -from mx_bluesky.common.parameters.components import TopNByMaxCountForEachSampleSelection from mx_bluesky.common.utils.exceptions import ( CrystalNotFoundException, WarningException, @@ -43,1084 +33,974 @@ LoadCentreCollectComposite, load_centre_collect_full, ) -from mx_bluesky.hyperion.experiment_plans.rotation_scan_plan import _move_and_rotation -from mx_bluesky.hyperion.external_interaction.callbacks.robot_actions.ispyb_callback import ( - RobotLoadISPyBCallback, -) -from mx_bluesky.hyperion.external_interaction.callbacks.rotation.ispyb_callback import ( - RotationISPyBCallback, +from mx_bluesky.hyperion.experiment_plans.robot_load_then_centre_plan import ( + RobotLoadThenCentreComposite, ) -from mx_bluesky.hyperion.external_interaction.callbacks.snapshot_callback import ( - BeamDrawingCallback, +from mx_bluesky.hyperion.experiment_plans.rotation_scan_plan import ( + RotationScanComposite, ) from mx_bluesky.hyperion.parameters.constants import CONST -from mx_bluesky.hyperion.parameters.gridscan import GridCommonWithHyperionDetectorParams from mx_bluesky.hyperion.parameters.load_centre_collect import LoadCentreCollect - -from ....conftest import ( - TEST_RESULT_IN_BOUNDS_TOP_LEFT_BOX, - TEST_RESULT_IN_BOUNDS_TOP_LEFT_GRID_CORNER, - TEST_RESULT_MEDIUM, - TEST_RESULT_OUT_OF_BOUNDS_BB, - TEST_RESULT_OUT_OF_BOUNDS_COM, - TEST_RESULT_SMALL, - SimConstants, - assert_images_pixelwise_equal, - raw_params_from_file, - replace_all_tmp_paths, +from mx_bluesky.hyperion.parameters.robot_load import RobotLoadAndEnergyChange + +from ....conftest import pin_tip_edge_data, raw_params_from_file +from .conftest import ( + FLYSCAN_RESULT_HIGH, + FLYSCAN_RESULT_HIGH_NO_SAMPLE_ID, + FLYSCAN_RESULT_LOW, + FLYSCAN_RESULT_MED, + sim_fire_event_on_open_run, ) -from ...conftest import ( - DATA_COLLECTION_COLUMN_MAP, - compare_actual_and_expected, - compare_comment, -) - -SNAPSHOT_GENERATION_ZOCALO_RESULT = [ - { - "centre_of_mass": [7.25, 12.2, 5.38], - "max_voxel": [7, 12, 5], - "max_count": 50000, - "n_voxels": 35, - "total_count": 100000, - "bounding_box": [[1, 2, 3], [3, 4, 4]], - "sample_id": SimConstants.ST_SAMPLE_ID, - } -] +GOOD_TEST_LOAD_CENTRE_COLLECT_MULTI_ROTATION = "tests/test_data/parameter_json_files/good_test_load_centre_collect_params_multi_rotation.json" -@pytest.fixture -def load_centre_collect_params(tmp_path): - json_dict = raw_params_from_file( - "tests/test_data/parameter_json_files/example_load_centre_collect_params.json", - tmp_path, - ) - json_dict["visit"] = SimConstants.ST_VISIT - json_dict["sample_id"] = SimConstants.ST_SAMPLE_ID - return LoadCentreCollect(**json_dict) +POS_HIGH = { + "x_start_um": 100, + "y_start_um": 200, + "z_start_um": 300, +} +POS_MED = { + "x_start_um": 400, + "y_start_um": 500, + "z_start_um": 600, +} @pytest.fixture -def load_centre_collect_msp_params(load_centre_collect_params): - load_centre_collect_params.select_centres = TopNByMaxCountForEachSampleSelection( - n=5 +def composite( + robot_load_composite, + fake_create_rotation_devices, + pin_tip_detection_with_found_pin, + sim_run_engine: RunEngineSimulator, + baton: Baton, +) -> LoadCentreCollectComposite: + rlaec_args = { + field.name: getattr(robot_load_composite, field.name) + for field in dataclasses.fields(robot_load_composite) + } + rotation_args = { + field.name: getattr(fake_create_rotation_devices, field.name) + for field in dataclasses.fields(fake_create_rotation_devices) + } + + composite = LoadCentreCollectComposite(baton=baton, **(rlaec_args | rotation_args)) + composite.pin_tip_detection = pin_tip_detection_with_found_pin + composite.undulator_dcm.set = MagicMock(return_value=NullStatus()) + minaxis = Location(setpoint=-2, readback=-2) + maxaxis = Location(setpoint=2, readback=2) + tip_x_px, tip_y_px, top_edge_array, bottom_edge_array = pin_tip_edge_data() + sim_run_engine.add_handler( + "locate", lambda _: minaxis, "smargon-x-low_limit_travel" ) - load_centre_collect_params.sample_id = SimConstants.ST_MSP_SAMPLE_IDS[0] - load_centre_collect_params.robot_load_then_centre.sample_id = ( - load_centre_collect_params.sample_id + sim_run_engine.add_handler( + "locate", lambda _: minaxis, "smargon-y-low_limit_travel" ) - return load_centre_collect_params - + sim_run_engine.add_handler( + "locate", lambda _: minaxis, "smargon-z-low_limit_travel" + ) + sim_run_engine.add_handler( + "locate", lambda _: maxaxis, "smargon-x-high_limit_travel" + ) + sim_run_engine.add_handler( + "locate", lambda _: maxaxis, "smargon-y-high_limit_travel" + ) + sim_run_engine.add_handler( + "locate", lambda _: maxaxis, "smargon-z-high_limit_travel" + ) + sim_run_engine.add_read_handler_for( + composite.synchrotron.synchrotron_mode, SynchrotronMode.USER + ) + sim_run_engine.add_read_handler_for( + composite.synchrotron.top_up_start_countdown, -1 + ) + sim_run_engine.add_read_handler_for( + composite.pin_tip_detection.triggered_top_edge, top_edge_array + ) + sim_run_engine.add_read_handler_for( + composite.pin_tip_detection.triggered_bottom_edge, bottom_edge_array + ) + zoom_levels_list = ["1.0x", "3.0x", "5.0x", "7.5x", "10.0x"] + composite.oav.zoom_controller.level.describe = AsyncMock( + return_value={"level": {"choices": zoom_levels_list}} + ) + set_mock_value(composite.oav.zoom_controller.level, "1.0x") -@pytest.fixture -def load_centre_collect_composite( - grid_detect_then_xray_centre_composite, - beamstop_phase1, - composite_for_rotation_scan, - thawer, - vfm, - mirror_voltages, - undulator_dcm, - webcam, - lower_gonio, - baton, -): - composite = LoadCentreCollectComposite( - aperture_scatterguard=composite_for_rotation_scan.aperture_scatterguard, - attenuator=composite_for_rotation_scan.attenuator, - backlight=composite_for_rotation_scan.backlight, - baton=baton, - beamstop=beamstop_phase1, - dcm=composite_for_rotation_scan.dcm, - detector_motion=composite_for_rotation_scan.detector_motion, - eiger=grid_detect_then_xray_centre_composite.eiger, - flux=composite_for_rotation_scan.flux, - robot=composite_for_rotation_scan.robot, - smargon=composite_for_rotation_scan.smargon, - undulator=composite_for_rotation_scan.undulator, - synchrotron=composite_for_rotation_scan.synchrotron, - s4_slit_gaps=composite_for_rotation_scan.s4_slit_gaps, - sample_shutter=composite_for_rotation_scan.sample_shutter, - zebra=grid_detect_then_xray_centre_composite.zebra, - oav=grid_detect_then_xray_centre_composite.oav, - xbpm_feedback=composite_for_rotation_scan.xbpm_feedback, - zebra_fast_grid_scan=grid_detect_then_xray_centre_composite.zebra_fast_grid_scan, - pin_tip_detection=grid_detect_then_xray_centre_composite.pin_tip_detection, - zocalo=grid_detect_then_xray_centre_composite.zocalo, - panda=grid_detect_then_xray_centre_composite.panda, - panda_fast_grid_scan=grid_detect_then_xray_centre_composite.panda_fast_grid_scan, - thawer=thawer, - vfm=vfm, - mirror_voltages=mirror_voltages, - undulator_dcm=undulator_dcm, - webcam=webcam, - lower_gonio=lower_gonio, + sim_run_engine.add_read_handler_for( + composite.pin_tip_detection.triggered_tip, (tip_x_px, tip_y_px) ) + sim_run_engine.add_read_handler_for(composite.oav.microns_per_pixel_x, 1.58) + sim_run_engine.add_read_handler_for(composite.oav.microns_per_pixel_y, 1.58) + return composite - set_mock_value(composite.dcm.bragg_in_degrees.user_readback, 5) - yield composite +@pytest.fixture +def load_centre_collect_params_multi(tmp_path): + params = raw_params_from_file( + GOOD_TEST_LOAD_CENTRE_COLLECT_MULTI_ROTATION, tmp_path + ) + return LoadCentreCollect(**params) @pytest.fixture -def robot_load_cb() -> RobotLoadISPyBCallback: - robot_load_cb = RobotLoadISPyBCallback() - robot_load_cb.expeye.start_robot_action = MagicMock(return_value=1234) - robot_load_cb.expeye.end_robot_action = MagicMock() - robot_load_cb.expeye.update_robot_action = MagicMock() - return robot_load_cb - - -GRID_DC_1_EXPECTED_VALUES = { - "BLSAMPLEID": SimConstants.ST_SAMPLE_ID, - "detectorid": 78, - "axisstart": 90.0, - "axisrange": 0, - "axisend": 90, - "focalspotsizeatsamplex": 0.02, - "focalspotsizeatsampley": 0.02, - "slitgapvertical": 0.234, - "slitgaphorizontal": 0.123, - "beamsizeatsamplex": 0.02, - "beamsizeatsampley": 0.02, - "transmission": 100, - "datacollectionnumber": 1, - "detectordistance": 255.0, - "exposuretime": 0.002, - "imagedirectory": "{tmp_data}/123457/xraycentring/", - "imageprefix": "robot_load_centring_file", - "imagesuffix": "h5", - "numberofpasses": 1, - "overlap": 0, - "omegastart": 90, - "startimagenumber": 1, - "wavelength": 1.11697, - "xbeam": 75.6027, - "ybeam": 79.4935, - "xtalsnapshotfullpath1": "{tmp_data}/123457/xraycentring/snapshots/robot_load_centring_file_1_90_grid_overlay.png", - "xtalsnapshotfullpath2": "{tmp_data}/123457/xraycentring/snapshots" - "/robot_load_centring_file_1_90_outer_overlay.png", - "xtalsnapshotfullpath3": "{tmp_data}/123457/xraycentring/snapshots/robot_load_centring_file_1_90.png", - "synchrotronmode": "User", - "undulatorgap1": 1.11, - "filetemplate": "robot_load_centring_file_1_master.h5", - "numberofimages": 180, -} - -GRID_DC_2_EXPECTED_VALUES = GRID_DC_1_EXPECTED_VALUES | { - "axisstart": 0, - "axisend": 0, - "omegastart": 0, - "datacollectionnumber": 2, - "filetemplate": "robot_load_centring_file_2_master.h5", - "numberofimages": 180, - "xtalsnapshotfullpath1": "{tmp_data}/123457/xraycentring/snapshots" - "/robot_load_centring_file_1_0_grid_overlay.png", - "xtalsnapshotfullpath2": "{tmp_data}/123457/xraycentring/snapshots" - "/robot_load_centring_file_1_0_outer_overlay.png", - "xtalsnapshotfullpath3": "{tmp_data}/123457/xraycentring/snapshots" - "/robot_load_centring_file_1_0.png", -} +def load_centre_collect_with_top_n_params(tmp_path): + params = raw_params_from_file( + "tests/test_data/parameter_json_files/load_centre_collect_params_top_n_by_max_count.json", + tmp_path, + ) + return LoadCentreCollect(**params) -ROTATION_DC_EXPECTED_VALUES = { - "axisStart": 10, - "axisEnd": -350, - # "chiStart": 0, mx-bluesky 325 - "wavelength": 1.11697, - "beamSizeAtSampleX": 0.02, - "beamSizeAtSampleY": 0.02, - "exposureTime": 0.004, - "undulatorGap1": 1.11, - "synchrotronMode": SynchrotronMode.USER.value, - "slitGapHorizontal": 0.123, - "slitGapVertical": 0.234, - "xtalSnapshotFullPath1": "regex:{tmp_data}/123457/snapshots/\\d{" - "8}_oav_snapshot_0_with_beam_centre\\.png", - "xtalSnapshotFullPath2": "regex:{tmp_data}/123457/snapshots/\\d{" - "8}_oav_snapshot_90_with_beam_centre\\.png", - "xtalSnapshotFullPath3": "regex:{tmp_data}/123457/snapshots/\\d{" - "8}_oav_snapshot_180_with_beam_centre\\.png", - "xtalSnapshotFullPath4": "regex:{tmp_data}/123457/snapshots/\\d{" - "8}_oav_snapshot_270_with_beam_centre\\.png", -} -ROTATION_DC_2_EXPECTED_VALUES = ROTATION_DC_EXPECTED_VALUES | { - "axisStart": -350, - "axisEnd": 10, - "xtalSnapshotFullPath1": "regex:{tmp_data}/123457/snapshots/\\d{" - "8}_oav_snapshot_0_with_beam_centre\\.png", - "xtalSnapshotFullPath2": "regex:{tmp_data}/123457/snapshots/\\d{" - "8}_oav_snapshot_90_with_beam_centre\\.png", - "xtalSnapshotFullPath3": "regex:{tmp_data}/123457/snapshots/\\d{" - "8}_oav_snapshot_180_with_beam_centre\\.png", - "xtalSnapshotFullPath4": "regex:{tmp_data}/123457/snapshots/\\d{" - "8}_oav_snapshot_270_with_beam_centre\\.png", -} +@pytest.fixture +def load_centre_collect_with_top_n_for_each_sample( + load_centre_collect_with_top_n_params, +): + load_centre_collect_with_top_n_params.select_centres = ( + TopNByMaxCountForEachSampleSelection(n=5) + ) + return load_centre_collect_with_top_n_params @pytest.fixture -def composite_with_no_diffraction( - load_centre_collect_composite: LoadCentreCollectComposite, -) -> Generator[LoadCentreCollectComposite, Any, None]: - zocalo = load_centre_collect_composite.zocalo +def mock_multi_rotation_scan(): + with ( + patch( + "mx_bluesky.hyperion.experiment_plans.robot_load_then_centre_plan.robot_load_and_change_energy_plan", + new=MagicMock( + return_value=iter([Msg(command="robot_load_and_change_energy")]) + ), + ), + patch( + "mx_bluesky.hyperion.experiment_plans.load_centre_collect_full_plan.rotation_scan_internal", + side_effect=lambda _, __, ___: iter([Msg(command="multi_rotation_scan")]), + ) as mock_rotation, + ): + yield mock_rotation - @AsyncStatus.wrap - async def mock_zocalo_complete(): - await zocalo._put_results([], {"dcid": 0, "dcgid": 0}) - with patch.object(zocalo, "trigger", side_effect=mock_zocalo_complete): - yield load_centre_collect_composite +def test_can_serialize_load_centre_collect_params(load_centre_collect_params): + load_centre_collect_params.model_dump_json() -@pytest.mark.system_test -def test_execute_load_centre_collect_full( - load_centre_collect_composite: LoadCentreCollectComposite, - load_centre_collect_params: LoadCentreCollect, - oav_parameters_for_rotation: OAVParameters, - RE: RunEngine, - fetch_datacollection_attribute: Callable[..., Any], - fetch_datacollectiongroup_attribute: Callable[..., Any], - fetch_datacollection_ids_for_group_id: Callable[..., Any], - fetch_blsample: Callable[[int], BLSample], - tmp_path, - robot_load_cb: RobotLoadISPyBCallback, +def test_params_good_multi_rotation_load_centre_collect_params( + load_centre_collect_params_multi, tmp_path ): - ispyb_gridscan_cb = GridscanISPyBCallback( - param_type=GridCommonWithHyperionDetectorParams - ) - ispyb_rotation_cb = RotationISPyBCallback() - snapshot_cb = BeamDrawingCallback(emit=ispyb_rotation_cb) - set_mock_value( - load_centre_collect_composite.undulator_dcm.undulator_ref().current_gap, 1.11 - ) - RE.subscribe(ispyb_gridscan_cb) - RE.subscribe(snapshot_cb) - RE.subscribe(robot_load_cb) - RE( - load_centre_collect_full( - load_centre_collect_composite, - load_centre_collect_params, - oav_parameters_for_rotation, - ) + params = raw_params_from_file( + GOOD_TEST_LOAD_CENTRE_COLLECT_MULTI_ROTATION, tmp_path ) + LoadCentreCollect(**params) - expected_proposal, expected_visit = get_proposal_and_session_from_visit_string( - load_centre_collect_params.visit - ) - expected_sample_id = load_centre_collect_params.sample_id - robot_load_cb.expeye.start_robot_action.assert_called_once_with( # type: ignore - "LOAD", expected_proposal, expected_visit, expected_sample_id - ) - # TODO re-enable this https://github.com/DiamondLightSource/mx-bluesky/issues/690 - # robot_load_cb.expeye.update_barcode_and_snapshots.assert_called_once_with( - # 1234, - # "BARCODE", - # "{tmp_data}/123457/xraycentring/snapshots/160705_webcam_after_load.png", - # "/tmp/snapshot1.png", - # ) - robot_load_cb.expeye.end_robot_action.assert_called_once_with(1234, "success", "OK") # type: ignore - # Compare gridscan collection - compare_actual_and_expected( - ispyb_gridscan_cb.ispyb_ids.data_collection_group_id, - {"experimentType": "Mesh3D", "blSampleId": expected_sample_id}, - fetch_datacollectiongroup_attribute, - ) - compare_actual_and_expected( - ispyb_gridscan_cb.ispyb_ids.data_collection_ids[0], - replace_all_tmp_paths(GRID_DC_1_EXPECTED_VALUES, tmp_path), - fetch_datacollection_attribute, - DATA_COLLECTION_COLUMN_MAP, - ) - compare_actual_and_expected( - ispyb_gridscan_cb.ispyb_ids.data_collection_ids[1], - replace_all_tmp_paths(GRID_DC_2_EXPECTED_VALUES, tmp_path), - fetch_datacollection_attribute, - DATA_COLLECTION_COLUMN_MAP, +def test_params_with_varying_frames_per_rotation_is_rejected(tmp_path): + params = raw_params_from_file( + GOOD_TEST_LOAD_CENTRE_COLLECT_MULTI_ROTATION, tmp_path ) + params["multi_rotation_scan"]["rotation_scans"][0]["scan_width_deg"] = 180 + params["multi_rotation_scan"]["rotation_scans"][1]["scan_width_deg"] = 90 + with pytest.raises( + ValidationError, + match="Sweeps with different numbers of frames are not supported.", + ): + LoadCentreCollect(**params) - compare_comment( - fetch_datacollection_attribute, - ispyb_gridscan_cb.ispyb_ids.data_collection_ids[0], - "MX-Bluesky: Xray centring 1 - Diffraction grid scan of 30 by 6 " - "images in 20.0 um by 20.0 um steps. Top left (px): [130,130], " - "bottom right (px): [874,278]. Aperture: Small. ", - ) - compare_comment( - fetch_datacollection_attribute, - ispyb_gridscan_cb.ispyb_ids.data_collection_ids[1], - "MX-Bluesky: Xray centring 2 - Diffraction grid scan of 30 by 6 " - "images in 20.0 um by 20.0 um steps. Top left (px): [130,130], " - "bottom right (px): [874,278]. Aperture: Small. ", - ) - rotation_dcg_id = ispyb_rotation_cb.ispyb_ids.data_collection_group_id - rotation_dc_ids = fetch_datacollection_ids_for_group_id(rotation_dcg_id) - compare_actual_and_expected( - rotation_dcg_id, - {"experimentType": "SAD", "blSampleId": expected_sample_id}, - fetch_datacollectiongroup_attribute, - ) - compare_actual_and_expected( - rotation_dc_ids[0], - replace_all_tmp_paths(ROTATION_DC_EXPECTED_VALUES, tmp_path), - fetch_datacollection_attribute, - ) - compare_actual_and_expected( - rotation_dc_ids[1], - replace_all_tmp_paths(ROTATION_DC_2_EXPECTED_VALUES, tmp_path), - fetch_datacollection_attribute, +@pytest.mark.parametrize( + "param, value", + [ + ["x_start_um", 1.0], + ["y_start_um", 2.0], + ["z_start_um", 3.0], + ], +) +def test_params_with_start_xyz_is_rejected(param: str, value: float, tmp_path): + params = raw_params_from_file( + GOOD_TEST_LOAD_CENTRE_COLLECT_MULTI_ROTATION, tmp_path ) + params["multi_rotation_scan"]["rotation_scans"][1][param] = value + with pytest.raises( + ValidationError, + match="Specifying start xyz for sweeps is not supported in combination with centring.", + ): + LoadCentreCollect(**params) - compare_comment( - fetch_datacollection_attribute, - ispyb_rotation_cb.ispyb_ids.data_collection_ids[0], - "Hyperion Rotation Scan - Sample position (µm): (-2309, -591, -571) Aperture: " - "Small. ", + +def test_params_with_different_energy_for_rotation_gridscan_rejected(tmp_path): + params = raw_params_from_file( + GOOD_TEST_LOAD_CENTRE_COLLECT_MULTI_ROTATION, tmp_path ) - assert fetch_blsample(expected_sample_id).blSampleStatus == "LOADED" # type: ignore + params["multi_rotation_scan"]["demand_energy_ev"] = 11000 + params["robot_load_then_centre"]["demand_energy_ev"] = 11100 + with pytest.raises( + ValidationError, + match="Setting a different energy for gridscan and rotation is not supported.", + ): + LoadCentreCollect(**params) -@pytest.mark.system_test -def test_load_centre_collect_updates_bl_sample_status_robot_load_fail( - load_centre_collect_composite: LoadCentreCollectComposite, - load_centre_collect_params: LoadCentreCollect, - oav_parameters_for_rotation: OAVParameters, - RE: RunEngine, - fetch_blsample: Callable[..., Any], +@pytest.mark.parametrize( + "key, value", + [ + # MxBlueskyParameters + ["parameter_model_version", "1.2.3"], + # WithSample + ["sample_id", 12345], + ["sample_puck", 1], + ["sample_pin", 2], + # WithVisit + ["beamline", "i03"], + ["visit", "cm12345"], + ["insertion_prefix", "SR03"], + ["detector_distance_mm", 123], + ["det_dist_to_beam_converter_path", "/foo/bar"], + ], +) +def test_params_with_unexpected_info_in_robot_load_rejected( + key: str, value: Any, tmp_path ): - robot_load_cb = RobotLoadISPyBCallback() - sample_handling_cb = SampleHandlingCallback() - RE.subscribe(robot_load_cb) - RE.subscribe(sample_handling_cb) - - with ( - patch( - "mx_bluesky.hyperion.experiment_plans.robot_load_and_change_energy.wait_for_smargon_not_disabled", - side_effect=TimeoutError("Simulated timeout"), - ), - pytest.raises(TimeoutError, match="Simulated timeout"), + params = raw_params_from_file( + GOOD_TEST_LOAD_CENTRE_COLLECT_MULTI_ROTATION, tmp_path + ) + params["robot_load_then_centre"][key] = value + with pytest.raises( + ValidationError, match="Unexpected keys in robot_load_then_centre" ): - RE( - load_centre_collect_full( - load_centre_collect_composite, - load_centre_collect_params, - oav_parameters_for_rotation, - ) - ) + LoadCentreCollect(**params) - assert ( - fetch_blsample(load_centre_collect_params.sample_id).blSampleStatus - == "ERROR - beamline" + +@pytest.mark.parametrize( + "key, value", + [ + # MxBlueskyParameters + ["parameter_model_version", "1.2.3"], + # WithSample + ["sample_id", 12345], + ["sample_puck", 1], + ["sample_pin", 2], + # WithVisit + ["beamline", "i03"], + ["visit", "cm12345"], + ["insertion_prefix", "SR03"], + ["detector_distance_mm", 123], + ["det_dist_to_beam_converter_path", "/foo/bar"], + ], +) +def test_params_with_unexpected_info_in_multi_rotation_scan_rejected( + key: str, value: Any, tmp_path +): + params = raw_params_from_file( + GOOD_TEST_LOAD_CENTRE_COLLECT_MULTI_ROTATION, tmp_path ) + params["multi_rotation_scan"][key] = value + with pytest.raises(ValidationError, match="Unexpected keys in multi_rotation_scan"): + LoadCentreCollect(**params) -@pytest.mark.system_test -def test_load_centre_collect_updates_bl_sample_status_pin_tip_detection_fail( - load_centre_collect_composite: LoadCentreCollectComposite, - load_centre_collect_params: LoadCentreCollect, - oav_parameters_for_rotation: OAVParameters, - pin_tip_no_pin_found: PinTipDetection, - RE: RunEngine, - fetch_blsample: Callable[..., Any], +def test_can_serialize_load_centre_collect_robot_load_params( + load_centre_collect_params, ): - robot_load_cb = RobotLoadISPyBCallback() - ispyb_gridscan_cb = GridscanISPyBCallback( - param_type=GridCommonWithHyperionDetectorParams - ) - sample_handling_cb = SampleHandlingCallback() - RE.subscribe(robot_load_cb) - RE.subscribe(ispyb_gridscan_cb) - RE.subscribe(sample_handling_cb) + load_centre_collect_params.robot_load_then_centre.model_dump_json() - with pytest.raises( - WarningException, match="Pin tip centring failed - pin too long/short.*" - ): - RE( - load_centre_collect_full( - load_centre_collect_composite, - load_centre_collect_params, - oav_parameters_for_rotation, - ) - ) - assert ( - fetch_blsample(load_centre_collect_params.sample_id).blSampleStatus - == "ERROR - sample" - ) +def test_can_serialize_load_centre_collect_multi_rotation_scan( + load_centre_collect_params, +): + load_centre_collect_params.multi_rotation_scan.model_dump_json() -@pytest.mark.system_test -def test_load_centre_collect_updates_bl_sample_status_grid_detection_fail_tip_not_found( - load_centre_collect_composite: LoadCentreCollectComposite, - load_centre_collect_params: LoadCentreCollect, - oav_parameters_for_rotation: OAVParameters, - RE: RunEngine, - fetch_blsample: Callable[..., Any], +def test_can_serialize_load_centre_collect_single_rotation_scans( + load_centre_collect_params, ): - robot_load_cb = RobotLoadISPyBCallback() - ispyb_gridscan_cb = GridscanISPyBCallback( - param_type=GridCommonWithHyperionDetectorParams - ) - sample_handling_cb = SampleHandlingCallback() - RE.subscribe(robot_load_cb) - RE.subscribe(ispyb_gridscan_cb) - RE.subscribe(sample_handling_cb) - - descriptor = None - - def wait_for_first_oav_grid(name: str, doc: dict): - nonlocal descriptor - if ( - name == "descriptor" - and doc["name"] == CONST.DESCRIPTORS.OAV_GRID_SNAPSHOT_TRIGGERED - ): - descriptor = doc["uid"] - if name == "event" and doc["descriptor"] == descriptor: - # Trigger a fail to find the pin at 2nd grid detect - set_mock_value( - load_centre_collect_composite.pin_tip_detection.triggered_tip, - PinTipDetection.INVALID_POSITION, - ) - trigger = load_centre_collect_composite.pin_tip_detection.trigger - trigger.return_value = NullStatus() # type:ignore - trigger.side_effect = None # type: ignore + list(load_centre_collect_params.multi_rotation_scan.single_rotation_scans)[ + 0 + ].model_dump_json() - RE.subscribe(wait_for_first_oav_grid) - with pytest.raises(WarningException, match="No pin found after 5.0 seconds"): - RE( - load_centre_collect_full( - load_centre_collect_composite, - load_centre_collect_params, - oav_parameters_for_rotation, - ) +@patch( + "mx_bluesky.hyperion.experiment_plans.robot_load_then_centre_plan.pin_centre_then_flyscan_plan", + return_value=iter( + [ + Msg( + "open_run", + xray_centre_results=[dataclasses.asdict(FLYSCAN_RESULT_MED)], + run=CONST.PLAN.FLYSCAN_RESULTS, + ), + Msg("close_run"), + ] + ), +) +@patch( + "mx_bluesky.hyperion.experiment_plans.robot_load_then_centre_plan.robot_load_and_change_energy_plan", + return_value=iter([Msg(command="robot_load_and_change_energy")]), +) +@patch( + "mx_bluesky.hyperion.experiment_plans.load_centre_collect_full_plan.rotation_scan_internal", + return_value=iter([Msg(command="multi_rotation_scan")]), +) +def test_collect_full_plan_happy_path_invokes_all_steps_and_centres_on_best_flyscan_result( + mock_rotation_scan: MagicMock, + mock_full_robot_load_plan: MagicMock, + mock_pin_centre_then_xray_centre_plan: MagicMock, + composite: LoadCentreCollectComposite, + load_centre_collect_params: LoadCentreCollect, + oav_parameters_for_rotation: OAVParameters, + sim_run_engine: RunEngineSimulator, +): + sim_run_engine.add_handler_for_callback_subscribes() + sim_fire_event_on_open_run(sim_run_engine, CONST.PLAN.FLYSCAN_RESULTS) + msgs = sim_run_engine.simulate_plan( + load_centre_collect_full( + composite, load_centre_collect_params, oav_parameters_for_rotation ) + ) - assert ( - fetch_blsample(load_centre_collect_params.sample_id).blSampleStatus - == "ERROR - sample" + msgs = assert_message_and_return_remaining( + msgs, + lambda msg: msg.command == "open_run" and "xray_centre_results" in msg.kwargs, + ) + # TODO re-enable tests see mx-bluesky 561 + # msgs = assert_message_and_return_remaining( + # msgs, lambda msg: msg.command == "set" and msg.args[0] == ApertureValue.MEDIUM + # ) + # msgs = assert_message_and_return_remaining( + # msgs, + # lambda msg: msg.command == "set" + # and msg.obj.name == "smargon-x" + # and msg.args[0] == 0.1, + # ) + # msgs = assert_message_and_return_remaining( + # msgs, + # lambda msg: msg.command == "set" + # and msg.obj.name == "smargon-y" + # and msg.args[0] == 0.2, + # ) + # msgs = assert_message_and_return_remaining( + # msgs, + # lambda msg: msg.command == "set" + # and msg.obj.name == "smargon-z" + # and msg.args[0] == 0.3, + # ) + msgs = assert_message_and_return_remaining( + msgs, lambda msg: msg.command == "multi_rotation_scan" + ) + + robot_load_energy_change_composite = mock_full_robot_load_plan.mock_calls[0].args[0] + robot_load_energy_change_params = mock_full_robot_load_plan.mock_calls[0].args[1] + assert isinstance(robot_load_energy_change_composite, RobotLoadThenCentreComposite) + assert isinstance(robot_load_energy_change_params, RobotLoadAndEnergyChange) + mock_pin_centre_then_xray_centre_plan.assert_called_once() + mock_rotation_scan.assert_called_once() + rotation_scan_composite = mock_rotation_scan.mock_calls[0].args[0] + rotation_scan_params = mock_rotation_scan.mock_calls[0].args[1] + assert isinstance(rotation_scan_composite, RotationScanComposite) + assert isinstance(rotation_scan_params, RotationScan) + # XXX sample test file xyz conflicts with detected xyz + # see https://github.com/DiamondLightSource/mx-bluesky/issues/563 + expected_rotation_scans = [ + { + "omega_start_deg": 0, + "chi_start_deg": 23.85, + "x_start_um": 400, + "y_start_um": 500, + "z_start_um": 600, + "nexus_vds_start_img": 0, + "rotation_direction": RotationDirection.NEGATIVE, + }, + ] + _compare_rotation_scans( + expected_rotation_scans, rotation_scan_params.rotation_scans ) -@pytest.mark.system_test -def test_load_centre_collect_updates_bl_sample_status_gridscan_no_diffraction( - composite_with_no_diffraction: LoadCentreCollectComposite, +@patch( + "mx_bluesky.hyperion.experiment_plans.load_centre_collect_full_plan.rotation_scan_internal", + return_value=iter([]), +) +@patch( + "mx_bluesky.hyperion.experiment_plans.robot_load_and_change_energy.set_energy_plan", + new=MagicMock(), +) +def test_load_centre_collect_full_skips_collect_if_pin_tip_not_found( + mock_rotation_scan: MagicMock, + composite: LoadCentreCollectComposite, load_centre_collect_params: LoadCentreCollect, oav_parameters_for_rotation: OAVParameters, - RE: RunEngine, - fetch_blsample: Callable[..., Any], + sim_run_engine: RunEngineSimulator, ): - robot_load_cb = RobotLoadISPyBCallback() - ispyb_gridscan_cb = GridscanISPyBCallback( - param_type=GridCommonWithHyperionDetectorParams + sim_run_engine.add_read_handler_for( + composite.pin_tip_detection.triggered_tip, PinTipDetection.INVALID_POSITION ) - sample_handling_cb = SampleHandlingCallback() - RE.subscribe(robot_load_cb) - RE.subscribe(ispyb_gridscan_cb) - RE.subscribe(sample_handling_cb) - with pytest.raises(CrystalNotFoundException): - RE( + with pytest.raises(WarningException, match="Pin tip centring failed"): + sim_run_engine.simulate_plan( load_centre_collect_full( - composite_with_no_diffraction, - load_centre_collect_params, - oav_parameters_for_rotation, + composite, load_centre_collect_params, oav_parameters_for_rotation ) ) - assert ( - fetch_blsample(load_centre_collect_params.sample_id).blSampleStatus - == "ERROR - sample" - ) + mock_rotation_scan.assert_not_called() -@pytest.mark.system_test -def test_load_centre_collect_updates_bl_sample_status_rotation_failure( - load_centre_collect_composite: LoadCentreCollectComposite, +@patch( + "mx_bluesky.hyperion.experiment_plans.load_centre_collect_full_plan.rotation_scan_internal", + return_value=iter([]), +) +@patch( + "mx_bluesky.hyperion.experiment_plans.robot_load_and_change_energy.set_energy_plan", + new=MagicMock(), +) +def test_load_centre_collect_full_plan_skips_collect_if_no_diffraction( + mock_rotation_scan: MagicMock, + composite: LoadCentreCollectComposite, load_centre_collect_params: LoadCentreCollect, oav_parameters_for_rotation: OAVParameters, - RE: RunEngine, - fetch_blsample: Callable[..., Any], + sim_run_engine: RunEngineSimulator, + grid_detection_callback_with_detected_grid, ): - robot_load_cb = RobotLoadISPyBCallback() - ispyb_gridscan_cb = GridscanISPyBCallback( - param_type=GridCommonWithHyperionDetectorParams - ) - sample_handling_cb = SampleHandlingCallback() - RE.subscribe(robot_load_cb) - RE.subscribe(ispyb_gridscan_cb) - RE.subscribe(sample_handling_cb) - - with ( - patch( - "mx_bluesky.hyperion.experiment_plans.rotation_scan_plan.arm_zebra", - side_effect=TimeoutError("Simulated timeout"), - ), - pytest.raises(TimeoutError, match="Simulated timeout"), - ): - RE( + with pytest.raises(CrystalNotFoundException): + sim_run_engine.simulate_plan( load_centre_collect_full( - load_centre_collect_composite, - load_centre_collect_params, - oav_parameters_for_rotation, + composite, load_centre_collect_params, oav_parameters_for_rotation ) ) - assert ( - fetch_blsample(load_centre_collect_params.sample_id).blSampleStatus - == "ERROR - beamline" - ) + mock_rotation_scan.assert_not_called() -@pytest.mark.parametrize( - "zocalo_result, expected_exception", - [ - [TEST_RESULT_MEDIUM, nullcontext()], - [TEST_RESULT_IN_BOUNDS_TOP_LEFT_BOX, nullcontext()], - [TEST_RESULT_IN_BOUNDS_TOP_LEFT_GRID_CORNER, nullcontext()], - [ - TEST_RESULT_OUT_OF_BOUNDS_COM, - pytest.raises(IndexError, match=".* is outside the bounds of the grid"), - ], - [ - TEST_RESULT_OUT_OF_BOUNDS_BB, - pytest.raises(IndexError, match=".* is outside the bounds of the grid"), - ], - ], +@patch( + "mx_bluesky.hyperion.experiment_plans.load_centre_collect_full_plan.rotation_scan_internal", + return_value=iter([]), ) -@pytest.mark.system_test -def test_load_centre_collect_gridscan_result_at_edge_of_grid( - zocalo_result, - expected_exception, - load_centre_collect_composite: LoadCentreCollectComposite, +@patch( + "mx_bluesky.hyperion.experiment_plans.robot_load_and_change_energy.set_energy_plan", + new=MagicMock(), +) +def test_load_centre_collect_full_plan_collects_at_current_pos_if_no_diffraction_and_dummy_xtal_selection_chosen( + mock_rotation_scan: MagicMock, + composite: LoadCentreCollectComposite, load_centre_collect_params: LoadCentreCollect, oav_parameters_for_rotation: OAVParameters, - robot_load_cb: RobotLoadISPyBCallback, - RE: RunEngine, + sim_run_engine: RunEngineSimulator, + grid_detection_callback_with_detected_grid, ): - load_centre_collect_composite.zocalo.my_zocalo_result = _with_sample_ids( - zocalo_result, [SimConstants.ST_SAMPLE_ID] - ) - ispyb_gridscan_cb = GridscanISPyBCallback( - param_type=GridCommonWithHyperionDetectorParams - ) - ispyb_rotation_cb = RotationISPyBCallback() - set_mock_value( - load_centre_collect_composite.undulator_dcm.undulator_ref().current_gap, 1.11 + load_centre_collect_params.select_centres = TopNByMaxCountForEachSampleSelection( + ignore_xtal_not_found=True, n=5 ) - RE.subscribe(ispyb_gridscan_cb) - RE.subscribe(ispyb_rotation_cb) - RE.subscribe(robot_load_cb) - with expected_exception: - RE( - load_centre_collect_full( - load_centre_collect_composite, - load_centre_collect_params, - oav_parameters_for_rotation, - ) + sim_run_engine.simulate_plan( + load_centre_collect_full( + composite, load_centre_collect_params, oav_parameters_for_rotation ) + ) + + mock_rotation_scan.assert_called_once() -@pytest.mark.system_test -def test_execute_load_centre_collect_capture_rotation_snapshots( - load_centre_collect_composite: LoadCentreCollectComposite, +@patch( + "mx_bluesky.hyperion.experiment_plans.load_centre_collect_full_plan.rotation_scan_internal" +) +@patch( + "mx_bluesky.hyperion.experiment_plans.load_centre_collect_full_plan.RotationScan.model_validate" +) +@patch( + "mx_bluesky.hyperion.experiment_plans.robot_load_then_centre_plan.pin_centre_then_flyscan_plan" +) +@patch( + "mx_bluesky.hyperion.experiment_plans.robot_load_and_change_energy.do_plan_while_lower_gonio_at_home", + new=MagicMock(), +) +def test_load_centre_collect_moves_beamstop_into_place( + mock_pin_tip_then_flyscan_plan: MagicMock, + mock_model_validate: MagicMock, + mock_multi_rotation_scan: MagicMock, + composite: LoadCentreCollectComposite, load_centre_collect_params: LoadCentreCollect, oav_parameters_for_rotation: OAVParameters, - RE: RunEngine, - fetch_datacollection_attribute: Callable[..., Any], - fetch_datacollectiongroup_attribute: Callable[..., Any], - fetch_datacollection_ids_for_group_id: Callable[..., Any], - fetch_blsample: Callable[[int], BLSample], - tmp_path: Path, + sim_run_engine: RunEngineSimulator, ): - load_centre_collect_params.multi_rotation_scan.snapshot_directory = tmp_path - - ispyb_gridscan_cb = GridscanISPyBCallback( - param_type=GridCommonWithHyperionDetectorParams + fake_model = MagicMock() + fake_model.demand_energy_ev = ( + load_centre_collect_params.robot_load_then_centre.demand_energy_ev ) - ispyb_rotation_cb = RotationISPyBCallback() - snapshot_callback = BeamDrawingCallback(emit=ispyb_rotation_cb) - set_mock_value( - load_centre_collect_composite.undulator_dcm.undulator_ref().current_gap, 1.11 + + mock_pin_tip_then_flyscan_plan.return_value = iter( + [Msg("pin_tip_then_flyscan_plan")] ) - RE.subscribe(ispyb_gridscan_cb) - RE.subscribe(snapshot_callback) - RE( + + mock_model_validate.return_value = fake_model + msgs = sim_run_engine.simulate_plan( load_centre_collect_full( - load_centre_collect_composite, - load_centre_collect_params, - oav_parameters_for_rotation, + composite, load_centre_collect_params, oav_parameters_for_rotation ) ) - - EXPECTED_SNAPSHOT_VALUES = { - "xtalSnapshotFullPath1": f"regex:{tmp_path}/\\d{{8}}_oav_snapshot_0_with_beam_centre\\.png", - "xtalSnapshotFullPath2": f"regex:{tmp_path}/\\d{{8}}_oav_snapshot_90_with_beam_centre\\.png", - "xtalSnapshotFullPath3": f"regex:{tmp_path}/\\d{{8}}_oav_snapshot_180_with_beam_centre\\.png", - "xtalSnapshotFullPath4": f"regex:{tmp_path}/\\d{{8}}_oav_snapshot_270_with_beam_centre\\.png", - } - - rotation_dcg_id = ispyb_rotation_cb.ispyb_ids.data_collection_group_id - rotation_dc_ids = fetch_datacollection_ids_for_group_id(rotation_dcg_id) - compare_actual_and_expected( - rotation_dc_ids[0], - EXPECTED_SNAPSHOT_VALUES, - fetch_datacollection_attribute, + msgs = assert_message_and_return_remaining( + msgs, + predicate=lambda msg: msg.command == "set" + and msg.obj.name == "beamstop-selected_pos" + and msg.args[0] == BeamstopPositions.DATA_COLLECTION, ) - compare_actual_and_expected( - rotation_dc_ids[1], - EXPECTED_SNAPSHOT_VALUES, - fetch_datacollection_attribute, + msgs = assert_message_and_return_remaining( + msgs, predicate=lambda msg: msg.command == "pin_tip_then_flyscan_plan" ) - for column in [ - "xtalSnapshotFullPath1", - "xtalSnapshotFullPath2", - "xtalSnapshotFullPath3", - "xtalSnapshotFullPath4", - ]: - filename = fetch_datacollection_attribute(rotation_dc_ids[0], column) - assert_images_pixelwise_equal( - filename, "tests/test_data/test_images/generate_snapshot_output.png" - ) +def test_can_deserialize_top_n_by_max_count_params( + load_centre_collect_with_top_n_params, +): + assert load_centre_collect_with_top_n_params.select_centres.name == "TopNByMaxCount" + assert load_centre_collect_with_top_n_params.select_centres.n == 5 -def _with_sample_ids(zocalo_results: list[dict], sample_ids: Iterable[int]): - copied_results = [zr.copy() for zr in zocalo_results] - for result, sample_id in zip(copied_results, sample_ids, strict=False): - result["sample_id"] = sample_id - return copied_results +def test_bad_selection_method_is_rejected(tmp_path): + params = raw_params_from_file( + "tests/test_data/parameter_json_files/load_centre_collect_params_top_n_by_max_count.json", + tmp_path, + ) + params["select_centres"]["name"] = "inject_bad_code_here" + with pytest.raises( + ValidationError, + match=( + "Input tag 'inject_bad_code_here' found using 'name' does not match any " + "of the expected tags" + ), + ): + LoadCentreCollect(**params) -@pytest.mark.system_test -@pytest.mark.parametrize( - "zocalo_result", - [ - _with_sample_ids( - TEST_RESULT_IN_BOUNDS_TOP_LEFT_BOX + TEST_RESULT_MEDIUM + TEST_RESULT_SMALL, + +def test_default_select_centres_is_top_n_by_max_count_n_is_1( + load_centre_collect_params, +): + assert load_centre_collect_params.select_centres.name == "TopNByMaxCount" + assert load_centre_collect_params.select_centres.n == 1 + + +@patch( + "mx_bluesky.hyperion.experiment_plans.robot_load_then_centre_plan.pin_centre_then_flyscan_plan", + new=MagicMock( + return_value=iter( [ - SimConstants.ST_MSP_SAMPLE_IDS[0], - SimConstants.ST_MSP_SAMPLE_IDS[0], - SimConstants.ST_MSP_SAMPLE_IDS[1], - ], + Msg( + "open_run", + xray_centre_results=[ + dataclasses.asdict(r) + for r in [ + FLYSCAN_RESULT_MED, + FLYSCAN_RESULT_HIGH, + FLYSCAN_RESULT_MED, + FLYSCAN_RESULT_LOW, + FLYSCAN_RESULT_MED, + FLYSCAN_RESULT_HIGH, + ] + ], + run=CONST.PLAN.FLYSCAN_RESULTS, + ), + Msg("close_run"), + ] ) - ], + ), ) -def test_load_centre_collect_multisample_pin_reports_correct_sample_ids_in_ispyb_gridscan( - zocalo_result, - load_centre_collect_composite: LoadCentreCollectComposite, - load_centre_collect_msp_params: LoadCentreCollect, +def test_load_centre_collect_full_plan_multiple_centres( + mock_multi_rotation_scan: MagicMock, + sim_run_engine: RunEngineSimulator, + load_centre_collect_with_top_n_params: LoadCentreCollect, oav_parameters_for_rotation: OAVParameters, - RE: RunEngine, - robot_load_cb: RobotLoadISPyBCallback, - fetch_datacollectiongroup_attribute: Callable[..., Any], - fetch_datacollection_attribute: Callable[..., Any], + composite: LoadCentreCollectComposite, ): - load_centre_collect_composite.zocalo.my_zocalo_result = zocalo_result - ispyb_gridscan_cb = GridscanISPyBCallback( - param_type=GridCommonWithHyperionDetectorParams - ) - ispyb_rotation_cb = RotationISPyBCallback() - snapshot_cb = BeamDrawingCallback(emit=ispyb_rotation_cb) - - RE.subscribe(ispyb_gridscan_cb) - RE.subscribe(snapshot_cb) - RE.subscribe(robot_load_cb) - - RE( + sim_run_engine.add_handler_for_callback_subscribes() + sim_fire_event_on_open_run(sim_run_engine, CONST.PLAN.FLYSCAN_RESULTS) + msgs = sim_run_engine.simulate_plan( load_centre_collect_full( - load_centre_collect_composite, - load_centre_collect_msp_params, + composite, + load_centre_collect_with_top_n_params, oav_parameters_for_rotation, ) ) - expected_sample_id = load_centre_collect_msp_params.sample_id - - compare_actual_and_expected( - ispyb_gridscan_cb.ispyb_ids.data_collection_group_id, - {"blSampleId": expected_sample_id}, - fetch_datacollectiongroup_attribute, + msgs = assert_message_and_return_remaining( + msgs, lambda msg: msg.command == "robot_load_and_change_energy" ) - - compare_actual_and_expected( - ispyb_gridscan_cb.ispyb_ids.data_collection_ids[0], - {"BLSAMPLEID": expected_sample_id}, - fetch_datacollection_attribute, + assert sum(1 for msg in msgs if msg.command == "robot_load_and_change_energy") == 1 + msgs = assert_message_and_return_remaining( + msgs, + lambda msg: msg.command == "open_run" and "xray_centre_results" in msg.kwargs, ) - compare_actual_and_expected( - ispyb_gridscan_cb.ispyb_ids.data_collection_ids[1], - {"BLSAMPLEID": expected_sample_id}, - fetch_datacollection_attribute, + msgs = assert_message_and_return_remaining( + msgs, lambda msg: msg.command == "multi_rotation_scan" ) + def _rotation_at_first_position(direction: RotationDirection, chi): + return { + "omega_start_deg": 10 if direction == RotationDirection.NEGATIVE else -350, + "chi_start_deg": chi, + "x_start_um": 100, + "y_start_um": 200, + "z_start_um": 300, + "rotation_direction": direction, + } -@pytest.mark.system_test -@pytest.mark.parametrize( - "zocalo_result", - [ - _with_sample_ids( - TEST_RESULT_IN_BOUNDS_TOP_LEFT_BOX + TEST_RESULT_MEDIUM + TEST_RESULT_SMALL, + def _rotation_at_second_position(direction: RotationDirection, chi): + return { + "omega_start_deg": 10 if direction == RotationDirection.NEGATIVE else -350, + "chi_start_deg": chi, + "x_start_um": 400, + "y_start_um": 500, + "z_start_um": 600, + "rotation_direction": direction, + } + + expected_rotation_scans = [ + _rotation_at_first_position(RotationDirection.NEGATIVE, 0), + _rotation_at_first_position(RotationDirection.POSITIVE, 30), + _rotation_at_first_position(RotationDirection.NEGATIVE, 0), + _rotation_at_first_position(RotationDirection.POSITIVE, 30), + _rotation_at_second_position(RotationDirection.NEGATIVE, 0), + _rotation_at_second_position(RotationDirection.POSITIVE, 30), + _rotation_at_second_position(RotationDirection.NEGATIVE, 0), + _rotation_at_second_position(RotationDirection.POSITIVE, 30), + _rotation_at_second_position(RotationDirection.NEGATIVE, 0), + _rotation_at_second_position(RotationDirection.POSITIVE, 30), + ] + for i in range(0, len(expected_rotation_scans)): + expected_rotation_scans[i]["nexus_vds_start_img"] = 3600 * i + + rotation_scan_params = mock_multi_rotation_scan.mock_calls[0].args[1] + assert isinstance(rotation_scan_params, RotationScan) + _compare_rotation_scans( + expected_rotation_scans, rotation_scan_params.rotation_scans + ) + assert rotation_scan_params.transmission_frac == 0.05 + + +def _rotation_at( + chi: float, + position: dict, + omega_start_deg: int, + rotation_direction: RotationDirection, +) -> dict: + return { + "omega_start_deg": omega_start_deg, + "chi_start_deg": chi, + "rotation_direction": rotation_direction, + } | position + + +@patch( + "mx_bluesky.hyperion.parameters.constants.I03Constants.ALTERNATE_ROTATION_DIRECTION", + new=True, +) +@patch( + "mx_bluesky.hyperion.experiment_plans.robot_load_then_centre_plan.pin_centre_then_flyscan_plan", + new=MagicMock( + side_effect=lambda *args, **kwargs: iter( [ - SimConstants.ST_MSP_SAMPLE_IDS[0], - SimConstants.ST_MSP_SAMPLE_IDS[0], - SimConstants.ST_MSP_SAMPLE_IDS[1], - ], + Msg( + "open_run", + xray_centre_results=[ + dataclasses.asdict(r) + for r in [ + FLYSCAN_RESULT_HIGH, + FLYSCAN_RESULT_MED, + ] + ], + run=CONST.PLAN.FLYSCAN_RESULTS, + ), + Msg("close_run"), + ] ) + ), +) +@pytest.mark.parametrize( + "rotation_scans, expected_scans", + [ + [ + ( + { + "omega_start_deg": 10, + "chi_start_deg": 0, + "scan_width_deg": 359, + }, + { + "omega_start_deg": 10, + "chi_start_deg": 30, + "scan_width_deg": 359, + }, + ), + ( + _rotation_at(0, POS_HIGH, 10, RotationDirection.NEGATIVE), + _rotation_at(30, POS_HIGH, -349, RotationDirection.POSITIVE), + _rotation_at(0, POS_MED, 10, RotationDirection.NEGATIVE), + _rotation_at(30, POS_MED, -349, RotationDirection.POSITIVE), + ), + ], + [ + ( + { + "omega_start_deg": 10, + "chi_start_deg": 0, + "scan_width_deg": 359, + }, + ), + ( + _rotation_at(0, POS_HIGH, 10, RotationDirection.NEGATIVE), + _rotation_at(0, POS_MED, -349, RotationDirection.POSITIVE), + ), + ], + [ + ( + { + "omega_start_deg": 10, + "chi_start_deg": 0, + "scan_width_deg": 360, + }, + { + "omega_start_deg": 10, + "chi_start_deg": 30, + "scan_width_deg": 360, + }, + ), + ( + _rotation_at(0, POS_HIGH, 10, RotationDirection.NEGATIVE), + _rotation_at(30, POS_HIGH, -350, RotationDirection.POSITIVE), + _rotation_at(0, POS_MED, 10, RotationDirection.NEGATIVE), + _rotation_at(30, POS_MED, -350, RotationDirection.POSITIVE), + ), + ], ], ) -def test_load_centre_collect_multisample_pin_reports_correct_sample_ids_in_ispyb_rotation( - zocalo_result, - load_centre_collect_composite: LoadCentreCollectComposite, - load_centre_collect_msp_params: LoadCentreCollect, +def test_load_centre_collect_full_plan_alternates_rotation_with_multiple_centres( + mock_multi_rotation_scan: MagicMock, + sim_run_engine: RunEngineSimulator, + load_centre_collect_with_top_n_params: LoadCentreCollect, oav_parameters_for_rotation: OAVParameters, - RE: RunEngine, - robot_load_cb: RobotLoadISPyBCallback, - fetch_datacollectiongroup_attribute: Callable[..., Any], - fetch_datacollection_attribute: Callable[..., Any], - fetch_datacollection_ids_for_group_id: Callable[..., Any], + composite: LoadCentreCollectComposite, + rotation_scans: tuple[dict], + expected_scans: tuple[dict], ): - load_centre_collect_composite.zocalo.my_zocalo_result = zocalo_result - ispyb_gridscan_cb = GridscanISPyBCallback( - param_type=GridCommonWithHyperionDetectorParams - ) - ispyb_rotation_cb = RotationISPyBCallback() - snapshot_cb = BeamDrawingCallback(emit=ispyb_rotation_cb) - RE.subscribe(ispyb_gridscan_cb) - RE.subscribe(snapshot_cb) - RE.subscribe(robot_load_cb) - - original_upsert_dcg = ispyb_rotation_cb.ispyb._upsert_data_collection_group - captured_upsert_dcg_ids = [] - - def intercept_upserts(conn, params): - dcg_id = original_upsert_dcg(conn, params) - nonlocal captured_upsert_dcg_ids - if dcg_id not in captured_upsert_dcg_ids: - captured_upsert_dcg_ids.append(dcg_id) - return dcg_id - - with patch.object( - ispyb_rotation_cb.ispyb, - "_upsert_data_collection_group", - side_effect=intercept_upserts, - ): - RE( - load_centre_collect_full( - load_centre_collect_composite, - load_centre_collect_msp_params, - oav_parameters_for_rotation, - ) + load_centre_collect_with_top_n_params.multi_rotation_scan.rotation_scans = [ + RotationScanPerSweep.model_construct(**rs) for rs in rotation_scans + ] + LoadCentreCollect.model_validate(load_centre_collect_with_top_n_params) + + sim_run_engine.add_handler_for_callback_subscribes() + sim_fire_event_on_open_run(sim_run_engine, CONST.PLAN.FLYSCAN_RESULTS) + sim_run_engine.simulate_plan( + load_centre_collect_full( + composite, + load_centre_collect_with_top_n_params, + oav_parameters_for_rotation, ) + ) - assert len(captured_upsert_dcg_ids) == 2 - for dcg_id, expected_sample_id in zip( - captured_upsert_dcg_ids, SimConstants.ST_MSP_SAMPLE_IDS, strict=True - ): - compare_actual_and_expected( - dcg_id, - {"blSampleId": expected_sample_id}, - fetch_datacollectiongroup_attribute, - ) - dc_ids = fetch_datacollection_ids_for_group_id(dcg_id) - compare_actual_and_expected( - dc_ids[0], - {"BLSAMPLEID": expected_sample_id}, - fetch_datacollection_attribute, - ) - compare_actual_and_expected( - dc_ids[1], - {"BLSAMPLEID": expected_sample_id}, - fetch_datacollection_attribute, - ) + multi_rotation_params = load_centre_collect_with_top_n_params.multi_rotation_scan + sweeps = multi_rotation_params.rotation_scans + for i in range(0, len(expected_scans)): + sweep_params = sweeps[i % len(sweeps)] + expected_scans[i]["nexus_vds_start_img"] = ( + sweep_params.scan_width_deg * 10 + ) * i + rotation_scan_params = mock_multi_rotation_scan.mock_calls[0].args[1] + assert isinstance(rotation_scan_params, RotationScan) + _compare_rotation_scans(expected_scans, rotation_scan_params.rotation_scans) + assert rotation_scan_params.transmission_frac == 0.05 -@pytest.mark.parametrize( - "zocalo_result", - [ - _with_sample_ids( - TEST_RESULT_IN_BOUNDS_TOP_LEFT_BOX + TEST_RESULT_MEDIUM + TEST_RESULT_SMALL, + +@patch( + "mx_bluesky.hyperion.experiment_plans.robot_load_then_centre_plan.pin_centre_then_flyscan_plan", + new=MagicMock( + side_effect=lambda *args, **kwargs: iter( [ - SimConstants.ST_MSP_SAMPLE_IDS[0], - SimConstants.ST_MSP_SAMPLE_IDS[0], - SimConstants.ST_MSP_SAMPLE_IDS[1], - ], + Msg( + "open_run", + xray_centre_results=[ + dataclasses.asdict(r) + for r in [ + FLYSCAN_RESULT_HIGH, + FLYSCAN_RESULT_MED, + ] + ], + run=CONST.PLAN.FLYSCAN_RESULTS, + ), + Msg("close_run"), + ] ) - ], + ), ) -@pytest.mark.system_test -def test_load_centre_collect_multisample_pin_reports_correct_sample_ids_robot_load( - zocalo_result, - load_centre_collect_composite: LoadCentreCollectComposite, - load_centre_collect_msp_params: LoadCentreCollect, +def test_load_centre_collect_full_plan_assigns_sample_ids_to_rotations_according_to_zocalo_assignment( + mock_multi_rotation_scan: MagicMock, + sim_run_engine: RunEngineSimulator, + composite: LoadCentreCollectComposite, + load_centre_collect_with_top_n_for_each_sample: LoadCentreCollect, oav_parameters_for_rotation: OAVParameters, - RE: RunEngine, - robot_load_cb: RobotLoadISPyBCallback, ): - load_centre_collect_composite.zocalo.my_zocalo_result = zocalo_result - ispyb_gridscan_cb = GridscanISPyBCallback( - param_type=GridCommonWithHyperionDetectorParams - ) - ispyb_rotation_cb = RotationISPyBCallback() - snapshot_cb = BeamDrawingCallback(emit=ispyb_rotation_cb) - RE.subscribe(ispyb_gridscan_cb) - RE.subscribe(snapshot_cb) - RE.subscribe(robot_load_cb) - - RE( + sim_run_engine.add_handler_for_callback_subscribes() + sim_fire_event_on_open_run(sim_run_engine, CONST.PLAN.FLYSCAN_RESULTS) + sim_run_engine.simulate_plan( load_centre_collect_full( - load_centre_collect_composite, - load_centre_collect_msp_params, + composite, + load_centre_collect_with_top_n_for_each_sample, oav_parameters_for_rotation, ) ) - expected_sample_id = load_centre_collect_msp_params.sample_id - expected_proposal, expected_visit = get_proposal_and_session_from_visit_string( - load_centre_collect_msp_params.visit - ) - robot_load_cb.expeye.start_robot_action.assert_called_once_with( # type: ignore - "LOAD", - expected_proposal, - expected_visit, - expected_sample_id, - ) + parameters: RotationScan = mock_multi_rotation_scan.mock_calls[0].args[1] + assert len(parameters.rotation_scans) == 4 + assert [ + (rs.x_start_um, rs.y_start_um, rs.z_start_um) + for rs in parameters.rotation_scans + ] == [ + (100.0, 200.0, 300.0), + (100.0, 200.0, 300.0), + (400.0, 500.0, 600.0), + (400.0, 500.0, 600.0), + ] + assert [rs.sample_id for rs in parameters.rotation_scans] == [2, 2, 1, 1] -@pytest.mark.parametrize( - "zocalo_result", - [ - _with_sample_ids( - TEST_RESULT_IN_BOUNDS_TOP_LEFT_BOX + TEST_RESULT_MEDIUM + TEST_RESULT_SMALL, + +@patch( + "mx_bluesky.hyperion.experiment_plans.robot_load_then_centre_plan.pin_centre_then_flyscan_plan", + new=MagicMock( + side_effect=lambda *args, **kwargs: iter( [ - SimConstants.ST_MSP_SAMPLE_IDS[0], - SimConstants.ST_MSP_SAMPLE_IDS[0], - SimConstants.ST_MSP_SAMPLE_IDS[1], - ], + Msg( + "open_run", + xray_centre_results=[ + dataclasses.asdict(r) + for r in [ + FLYSCAN_RESULT_HIGH_NO_SAMPLE_ID, + FLYSCAN_RESULT_MED, + ] + ], + run=CONST.PLAN.FLYSCAN_RESULTS, + ), + Msg("close_run"), + ] ) - ], -) -@pytest.mark.system_test -@patch( - "mx_bluesky.hyperion.experiment_plans.rotation_scan_plan._move_and_rotation", - new=MagicMock(side_effect=AssertionError("Simulated error in rotation")), + ), ) -def test_load_centre_collect_multisample_pin_updates_sample_status_for_parent_sample_when_error_in_rotation_on_child_sample( - zocalo_result, - load_centre_collect_composite: LoadCentreCollectComposite, - load_centre_collect_msp_params: LoadCentreCollect, +def test_load_centre_collect_full_plan_omits_collection_if_no_sample_id_is_assigned( + mock_multi_rotation_scan: MagicMock, + sim_run_engine: RunEngineSimulator, + composite: LoadCentreCollectComposite, + load_centre_collect_with_top_n_for_each_sample: LoadCentreCollect, oav_parameters_for_rotation: OAVParameters, - RE: RunEngine, - robot_load_cb: RobotLoadISPyBCallback, - fetch_blsample: Callable[..., Any], ): - load_centre_collect_composite.zocalo.my_zocalo_result = zocalo_result - ispyb_gridscan_cb = GridscanISPyBCallback( - param_type=GridCommonWithHyperionDetectorParams - ) - ispyb_rotation_cb = RotationISPyBCallback() - snapshot_cb = BeamDrawingCallback(emit=ispyb_rotation_cb) - sample_handling_cb = SampleHandlingCallback() - RE.subscribe(ispyb_gridscan_cb) - RE.subscribe(snapshot_cb) - RE.subscribe(robot_load_cb) - RE.subscribe(sample_handling_cb) - - unpatched_move_and_rotation = _move_and_rotation - num_calls = 0 - - def throw_on_third_call_wrapper(plan, *args, **kwargs): - nonlocal num_calls - num_calls += 1 - if num_calls == 3: - raise AssertionError("Simulated error in rotation") - yield from plan(*args, **kwargs) - - with patch( - "mx_bluesky.hyperion.experiment_plans.rotation_scan_plan._move_and_rotation", - partial(throw_on_third_call_wrapper, unpatched_move_and_rotation), - ): - with pytest.raises(AssertionError, match="Simulated error in rotation"): - RE( - load_centre_collect_full( - load_centre_collect_composite, - load_centre_collect_msp_params, - oav_parameters_for_rotation, - ) - ) - - assert ( - fetch_blsample(SimConstants.ST_MSP_SAMPLE_IDS[0]).blSampleStatus - == "ERROR - beamline" - ) - - -@pytest.fixture -def patch_detect_grid_and_do_gridscan_with_detected_pin_position( - load_centre_collect_composite: LoadCentreCollectComposite, -): - wrapped = detect_grid_and_do_gridscan - - # Before we do the grid scan, pretend we detected the pin at this position and move to it - # This is the base snapshot position - def wrapper(*args, **kwargs): - yield from bps.mv( - load_centre_collect_composite.smargon.x, - -0.614, - load_centre_collect_composite.smargon.y, - 0.0259, - load_centre_collect_composite.smargon.z, - 0.250, + sim_run_engine.add_handler_for_callback_subscribes() + sim_fire_event_on_open_run(sim_run_engine, CONST.PLAN.FLYSCAN_RESULTS) + sim_run_engine.simulate_plan( + load_centre_collect_full( + composite, + load_centre_collect_with_top_n_for_each_sample, + oav_parameters_for_rotation, ) + ) - yield from wrapped(*args, **kwargs) + parameters: RotationScan = mock_multi_rotation_scan.mock_calls[0].args[1] + assert len(parameters.rotation_scans) == 2 + assert [ + (rs.x_start_um, rs.y_start_um, rs.z_start_um) + for rs in parameters.rotation_scans + ] == [ + (400.0, 500.0, 600.0), + (400.0, 500.0, 600.0), + ] - with patch( - "mx_bluesky.hyperion.experiment_plans.pin_centre_then_xray_centre_plan.detect_grid_and_do_gridscan", - ) as patched_detect_grid: - patched_detect_grid.side_effect = wrapper - yield patched_detect_grid + assert [rs.sample_id for rs in parameters.rotation_scans] == [1, 1] -@pytest.fixture -def grid_detect_for_snapshot_generation(): - fake_grid_params = GridParamUpdate( - x_start_um=-598.4, - y_start_um=-215.3, - y2_start_um=-215.3, - z_start_um=150.6, - z2_start_um=150.6, - x_steps=30, - y_steps=20, - z_steps=13, - x_step_size_um=20, - y_step_size_um=20, - z_step_size_um=20, +def _compare_rotation_scans( + expected_rotation_scans: Sequence[dict], + actual_rotation_scans: Sequence[RotationScanPerSweep], +): + for expected, rotation_scan in zip( + expected_rotation_scans, actual_rotation_scans, strict=False + ): + assert rotation_scan.omega_start_deg == expected["omega_start_deg"] + assert rotation_scan.chi_start_deg == expected["chi_start_deg"] + assert rotation_scan.x_start_um == expected["x_start_um"] + assert rotation_scan.y_start_um == expected["y_start_um"] + assert rotation_scan.z_start_um == expected["z_start_um"] + assert rotation_scan.nexus_vds_start_img == expected["nexus_vds_start_img"] + assert rotation_scan.rotation_direction == expected["rotation_direction"] + + +@patch("mx_bluesky.common.parameters.components.os.makedirs") +def test_load_centre_collect_creates_storage_directory_if_not_present( + mock_makedirs, tmp_path +): + params = raw_params_from_file( + "tests/test_data/parameter_json_files/good_test_load_centre_collect_params.json", + tmp_path, ) - with patch( - "mx_bluesky.common.experiment_plans.common_grid_detect_then_xray_centre_plan.GridDetectionCallback" - ) as gdc: - gdc.return_value.get_grid_parameters.return_value = fake_grid_params - yield fake_grid_params - + LoadCentreCollect(**params) -class TestGenerateSnapshot: - @pytest.fixture() - def test_config_files(self): - return { - "zoom_params_file": "tests/test_data/test_jCameraManZoomLevels.xml", - "oav_config_json": "tests/test_data/test_daq_configuration/OAVCentring_hyperion.json", - "display_config": "tests/test_data/test_daq_configuration/display.configuration", - } - - @pytest.mark.system_test - def test_load_centre_collect_generate_rotation_snapshots( - self, - load_centre_collect_composite: LoadCentreCollectComposite, - load_centre_collect_params: LoadCentreCollect, - grid_detect_for_snapshot_generation: GridParamUpdate, - patch_detect_grid_and_do_gridscan_with_detected_pin_position: MagicMock, - next_oav_system_test_image: MagicMock, - RE: RunEngine, - tmp_path: Path, - test_config_files: dict, - fetch_datacollection_attribute: Callable[..., Any], - fetch_datacollection_ids_for_group_id: Callable[..., Any], - ): - oav_parameters = OAVParameters( - oav_config_json=test_config_files["oav_config_json"], - context="xrayCentring", - ) - next_fake_snapshot = iter( - [ - # 1 extra for robot load - "tests/test_data/test_images/thau_1_91_0.png", - "tests/test_data/test_images/thau_1_91_90.png", - "tests/test_data/test_images/thau_1_91_0.png", - ] - ) + mock_makedirs.assert_has_calls( + [ + call( + str(tmp_path / "123458/xraycentring"), + exist_ok=True, + ) + ], + any_order=True, + ) + mock_makedirs.assert_has_calls( + [call(f"{str(tmp_path)}/123458/", exist_ok=True)], + any_order=True, + ) - next_oav_system_test_image.side_effect = lambda: next(next_fake_snapshot) - load_centre_collect_params.multi_rotation_scan.snapshot_directory = tmp_path - load_centre_collect_params.robot_load_then_centre.snapshot_directory = ( - tmp_path / "grid_snapshots" - ) - os.mkdir(load_centre_collect_params.robot_load_then_centre.snapshot_directory) - load_centre_collect_params.multi_rotation_scan.use_grid_snapshots = True - load_centre_collect_params.multi_rotation_scan.snapshot_omegas_deg = None - load_centre_collect_composite.zocalo.my_zocalo_result = ( - SNAPSHOT_GENERATION_ZOCALO_RESULT - ) +@pytest.mark.timeout(2) +@patch( + "mx_bluesky.hyperion.experiment_plans.pin_centre_then_xray_centre_plan.detect_grid_and_do_gridscan" +) +@patch( + "mx_bluesky.hyperion.experiment_plans.load_centre_collect_full_plan.rotation_scan_internal", + MagicMock(), +) +def test_box_size_passed_through_to_gridscan( + mock_detect_grid: MagicMock, + composite: LoadCentreCollectComposite, + load_centre_collect_params: LoadCentreCollect, + oav_parameters_for_rotation: OAVParameters, + RE: RunEngine, +): + load_centre_collect_params.robot_load_then_centre.box_size_um = 25 - ispyb_gridscan_cb = GridscanISPyBCallback( - param_type=GridCommonWithHyperionDetectorParams - ) - ispyb_rotation_cb = RotationISPyBCallback() - snapshot_callback = BeamDrawingCallback(emit=ispyb_rotation_cb) - RE.subscribe(ispyb_gridscan_cb) - RE.subscribe(snapshot_callback) - RE( - load_centre_collect_full( - load_centre_collect_composite, - load_centre_collect_params, - oav_parameters, - ) + RE( + load_centre_collect_full( + composite, load_centre_collect_params, oav_parameters_for_rotation ) + ) + detect_grid_call = mock_detect_grid.mock_calls[0] + assert detect_grid_call.args[1].box_size_um == 25 - EXPECTED_GRID_SNAPSHOT_VALUES_0 = { - "xtalSnapshotFullPath1": f"regex:{tmp_path}/grid_snapshots/robot_load_centring_file_1_90_grid_overlay.png", - "xtalSnapshotFullPath2": f"regex:{tmp_path}/grid_snapshots/robot_load_centring_file_1_90_outer_overlay.png", - "xtalSnapshotFullPath3": f"regex:{tmp_path}/grid_snapshots/robot_load_centring_file_1_90.png", - } - EXPECTED_GRID_SNAPSHOT_VALUES_1 = { - "xtalSnapshotFullPath1": f"regex:{tmp_path}/grid_snapshots/robot_load_centring_file_1_0_grid_overlay.png", - "xtalSnapshotFullPath2": f"regex:{tmp_path}/grid_snapshots/robot_load_centring_file_1_0_outer_overlay.png", - "xtalSnapshotFullPath3": f"regex:{tmp_path}/grid_snapshots/robot_load_centring_file_1_0.png", - } - grid_dcg_id = ispyb_gridscan_cb.ispyb_ids.data_collection_group_id - grid_dc_ids = fetch_datacollection_ids_for_group_id(grid_dcg_id) - compare_actual_and_expected( - grid_dc_ids[0], - EXPECTED_GRID_SNAPSHOT_VALUES_0, - fetch_datacollection_attribute, - ) - compare_actual_and_expected( - grid_dc_ids[1], - EXPECTED_GRID_SNAPSHOT_VALUES_1, - fetch_datacollection_attribute, - ) - EXPECTED_ROTATION_SNAPSHOT_VALUES = { - "xtalSnapshotFullPath1": f"regex:{tmp_path}/\\d{{8}}_oav_snapshot_robot_load_centring_file_1_90\\.png", - "xtalSnapshotFullPath2": f"regex:{tmp_path}/\\d{{8}}_oav_snapshot_robot_load_centring_file_1_0\\.png", - } +@patch( + "mx_bluesky.hyperion.experiment_plans.load_centre_collect_full_plan.rotation_scan_internal", + return_value=iter([]), +) +@patch( + "mx_bluesky.hyperion.experiment_plans.load_centre_collect_full_plan.robot_load_then_xray_centre", + return_value=iter([]), +) +def test_load_centre_collect_full_collects_at_current_location_if_no_xray_centring_required( + _: MagicMock, + mock_rotation_scan: MagicMock, + composite: LoadCentreCollectComposite, + load_centre_collect_params: LoadCentreCollect, + oav_parameters_for_rotation: OAVParameters, + sim_run_engine: RunEngineSimulator, +): + sim_run_engine.add_read_handler_for(composite.smargon.x, 1.1) + sim_run_engine.add_read_handler_for(composite.smargon.y, 2.2) + sim_run_engine.add_read_handler_for(composite.smargon.z, 3.3) - rotation_dcg_id = ispyb_rotation_cb.ispyb_ids.data_collection_group_id - rotation_dc_ids = fetch_datacollection_ids_for_group_id(rotation_dcg_id) - compare_actual_and_expected( - rotation_dc_ids[0], - EXPECTED_ROTATION_SNAPSHOT_VALUES, - fetch_datacollection_attribute, - ) - compare_actual_and_expected( - rotation_dc_ids[1], - EXPECTED_ROTATION_SNAPSHOT_VALUES, - fetch_datacollection_attribute, + sim_run_engine.simulate_plan( + load_centre_collect_full( + composite, load_centre_collect_params, oav_parameters_for_rotation ) + ) - for expected_path, actual_path in zip( - [ - "tests/test_data/test_images/thau_1_91_expected_270.png", - "tests/test_data/test_images/thau_1_91_expected_270.png", - "tests/test_data/test_images/thau_1_91_expected_0.png", - "tests/test_data/test_images/thau_1_91_expected_0.png", - ], - [ - fetch_datacollection_attribute(rotation_dc_ids[i], col) - for col in ["xtalSnapshotFullPath1", "xtalSnapshotFullPath2"] - for i in (0, 1) - ], - strict=False, - ): - assert_images_pixelwise_equal(actual_path, expected_path) + rotation_scans = mock_rotation_scan.call_args.args[1].rotation_scans + assert len(rotation_scans) == 1 + assert rotation_scans[0].x_start_um == 1100 + assert rotation_scans[0].y_start_um == 2200 + assert rotation_scans[0].z_start_um == 3300 From 43590d167d468b1a06af5735f4fe6c96bddc71a7 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Thu, 2 Oct 2025 09:33:36 +0000 Subject: [PATCH 42/64] Add test that pedestal mode is turned off on exception --- .../jungfrau_commissioning/test_do_darks.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py index 10ac7fd774..7a906ca8bb 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import bluesky.plan_stubs as bps +import pytest from bluesky.callbacks import CallbackBase from bluesky.preprocessors import monitor_during_wrapper, run_decorator from bluesky.run_engine import RunEngine @@ -94,3 +95,31 @@ def test_plan(): GainMode.DYNAMIC, ] mock_override_path.assert_called_once_with(jungfrau, test_path) + + +class TestException(Exception): ... + + +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks.fly_jungfrau", + side_effect=TestException, +) +@patch("mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks.override_file_path") +async def test_pedestal_mode_turned_off_on_exception( + mock_fly: MagicMock, + mock_override_path: MagicMock, + jungfrau: CommissioningJungfrau, + RE: RunEngine, +): + await jungfrau.drv.pedestal_mode_state.set(PedestalMode.ON) + await jungfrau.drv.acquisition_type.set(AcquisitionType.PEDESTAL) + + @run_decorator() + def test_plan(): + yield from do_pedestal_darks(0.001, 2, 2, jungfrau) + + with pytest.raises(TestException): + RE(test_plan()) + + assert await jungfrau.drv.pedestal_mode_state.get_value() == PedestalMode.OFF + assert await jungfrau.drv.acquisition_type.get_value() == AcquisitionType.STANDARD From 69bfe2ff94a7534c38c6273545edd58f69af6dd2 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Thu, 2 Oct 2025 09:52:05 +0000 Subject: [PATCH 43/64] Create fixture for enum attenuator --- tests/conftest.py | 25 ++++++++++++++++++- .../test_rotation_scan.py | 9 ++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f22d896695..0036dbc910 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,7 +33,14 @@ ApertureScatterguard, ApertureValue, ) -from dodal.devices.attenuator.attenuator import BinaryFilterAttenuator +from dodal.devices.attenuator.attenuator import ( + BinaryFilterAttenuator, + EnumFilterAttenuator, +) +from dodal.devices.attenuator.filter_selections import ( + I24_FilterOneSelections, + I24_FilterTwoSelections, +) from dodal.devices.backlight import Backlight from dodal.devices.baton import Baton from dodal.devices.detector.detector_motion import DetectorMotion @@ -68,6 +75,7 @@ AsyncStatus, Device, DeviceVector, + init_devices, ) from ophyd_async.epics.core import epics_signal_rw from ophyd_async.epics.motor import Motor @@ -864,6 +872,21 @@ def zocalo(done_status, RE: RunEngine): return zoc +@pytest.fixture +async def enum_attenuator(RE: RunEngine) -> EnumFilterAttenuator: + with init_devices(mock=True): + attenuator = EnumFilterAttenuator( + "", filter_selection=(I24_FilterOneSelections, I24_FilterTwoSelections) + ) + + @AsyncStatus.wrap + async def fake_attenuator_set(val): + set_mock_value(attenuator.actual_transmission, val) + + attenuator.set = MagicMock(side_effect=fake_attenuator_set) + return attenuator + + @pytest.fixture async def panda(RE: RunEngine): class MockBlock(Device): diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py index a164c95746..b895267a7a 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py @@ -1,7 +1,9 @@ import pytest from bluesky.run_engine import RunEngine from dodal.beamlines.i24 import VerticalGoniometer -from dodal.beamlines.i24 import attenuator as i24_attenuator +from dodal.devices.attenuator.attenuator import ( + EnumFilterAttenuator, +) from dodal.devices.hutch_shutter import HutchShutter, ShutterState from dodal.devices.i24.aperture import Aperture from dodal.devices.i24.beamstop import Beamstop @@ -36,11 +38,10 @@ def get_good_rotation_params(tmp_path): @pytest.fixture def rotation_composite( - jungfrau: CommissioningJungfrau, zebra: Zebra + jungfrau: CommissioningJungfrau, zebra: Zebra, enum_attenuator: EnumFilterAttenuator ) -> RotationScanComposite: with init_devices(mock=True): aperture = Aperture("") - attenuator = i24_attenuator() gonio = VerticalGoniometer("") synchrotron = Synchrotron("") sample_shutter = ZebraShutter("") @@ -57,7 +58,7 @@ def rotation_composite( composite = RotationScanComposite( aperture, - attenuator, + enum_attenuator, jungfrau, gonio, synchrotron, From 27a30764634d530d9707e9b3ff43120f4fbdad70 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Thu, 2 Oct 2025 09:53:28 +0000 Subject: [PATCH 44/64] rename exception --- .../beamlines/i24/jungfrau_commissioning/test_do_darks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py index 7a906ca8bb..426fd57d38 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py @@ -97,12 +97,12 @@ def test_plan(): mock_override_path.assert_called_once_with(jungfrau, test_path) -class TestException(Exception): ... +class FakeException(Exception): ... @patch( "mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks.fly_jungfrau", - side_effect=TestException, + side_effect=FakeException, ) @patch("mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks.override_file_path") async def test_pedestal_mode_turned_off_on_exception( @@ -118,7 +118,7 @@ async def test_pedestal_mode_turned_off_on_exception( def test_plan(): yield from do_pedestal_darks(0.001, 2, 2, jungfrau) - with pytest.raises(TestException): + with pytest.raises(FakeException): RE(test_plan()) assert await jungfrau.drv.pedestal_mode_state.get_value() == PedestalMode.OFF From 49de8b8e87e9d39d5d22befab6c4e38e38d5ff43 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Thu, 2 Oct 2025 15:26:18 +0000 Subject: [PATCH 45/64] relentless testing --- .../rotation_scan_plan.py | 242 ++++++++++-------- .../beamlines/i24/parameters/constants.py | 2 + .../test_data/test_good_rotation_params.json | 5 +- .../test_rotation_scan.py | 175 +++++++++++-- 4 files changed, 295 insertions(+), 129 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py index 0c652146c5..94e72bf3ce 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py @@ -5,6 +5,7 @@ import bluesky.plan_stubs as bps import bluesky.preprocessors as bpp +from bluesky.preprocessors import run_decorator from dodal.devices.hutch_shutter import ShutterState from dodal.devices.i24.aperture import AperturePositions from dodal.devices.i24.beamstop import BeamstopPositions @@ -103,12 +104,17 @@ def multi_rotation_plan_varying_transmission( composite: RotationScanComposite, params: MultiRotationScanByTransmissions, ): - for transmission in params.transmission_fractions: - param_copy = deepcopy(params).model_dump() - del param_copy["transmission_fractions"] - param_copy["transmission_frac"] = transmission - single_rotation_params = SingleRotationScan(**param_copy) - yield from single_rotation_plan(composite, single_rotation_params) + @bpp.set_run_key_decorator(I24PlanNameConstants.MULTI_ROTATION_SCAN) + @run_decorator() + def _plan_in_run_decorator(): + for transmission in params.transmission_fractions: + param_copy = deepcopy(params).model_dump() + del param_copy["transmission_fractions"] + param_copy["transmission_frac"] = transmission + single_rotation_params = SingleRotationScan(**param_copy) + yield from single_rotation_plan(composite, single_rotation_params) + + yield from _plan_in_run_decorator() def single_rotation_plan( @@ -119,121 +125,135 @@ def single_rotation_plan( about a fixed axis - for now this axis is limited to omega. Needs additional setup of the sample environment and a wrapper to clean up.""" - if not params.detector_distance_mm: - LOGGER.info( - f"Using default detector distance of {DEFAULT_DETECTOR_DISTANCE_MM} mm" - ) - params.detector_distance_mm = DEFAULT_DETECTOR_DISTANCE_MM - - yield from set_up_beamline_for_rotation( - composite, params.detector_distance_mm, params.transmission_frac - ) - - # This value isn't actually used, see https://github.com/DiamondLightSource/mx-bluesky/issues/1224 - _motor_time_to_speed = 1 - _max_velocity_deg_s = yield from bps.rd(composite.gonio.omega.max_velocity) - - motion_values = calculate_motion_profile( - params, _motor_time_to_speed, _max_velocity_deg_s - ) - - @bpp.set_run_key_decorator(PlanNameConstants.ROTATION_MAIN) - @bpp.run_decorator( - md={ - "subplan_name": PlanNameConstants.ROTATION_MAIN, - "scan_points": [params.scan_points], - "rotation_scan_params": params.model_dump_json(), - } - ) - def _rotation_scan_plan( - motion_values: RotationMotionProfile, - composite: RotationScanComposite, - ): - _jf_trigger_info = create_jungfrau_external_triggering_info( - params.num_images, params.detector_params.exposure_time_s - ) - - axis = composite.gonio.omega - - # can move to start as fast as possible - yield from bps.abs_set( - axis.velocity, motion_values.max_velocity_deg_s, wait=True - ) - LOGGER.info(f"Moving omega to start value, {motion_values.start_scan_deg=}") - yield from bps.abs_set( - axis, - motion_values.start_motion_deg, - group=PlanGroupCheckpointConstants.ROTATION_READY_FOR_DC, - ) - - yield from setup_zebra_for_rotation( - composite.zebra, - composite.sample_shutter, - axis=I24Axes.OMEGA, - start_angle=motion_values.start_scan_deg, - scan_width=motion_values.scan_width_deg, - direction=motion_values.direction, - shutter_opening_deg=motion_values.shutter_opening_deg, - shutter_opening_s=motion_values.shutter_time_s, - group=PlanGroupCheckpointConstants.SETUP_ZEBRA_FOR_ROTATION, + @bpp.set_run_key_decorator(I24PlanNameConstants.SINGLE_ROTATION_SCAN) + @run_decorator() + def _plan_in_run_decorator(): + if not params.detector_distance_mm: + LOGGER.info( + f"Using default detector distance of {DEFAULT_DETECTOR_DISTANCE_MM} mm" + ) + params.detector_distance_mm = DEFAULT_DETECTOR_DISTANCE_MM + + yield from set_up_beamline_for_rotation( + composite, params.detector_distance_mm, params.transmission_frac ) - yield from bps.wait(PlanGroupCheckpointConstants.ROTATION_READY_FOR_DC) + # This value isn't actually used, see https://github.com/DiamondLightSource/mx-bluesky/issues/1224 + _motor_time_to_speed = 1 + _max_velocity_deg_s = yield from bps.rd(composite.gonio.omega.max_velocity) - # Get ready for the actual scan - yield from bps.abs_set( - axis.velocity, motion_values.speed_for_rotation_deg_s, wait=True - ) - yield from arm_zebra(composite.zebra) - - # Check topup gate - yield from check_topup_and_wait_if_necessary( - composite.synchrotron, - motion_values.total_exposure_s, - ops_time=10.0, # Additional time to account for rotation, is s - ) # See #https://github.com/DiamondLightSource/hyperion/issues/932 - - override_file_path( - composite.jungfrau, - f"{params.storage_directory}/{params.detector_params.full_filename}", + motion_values = calculate_motion_profile( + params, _motor_time_to_speed, _max_velocity_deg_s ) - metadata_writer = JsonMetadataWriter() - - @bpp.subs_decorator([metadata_writer]) - @bpp.set_run_key_decorator(I24PlanNameConstants.ROTATION_META_READ) + @bpp.set_run_key_decorator(PlanNameConstants.ROTATION_MAIN) @bpp.run_decorator( md={ - "subplan_name": I24PlanNameConstants.ROTATION_META_READ, + "subplan_name": PlanNameConstants.ROTATION_MAIN, "scan_points": [params.scan_points], "rotation_scan_params": params.model_dump_json(), } ) - # Write metadata json file - def _do_read(): - yield from read_devices_for_metadata(composite) - - yield from _do_read() - yield from fly_jungfrau( - composite.jungfrau, - _jf_trigger_info, - wait=False, - log_on_percentage_prefix="Jungfrau rotation scan triggers received", + def _rotation_scan_plan( + motion_values: RotationMotionProfile, + composite: RotationScanComposite, + ): + _jf_trigger_info = create_jungfrau_external_triggering_info( + params.num_images, params.detector_params.exposure_time_s + ) + + axis = composite.gonio.omega + + # can move to start as fast as possible + yield from bps.abs_set( + axis.velocity, motion_values.max_velocity_deg_s, wait=True + ) + LOGGER.info(f"Moving omega to start value, {motion_values.start_scan_deg=}") + yield from bps.abs_set( + axis, + motion_values.start_motion_deg, + group=PlanGroupCheckpointConstants.ROTATION_READY_FOR_DC, + ) + + yield from setup_zebra_for_rotation( + composite.zebra, + composite.sample_shutter, + axis=I24Axes.OMEGA, + start_angle=motion_values.start_scan_deg, + scan_width=motion_values.scan_width_deg, + direction=motion_values.direction, + shutter_opening_deg=motion_values.shutter_opening_deg, + shutter_opening_s=motion_values.shutter_time_s, + group=PlanGroupCheckpointConstants.SETUP_ZEBRA_FOR_ROTATION, + ) + + yield from bps.wait(PlanGroupCheckpointConstants.ROTATION_READY_FOR_DC) + + # Get ready for the actual scan + yield from bps.abs_set( + axis.velocity, motion_values.speed_for_rotation_deg_s, wait=True + ) + yield from arm_zebra(composite.zebra) + + # Check topup gate + yield from check_topup_and_wait_if_necessary( + composite.synchrotron, + motion_values.total_exposure_s, + ops_time=10.0, # Additional time to account for rotation, is s + ) # See #https://github.com/DiamondLightSource/hyperion/issues/932 + + override_file_path( + composite.jungfrau, + f"{params.storage_directory}/{params.detector_params.full_filename}", + ) + + metadata_writer = JsonMetadataWriter() + + @bpp.subs_decorator([metadata_writer]) + @bpp.set_run_key_decorator(I24PlanNameConstants.ROTATION_META_READ) + @bpp.run_decorator( + md={ + "subplan_name": I24PlanNameConstants.ROTATION_META_READ, + "scan_points": [params.scan_points], + "rotation_scan_params": params.model_dump_json(), + } + ) + # Write metadata json file + def _do_read(): + yield from read_devices_for_metadata(composite) + + yield from _do_read() + yield from fly_jungfrau( + composite.jungfrau, + _jf_trigger_info, + wait=False, + log_on_percentage_prefix="Jungfrau rotation scan triggers received", + ) + + LOGGER.info("Executing rotation scan") + yield from bps.rel_set( + axis, + motion_values.distance_to_move_deg, + wait=False, + group=JF_COMPLETE_GROUP, + ) + + LOGGER.info( + "Waiting for omega to finish moving and for Jungfrau to receive correct number of triggers" + ) + yield from bps.wait(group=JF_COMPLETE_GROUP) + + yield from bpp.finalize_wrapper( + _rotation_scan_plan(motion_values, composite), + final_plan=partial( + _cleanup_plan, + composite.zebra, + composite.jungfrau, + composite.sample_shutter, + ), ) - LOGGER.info("Executing rotation scan") - yield from bps.rel_set(axis, motion_values.distance_to_move_deg, wait=True) - - yield from bps.wait(group=JF_COMPLETE_GROUP) - - yield from bpp.finalize_wrapper( - _rotation_scan_plan(motion_values, composite), - final_plan=partial( - _cleanup_plan, composite.zebra, composite.jungfrau, composite.sample_shutter - ), - ) - - yield from _rotation_scan_plan(motion_values, composite) + yield from _plan_in_run_decorator() def _cleanup_plan( @@ -242,7 +262,9 @@ def _cleanup_plan( zebra_shutter: ZebraShutter, group="rotation cleanup", ): - LOGGER.info("Tidying up zebra and Jungfrau...") + LOGGER.info("Tidying up Zebra and Jungfrau...") yield from bps.unstage(jf, group=group) - yield from tidy_up_zebra_after_rotation_scan(zebra, zebra_shutter) + yield from tidy_up_zebra_after_rotation_scan( + zebra, zebra_shutter, group=group, wait=False + ) yield from bps.wait(group=group) diff --git a/src/mx_bluesky/beamlines/i24/parameters/constants.py b/src/mx_bluesky/beamlines/i24/parameters/constants.py index 14ca349581..ae5ec80d46 100644 --- a/src/mx_bluesky/beamlines/i24/parameters/constants.py +++ b/src/mx_bluesky/beamlines/i24/parameters/constants.py @@ -4,3 +4,5 @@ @dataclass(frozen=True) class PlanNameConstants: ROTATION_META_READ = "ROTATION_META_READ" + SINGLE_ROTATION_SCAN = "OUTER SINGLE ROTATION SCAN" + MULTI_ROTATION_SCAN = "OUTER MULTI ROTATION SCAN" diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_data/test_good_rotation_params.json b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_data/test_good_rotation_params.json index ffc703417b..503c2bfae7 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_data/test_good_rotation_params.json +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_data/test_good_rotation_params.json @@ -1,7 +1,7 @@ { "parameter_model_version": "5.0.0", "storage_directory": "{tmp_data}/123456/", - "detector_distance_mm": 100.0, + "detector_distance_mm": 200.0, "exposure_time_s": 0.1, "omega_start_deg": 45, "file_name": "file_name", @@ -11,6 +11,3 @@ "visit": "cm31105-4", "transmission_frac": 0.1 } - - - diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py index b895267a7a..784704be55 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py @@ -1,9 +1,10 @@ +from unittest.mock import MagicMock, patch + import pytest from bluesky.run_engine import RunEngine +from bluesky.simulators import RunEngineSimulator, assert_message_and_return_remaining from dodal.beamlines.i24 import VerticalGoniometer -from dodal.devices.attenuator.attenuator import ( - EnumFilterAttenuator, -) +from dodal.devices.attenuator.attenuator import EnumFilterAttenuator from dodal.devices.hutch_shutter import HutchShutter, ShutterState from dodal.devices.i24.aperture import Aperture from dodal.devices.i24.beamstop import Beamstop @@ -20,14 +21,25 @@ from ophyd_async.testing import set_mock_value from tests.conftest import raw_params_from_file +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import JF_COMPLETE_GROUP from mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan import ( + DEFAULT_DETECTOR_DISTANCE_MM, RotationScanComposite, + multi_rotation_plan_varying_transmission, single_rotation_plan, ) +from mx_bluesky.beamlines.i24.parameters.constants import PlanNameConstants +from mx_bluesky.beamlines.i24.parameters.rotation import ( + MultiRotationScanByTransmissions, +) +from mx_bluesky.common.experiment_plans.rotation.rotation_utils import ( + calculate_motion_profile, +) +from mx_bluesky.common.parameters.constants import PlanGroupCheckpointConstants from mx_bluesky.common.parameters.rotation import SingleRotationScan -def get_good_rotation_params(tmp_path): +def get_good_single_rotation_params(tmp_path): params = raw_params_from_file( "tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_data/test_good_rotation_params.json", tmp_path, @@ -36,6 +48,16 @@ def get_good_rotation_params(tmp_path): return SingleRotationScan(**params) +def get_good_multi_rotation_params(transmissions: list[float], tmp_path): + params = raw_params_from_file( + "tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_data/test_good_rotation_params.json", + tmp_path, + ) + del params["transmission_frac"] + params["transmission_fractions"] = [0.2, 0.4, 0.6] + return MultiRotationScanByTransmissions(**params) + + @pytest.fixture def rotation_composite( jungfrau: CommissioningJungfrau, zebra: Zebra, enum_attenuator: EnumFilterAttenuator @@ -48,7 +70,7 @@ def rotation_composite( xbpm_feedback = XBPMFeedback("") hutch_shutter = HutchShutter("") beamstop = Beamstop("") - det_stage = YZStage("") # TODO add JF position to det stage device + det_stage = YZStage("") backlight = DualBacklight("") dcm = DCM("", "") @@ -75,28 +97,151 @@ def rotation_composite( return composite -def test_single_rotation_plan_in_re( - RE: RunEngine, tmp_path, rotation_composite: RotationScanComposite +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan._cleanup_plan" +) +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.check_topup_and_wait_if_necessary" +) +@patch("mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.arm_zebra") +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.setup_zebra_for_rotation" +) +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.calculate_motion_profile" +) +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.set_up_beamline_for_rotation" +) +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.fly_jungfrau" +) +async def test_single_rotation_plan_in_re( + mock_fly: MagicMock, + mock_setup_beamline: MagicMock, + mock_calc_motion_profile: MagicMock, + mock_setup_zebra: MagicMock, + mock_arm_zebra: MagicMock, + mock_check_topup: MagicMock, + mock_cleanup: MagicMock, + RE: RunEngine, + tmp_path, + rotation_composite: RotationScanComposite, ): - params = get_good_rotation_params(tmp_path) - set_mock_value(rotation_composite.jungfrau._writer.frame_counter, params.num_images) - set_mock_value(rotation_composite.hutch_shutter.status, ShutterState.OPEN) + # Test correct functions are called, but don't test bluesky messages + params = get_good_single_rotation_params(tmp_path) + mock_calc_motion_profile.return_value = calculate_motion_profile(params, 1, 1) RE(single_rotation_plan(rotation_composite, params)) + mock_setup_beamline.assert_called_once_with( + rotation_composite, DEFAULT_DETECTOR_DISTANCE_MM, 0.1 + ) + mock_calc_motion_profile.assert_called_once_with( + params, 1, await rotation_composite.gonio.omega.max_velocity.get_value() + ) + mock_setup_zebra.assert_called_once() + mock_arm_zebra.assert_called_once() + mock_check_topup.assert_called_once() + mock_fly.assert_called_once() + mock_cleanup.assert_called_once() -def test_metadata_writer_produces_correct_json_after_plan(): ... +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.check_topup_and_wait_if_necessary" +) +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.set_up_beamline_for_rotation" +) +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.fly_jungfrau" +) +def test_single_rotation_plan_in_simulator( + _mock_fly: MagicMock, + _mock_set_up_beamline_for_rotation: MagicMock, + _mock_topup: MagicMock, + sim_run_engine: RunEngineSimulator, + rotation_composite: RotationScanComposite, + tmp_path, +): + params = get_good_single_rotation_params(tmp_path) + set_mock_value(rotation_composite.hutch_shutter.status, ShutterState.OPEN) + msgs = sim_run_engine.simulate_plan( + single_rotation_plan(rotation_composite, params) + ) + + assert_message_and_return_remaining( + msgs, + lambda msg: msg.command == "open_run" + and msg.run == "OUTER SINGLE ROTATION SCAN", + ) + + # Wait for rotation devices to be ready before reading metadata + assert_message_and_return_remaining( + msgs, + lambda msg: msg.command == "wait" + and msg.kwargs["group"] == PlanGroupCheckpointConstants.ROTATION_READY_FOR_DC, + ) + assert_message_and_return_remaining( + msgs, + lambda msg: msg.command == "create" + and msg.kwargs["name"] == PlanNameConstants.ROTATION_META_READ, + ) + # Set omega axis then wait for JF to complete + assert_message_and_return_remaining( + msgs, + lambda msg: msg.command == "set" and msg.obj == rotation_composite.gonio.omega, + ) + assert_message_and_return_remaining( + msgs, + lambda msg: msg.command == "wait" and msg.kwargs["group"] == JF_COMPLETE_GROUP, + ) -def test_set_up_beamline_for_rotation_in_re(): ... + # Unstage JF and close run + assert_message_and_return_remaining( + msgs, + lambda msg: msg.command == "unstage" and msg.obj == rotation_composite.jungfrau, + ) + assert_message_and_return_remaining( + msgs, + lambda msg: msg.command == "close_run" + and msg.run == PlanNameConstants.SINGLE_ROTATION_SCAN, + ) + + +def test_devices_are_unstaged_on_exception(): ... + + +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.single_rotation_plan" +) +def test_multi_rotation_plan_in_re( + mock_single_rotation: MagicMock, + RE: RunEngine, + tmp_path, + rotation_composite: RotationScanComposite, +): + desired_transmission_fracs = [0.2, 0.4, 0.6] + params = get_good_multi_rotation_params(desired_transmission_fracs, tmp_path) + set_mock_value(rotation_composite.jungfrau._writer.frame_counter, params.num_images) + set_mock_value(rotation_composite.hutch_shutter.status, ShutterState.OPEN) + RE(multi_rotation_plan_varying_transmission(rotation_composite, params)) + called_transmission_fracs = [ + mock_single_rotation.call_args_list[i].args[1].transmission_frac + for i in range(mock_single_rotation.call_count) + ] + assert desired_transmission_fracs == called_transmission_fracs + + +def test_metadata_writer_produces_correct_json_after_plan(): ... -def test_set_up_beamline_for_rotation_in_simulator(): ... +def test_set_up_beamline_for_rotation_success(): ... # use RE -def test_single_rotation_plan_error_if_no_det_distance(): ... +def test_set_up_beamline_for_rotation_error_on_closed_hutch(): ... -def test_multi_rotation_plan_varying_transmission(): ... +def test_single_rotation_plan_uses_default_if_no_det_distance(): ... def test_cleanup_plan(): ... From 4c58ff449eb40c2cae896f7a54a7d8d3202a3f3d Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Thu, 2 Oct 2025 16:26:43 +0000 Subject: [PATCH 46/64] Add metadata writer test --- .../callbacks/metadata_writer.py | 2 +- .../rotation_scan_plan.py | 1 - .../i24/jungfrau_commissioning/__init__.py | 0 .../callbacks/__init__.py | 0 .../callbacks/test_metadata_writer.py | 69 +++++++++++++++++ .../i24/jungfrau_commissioning/conftest.py | 60 +++++++++++++++ .../test_rotation_scan.py | 74 ++----------------- .../i24/jungfrau_commissioning/utils.py | 11 +++ 8 files changed, 146 insertions(+), 71 deletions(-) create mode 100644 tests/unit_tests/beamlines/i24/jungfrau_commissioning/__init__.py create mode 100644 tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/__init__.py create mode 100644 tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/test_metadata_writer.py create mode 100644 tests/unit_tests/beamlines/i24/jungfrau_commissioning/conftest.py create mode 100644 tests/unit_tests/beamlines/i24/jungfrau_commissioning/utils.py diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py index 1eb601dea0..530029942c 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py @@ -57,7 +57,7 @@ def event(self, doc: dict): # type: ignore assert data is not None self.wavelength_in_a = data.get("dcm-wavelength_in_a") self.energy_in_kev = data.get("dcm-energy_in_kev") - self.detector_distance_mm = data.get("det-stage_z") + self.detector_distance_mm = data.get("det_stage-z") if self.detector_distance_mm: self.beam_xy = self.parameters.detector_params.get_beam_position_mm( diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py index 94e72bf3ce..25298851b8 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py @@ -83,7 +83,6 @@ def set_up_beamline_for_rotation( LOGGER.info( "Making sure aperture and beamstop are in, detector stages are in position, backlight is out, and transmission is set..." ) - yield from bps.abs_set(composite.det_stage.y, JF_DET_STAGE_Y_POSITION_MM) yield from bps.mv( composite.aperture.position, AperturePositions.IN, diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/__init__.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/__init__.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/test_metadata_writer.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/test_metadata_writer.py new file mode 100644 index 0000000000..437981faeb --- /dev/null +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/test_metadata_writer.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import bluesky.preprocessors as bpp +from bluesky.run_engine import RunEngine +from numpy.testing import assert_allclose + +from mx_bluesky.beamlines.i24.jungfrau_commissioning.callbacks.metadata_writer import ( + JsonMetadataWriter, +) +from mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan import ( + READING_DUMP_FILENAME, + RotationScanComposite, +) +from mx_bluesky.beamlines.i24.jungfrau_commissioning.utility_plans import ( + read_devices_for_metadata, +) +from mx_bluesky.beamlines.i24.parameters.constants import ( + PlanNameConstants as I24PlanNameConstants, +) +from tests.unit_tests.beamlines.i24.jungfrau_commissioning.utils import ( + get_good_single_rotation_params, +) + + +async def test_metadata_writer_produces_correct_output( + RE: RunEngine, tmp_path, rotation_composite: RotationScanComposite +): + params = get_good_single_rotation_params(tmp_path) + metadata_writer = JsonMetadataWriter() + + @bpp.subs_decorator([metadata_writer]) + @bpp.set_run_key_decorator(I24PlanNameConstants.ROTATION_META_READ) + @bpp.run_decorator( + md={ + "subplan_name": I24PlanNameConstants.ROTATION_META_READ, + "scan_points": [params.scan_points], + "rotation_scan_params": params.model_dump_json(), + } + ) + # Write metadata json file + def _do_read(): + yield from read_devices_for_metadata(rotation_composite) + + wavelength = 1 + energy = 1 + det_z = 3 + + await rotation_composite.dcm.wavelength_in_a.set(wavelength) + await rotation_composite.dcm.energy_in_kev.set(energy) + await rotation_composite.det_stage.z.set(det_z) + beam_center = params.detector_params.get_beam_position_mm(det_z) + + expected_output = { + "wavelength_in_a": wavelength, + "energy_kev": energy, + "detector_distance_mm": det_z, + "angular_increment_deg": 0.1, + "beam_xy_mm": beam_center, + } + RE(_do_read()) + + with open(Path(params.storage_directory) / READING_DUMP_FILENAME) as f: + actual_output = json.load(f) + assert expected_output.keys() == actual_output.keys() + for key in actual_output: + assert_allclose(actual_output[key], expected_output[key]) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/conftest.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/conftest.py new file mode 100644 index 0000000000..a7779dc98d --- /dev/null +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/conftest.py @@ -0,0 +1,60 @@ +import pytest +from dodal.beamlines.i24 import VerticalGoniometer +from dodal.devices.attenuator.attenuator import EnumFilterAttenuator +from dodal.devices.hutch_shutter import HutchShutter +from dodal.devices.i24.aperture import Aperture +from dodal.devices.i24.beamstop import Beamstop +from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau +from dodal.devices.i24.dcm import DCM +from dodal.devices.i24.dual_backlight import DualBacklight +from dodal.devices.motors import YZStage +from dodal.devices.synchrotron import Synchrotron +from dodal.devices.xbpm_feedback import XBPMFeedback +from dodal.devices.zebra.zebra import Zebra +from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter +from dodal.testing import patch_all_motors +from ophyd_async.core import init_devices + +from mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan import ( + RotationScanComposite, +) + + +@pytest.fixture +def rotation_composite( + jungfrau: CommissioningJungfrau, zebra: Zebra, enum_attenuator: EnumFilterAttenuator +) -> RotationScanComposite: + with init_devices(mock=True): + aperture = Aperture("") + gonio = VerticalGoniometer("") + synchrotron = Synchrotron("") + sample_shutter = ZebraShutter("") + xbpm_feedback = XBPMFeedback("") + hutch_shutter = HutchShutter("") + beamstop = Beamstop("") + det_stage = YZStage("") + backlight = DualBacklight("") + dcm = DCM("", "") + + patch_all_motors(det_stage) + patch_all_motors(sample_shutter) + patch_all_motors(gonio) + patch_all_motors(dcm) + + composite = RotationScanComposite( + aperture, + enum_attenuator, + jungfrau, + gonio, + synchrotron, + sample_shutter, + zebra, + xbpm_feedback, + hutch_shutter, + beamstop, + det_stage, + backlight, + dcm, + ) + + return composite diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py index 784704be55..f9e03ea0ac 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py @@ -1,25 +1,9 @@ from unittest.mock import MagicMock, patch -import pytest from bluesky.run_engine import RunEngine from bluesky.simulators import RunEngineSimulator, assert_message_and_return_remaining -from dodal.beamlines.i24 import VerticalGoniometer -from dodal.devices.attenuator.attenuator import EnumFilterAttenuator -from dodal.devices.hutch_shutter import HutchShutter, ShutterState -from dodal.devices.i24.aperture import Aperture -from dodal.devices.i24.beamstop import Beamstop -from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau -from dodal.devices.i24.dcm import DCM -from dodal.devices.i24.dual_backlight import DualBacklight -from dodal.devices.motors import YZStage -from dodal.devices.synchrotron import Synchrotron -from dodal.devices.xbpm_feedback import XBPMFeedback -from dodal.devices.zebra.zebra import Zebra -from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter -from dodal.testing import patch_all_motors -from ophyd_async.core import init_devices +from dodal.devices.hutch_shutter import ShutterState from ophyd_async.testing import set_mock_value -from tests.conftest import raw_params_from_file from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import JF_COMPLETE_GROUP from mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan import ( @@ -36,16 +20,10 @@ calculate_motion_profile, ) from mx_bluesky.common.parameters.constants import PlanGroupCheckpointConstants -from mx_bluesky.common.parameters.rotation import SingleRotationScan - - -def get_good_single_rotation_params(tmp_path): - params = raw_params_from_file( - "tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_data/test_good_rotation_params.json", - tmp_path, - ) - - return SingleRotationScan(**params) +from tests.conftest import raw_params_from_file +from tests.unit_tests.beamlines.i24.jungfrau_commissioning.utils import ( + get_good_single_rotation_params, +) def get_good_multi_rotation_params(transmissions: list[float], tmp_path): @@ -58,45 +36,6 @@ def get_good_multi_rotation_params(transmissions: list[float], tmp_path): return MultiRotationScanByTransmissions(**params) -@pytest.fixture -def rotation_composite( - jungfrau: CommissioningJungfrau, zebra: Zebra, enum_attenuator: EnumFilterAttenuator -) -> RotationScanComposite: - with init_devices(mock=True): - aperture = Aperture("") - gonio = VerticalGoniometer("") - synchrotron = Synchrotron("") - sample_shutter = ZebraShutter("") - xbpm_feedback = XBPMFeedback("") - hutch_shutter = HutchShutter("") - beamstop = Beamstop("") - det_stage = YZStage("") - backlight = DualBacklight("") - dcm = DCM("", "") - - patch_all_motors(det_stage) - patch_all_motors(sample_shutter) - patch_all_motors(gonio) - - composite = RotationScanComposite( - aperture, - enum_attenuator, - jungfrau, - gonio, - synchrotron, - sample_shutter, - zebra, - xbpm_feedback, - hutch_shutter, - beamstop, - det_stage, - backlight, - dcm, - ) - - return composite - - @patch( "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan._cleanup_plan" ) @@ -208,9 +147,6 @@ def test_single_rotation_plan_in_simulator( ) -def test_devices_are_unstaged_on_exception(): ... - - @patch( "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.single_rotation_plan" ) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/utils.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/utils.py new file mode 100644 index 0000000000..08882c1c8d --- /dev/null +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/utils.py @@ -0,0 +1,11 @@ +from mx_bluesky.common.parameters.rotation import SingleRotationScan +from tests.conftest import raw_params_from_file + + +def get_good_single_rotation_params(tmp_path): + params = raw_params_from_file( + "tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_data/test_good_rotation_params.json", + tmp_path, + ) + + return SingleRotationScan(**params) From 038a8c88e2cec669c8c418884872e7e04c51bd78 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Thu, 2 Oct 2025 16:33:28 +0000 Subject: [PATCH 47/64] Add run decorator onto plan --- .../i24/jungfrau_commissioning/do_darks.py | 49 +++++++++++-------- .../jungfrau_commissioning/test_do_darks.py | 4 +- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py index 1e7d1603c9..1f7263fe31 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py @@ -18,6 +18,8 @@ ) from mx_bluesky.common.utils.log import LOGGER +PEDESTAL_DARKS_RUN = "PEDESTAL DARKS RUN" + def do_pedestal_darks( exp_time_s: float = 0.001, @@ -48,31 +50,36 @@ def do_pedestal_darks( wait: Optionally block until data collection is complete. """ - if path_of_output_file: - override_file_path(jungfrau, path_of_output_file) - - yield from bps.mv( - jungfrau.drv.acquisition_type, - AcquisitionType.PEDESTAL, - jungfrau.drv.gain_mode, - GainMode.DYNAMIC, - ) + @bpp.set_run_key_decorator(PEDESTAL_DARKS_RUN) + @bpp.run_decorator(md={"subplan_name": PEDESTAL_DARKS_RUN}) + def _do_decorated_plan(): + if path_of_output_file: + override_file_path(jungfrau, path_of_output_file) - trigger_info = create_jungfrau_pedestal_triggering_info( - exp_time_s, pedestal_frames, pedestal_loops - ) + yield from bps.mv( + jungfrau.drv.acquisition_type, + AcquisitionType.PEDESTAL, + jungfrau.drv.gain_mode, + GainMode.DYNAMIC, + ) - @bpp.finalize_decorator(final_plan=lambda: _revert_pedestal_mode(jungfrau)) - def _fly_then_revert_acquisition_type(): - status = yield from fly_jungfrau( - jungfrau, - trigger_info, - wait, - log_on_percentage_prefix="Jungfrau pedestal dynamic gain mode darks triggers recieved", + trigger_info = create_jungfrau_pedestal_triggering_info( + exp_time_s, pedestal_frames, pedestal_loops ) - return status - return (yield from _fly_then_revert_acquisition_type()) + @bpp.finalize_decorator(final_plan=lambda: _revert_pedestal_mode(jungfrau)) + def _fly_then_revert_acquisition_type(): + status = yield from fly_jungfrau( + jungfrau, + trigger_info, + wait, + log_on_percentage_prefix="Jungfrau pedestal dynamic gain mode darks triggers recieved", + ) + return status + + return (yield from _fly_then_revert_acquisition_type()) + + return (yield from _do_decorated_plan()) def _revert_pedestal_mode(jungfrau: CommissioningJungfrau): diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py index 426fd57d38..1076d6355a 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py @@ -5,7 +5,7 @@ import bluesky.plan_stubs as bps import pytest from bluesky.callbacks import CallbackBase -from bluesky.preprocessors import monitor_during_wrapper, run_decorator +from bluesky.preprocessors import monitor_during_wrapper from bluesky.run_engine import RunEngine from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau from ophyd_async.fastcs.jungfrau import ( @@ -44,7 +44,6 @@ async def test_full_do_pedestal_darks( # Test that plan succeeds in RunEngine and pedestal-specific signals are changed as expected test_path = "path" - @run_decorator() def test_plan(): status = yield from do_pedestal_darks(0.001, 2, 2, jungfrau, test_path) assert not status.done @@ -114,7 +113,6 @@ async def test_pedestal_mode_turned_off_on_exception( await jungfrau.drv.pedestal_mode_state.set(PedestalMode.ON) await jungfrau.drv.acquisition_type.set(AcquisitionType.PEDESTAL) - @run_decorator() def test_plan(): yield from do_pedestal_darks(0.001, 2, 2, jungfrau) From 90721b25c572dd00efe0c7dff06cf31a53f293d0 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Fri, 3 Oct 2025 10:25:14 +0000 Subject: [PATCH 48/64] Add extra patch to test --- .../i24/jungfrau_commissioning/test_rotation_scan.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py index f9e03ea0ac..86f88b4fbb 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py @@ -36,6 +36,9 @@ def get_good_multi_rotation_params(transmissions: list[float], tmp_path): return MultiRotationScanByTransmissions(**params) +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.read_devices_for_metadata" +) @patch( "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan._cleanup_plan" ) @@ -63,6 +66,7 @@ async def test_single_rotation_plan_in_re( mock_arm_zebra: MagicMock, mock_check_topup: MagicMock, mock_cleanup: MagicMock, + mock_metadata_read: MagicMock, RE: RunEngine, tmp_path, rotation_composite: RotationScanComposite, @@ -80,6 +84,7 @@ async def test_single_rotation_plan_in_re( mock_setup_zebra.assert_called_once() mock_arm_zebra.assert_called_once() mock_check_topup.assert_called_once() + mock_metadata_read.assert_called_once() mock_fly.assert_called_once() mock_cleanup.assert_called_once() From 2cafc603e278e3bb8c55ef5835759c3fe28ce1a8 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Fri, 3 Oct 2025 15:36:18 +0000 Subject: [PATCH 49/64] remove wait as a parameter --- .../beamlines/i24/jungfrau_commissioning/do_darks.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py index 1f7263fe31..9a840504de 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py @@ -27,7 +27,6 @@ def do_pedestal_darks( pedestal_loops: PositiveInt = 200, jungfrau: CommissioningJungfrau = inject("jungfrau"), path_of_output_file: str | None = None, - wait: bool = False, ) -> MsgGenerator[WatchableAsyncStatus]: """Acquire darks in pedestal mode, using dynamic gain mode. This calibrates the offsets for the jungfrau, and must be performed before acquiring real data in dynamic gain mode. @@ -47,7 +46,6 @@ def do_pedestal_darks( jungfrau: Jungfrau device path_of_output_file: Absolute path of the detector file output, including file name. If None, then use the PathProvider set during Jungfrau device instantiation - wait: Optionally block until data collection is complete. """ @bpp.set_run_key_decorator(PEDESTAL_DARKS_RUN) @@ -72,7 +70,7 @@ def _fly_then_revert_acquisition_type(): status = yield from fly_jungfrau( jungfrau, trigger_info, - wait, + wait=True, log_on_percentage_prefix="Jungfrau pedestal dynamic gain mode darks triggers recieved", ) return status From 385cd68c1c261440059ba086c1c26a94a3fdaac3 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Fri, 3 Oct 2025 16:06:38 +0000 Subject: [PATCH 50/64] Let ophyd async revert pedestal mode on disarm instead of the plan --- .../i24/jungfrau_commissioning/do_darks.py | 22 +++---------------- .../jungfrau_commissioning/test_do_darks.py | 16 +++++--------- 2 files changed, 8 insertions(+), 30 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py index 9a840504de..9cd121d305 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py @@ -7,7 +7,6 @@ from ophyd_async.fastcs.jungfrau import ( AcquisitionType, GainMode, - PedestalMode, create_jungfrau_pedestal_triggering_info, ) from pydantic import PositiveInt @@ -16,7 +15,6 @@ fly_jungfrau, override_file_path, ) -from mx_bluesky.common.utils.log import LOGGER PEDESTAL_DARKS_RUN = "PEDESTAL DARKS RUN" @@ -64,27 +62,13 @@ def _do_decorated_plan(): trigger_info = create_jungfrau_pedestal_triggering_info( exp_time_s, pedestal_frames, pedestal_loops ) - - @bpp.finalize_decorator(final_plan=lambda: _revert_pedestal_mode(jungfrau)) - def _fly_then_revert_acquisition_type(): - status = yield from fly_jungfrau( + return ( + yield from fly_jungfrau( jungfrau, trigger_info, wait=True, log_on_percentage_prefix="Jungfrau pedestal dynamic gain mode darks triggers recieved", ) - return status - - return (yield from _fly_then_revert_acquisition_type()) + ) return (yield from _do_decorated_plan()) - - -def _revert_pedestal_mode(jungfrau: CommissioningJungfrau): - LOGGER.info("Moving Jungfrau out of pedestal mode...") - yield from bps.mv( - jungfrau.drv.acquisition_type, - AcquisitionType.STANDARD, - jungfrau.drv.pedestal_mode_state, - PedestalMode.OFF, - ) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py index 1076d6355a..958fdd9381 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py @@ -99,25 +99,19 @@ def test_plan(): class FakeException(Exception): ... -@patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks.fly_jungfrau", - side_effect=FakeException, -) @patch("mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks.override_file_path") -async def test_pedestal_mode_turned_off_on_exception( - mock_fly: MagicMock, +@patch("bluesky.plan_stubs.unstage") +async def test_jungfrau_unstage( + mock_unstage: MagicMock, mock_override_path: MagicMock, jungfrau: CommissioningJungfrau, RE: RunEngine, ): - await jungfrau.drv.pedestal_mode_state.set(PedestalMode.ON) - await jungfrau.drv.acquisition_type.set(AcquisitionType.PEDESTAL) + jungfrau.stage = MagicMock(side_effect=FakeException) def test_plan(): yield from do_pedestal_darks(0.001, 2, 2, jungfrau) with pytest.raises(FakeException): RE(test_plan()) - - assert await jungfrau.drv.pedestal_mode_state.get_value() == PedestalMode.OFF - assert await jungfrau.drv.acquisition_type.get_value() == AcquisitionType.STANDARD + mock_unstage.assert_called_once_with(jungfrau, wait=True) From 1b609eaafe6603d876754c7e0909bf922405156d Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Fri, 3 Oct 2025 17:08:02 +0000 Subject: [PATCH 51/64] Add contingency wrapper --- .../beamlines/i24/jungfrau_commissioning/do_darks.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py index 9cd121d305..2cb378844a 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py @@ -46,6 +46,9 @@ def do_pedestal_darks( set during Jungfrau device instantiation """ + @bpp.contingency_decorator( + except_plan=lambda _: (yield from bps.unstage(jungfrau, wait=True)) + ) @bpp.set_run_key_decorator(PEDESTAL_DARKS_RUN) @bpp.run_decorator(md={"subplan_name": PEDESTAL_DARKS_RUN}) def _do_decorated_plan(): From c6f5e146b59e5762377f5c007fb30f0c480354dd Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Mon, 6 Oct 2025 09:30:03 +0000 Subject: [PATCH 52/64] Add more tests --- .../rotation_scan_plan.py | 7 +- .../test_rotation_scan.py | 84 +++++++++++++++++-- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py index 25298851b8..938190fab1 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py @@ -63,6 +63,9 @@ DEFAULT_DETECTOR_DISTANCE_MM = 200 +class HutchClosedException(Exception): ... + + def set_up_beamline_for_rotation( composite: RotationScanComposite, det_z_mm: float, @@ -78,7 +81,9 @@ def set_up_beamline_for_rotation( LOGGER.info(f"Hutch shutter: {hutch_shutter_state}") if hutch_shutter_state != ShutterState.OPEN: LOGGER.error(f"Hutch shutter is not open! State is {hutch_shutter_state}") - raise Exception(f"Hutch shutter is not open! State is {hutch_shutter_state}") + raise HutchClosedException( + f"Hutch shutter is not open! State is {hutch_shutter_state}" + ) LOGGER.info( "Making sure aperture and beamstop are in, detector stages are in position, backlight is out, and transmission is set..." diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py index 86f88b4fbb..ce151cc3d9 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py @@ -1,15 +1,24 @@ +import asyncio from unittest.mock import MagicMock, patch +import pytest from bluesky.run_engine import RunEngine from bluesky.simulators import RunEngineSimulator, assert_message_and_return_remaining from dodal.devices.hutch_shutter import ShutterState +from dodal.devices.i24.aperture import AperturePositions +from dodal.devices.i24.beamstop import BeamstopPositions +from dodal.devices.i24.dual_backlight import BacklightPositions from ophyd_async.testing import set_mock_value from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import JF_COMPLETE_GROUP from mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan import ( DEFAULT_DETECTOR_DISTANCE_MM, + JF_DET_STAGE_Y_POSITION_MM, + HutchClosedException, RotationScanComposite, + _cleanup_plan, multi_rotation_plan_varying_transmission, + set_up_beamline_for_rotation, single_rotation_plan, ) from mx_bluesky.beamlines.i24.parameters.constants import PlanNameConstants @@ -173,16 +182,81 @@ def test_multi_rotation_plan_in_re( assert desired_transmission_fracs == called_transmission_fracs -def test_metadata_writer_produces_correct_json_after_plan(): ... +async def test_set_up_beamline_for_rotation_success( + rotation_composite: RotationScanComposite, + RE: RunEngine, +): + trans_frac = 0.1 + det_z = 200 + set_mock_value(rotation_composite.hutch_shutter.status, ShutterState.OPEN) + RE(set_up_beamline_for_rotation(rotation_composite, det_z, trans_frac)) + + assert await asyncio.gather( + rotation_composite.aperture.position.get_value(), + rotation_composite.beamstop.pos_select.get_value(), + rotation_composite.det_stage.y.user_readback.get_value(), + rotation_composite.backlight.backlight_position.pos_level.get_value(), + rotation_composite.det_stage.z.user_readback.get_value(), + rotation_composite.attenuator.actual_transmission.get_value(), + ) == [ + AperturePositions.IN, + BeamstopPositions.DATA_COLLECTION, + JF_DET_STAGE_Y_POSITION_MM, + BacklightPositions.OUT, + det_z, + trans_frac, + ] -def test_set_up_beamline_for_rotation_success(): ... # use RE +def test_set_up_beamline_for_rotation_error_on_closed_hutch( + rotation_composite: RotationScanComposite, + RE: RunEngine, +): + trans_frac = 0.1 + det_z = 200 + set_mock_value(rotation_composite.hutch_shutter.status, ShutterState.CLOSED) + with pytest.raises(HutchClosedException): + RE(set_up_beamline_for_rotation(rotation_composite, det_z, trans_frac)) -def test_set_up_beamline_for_rotation_error_on_closed_hutch(): ... +class FakeException(Exception): ... -def test_single_rotation_plan_uses_default_if_no_det_distance(): ... +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.check_topup_and_wait_if_necessary", + new=MagicMock(side_effect=FakeException), # Exit test early by inserting exception +) +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.set_up_beamline_for_rotation" +) +def test_single_rotation_plan_uses_default_if_no_det_distance( + mock_set_up_beamline: MagicMock, + sim_run_engine: RunEngineSimulator, + rotation_composite: RotationScanComposite, + tmp_path, +): + params = get_good_single_rotation_params(tmp_path) + params.detector_distance_mm = None + with pytest.raises(FakeException): + sim_run_engine.simulate_plan(single_rotation_plan(rotation_composite, params)) + mock_set_up_beamline.assert_called_once_with( + rotation_composite, DEFAULT_DETECTOR_DISTANCE_MM, params.transmission_frac + ) -def test_cleanup_plan(): ... +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.tidy_up_zebra_after_rotation_scan" +) +def test_cleanup_plan( + mock_tidy_zebra: MagicMock, rotation_composite: RotationScanComposite, RE: RunEngine +): + rotation_composite.jungfrau.unstage = MagicMock() + RE( + _cleanup_plan( + rotation_composite.zebra, + rotation_composite.jungfrau, + rotation_composite.sample_shutter, + ) + ) + mock_tidy_zebra.assert_called_once() + rotation_composite.jungfrau.unstage.assert_called_once() From fe6d8464b3ec33c58e4526192ef0b0d9b4e0ee33 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Mon, 6 Oct 2025 13:19:07 +0000 Subject: [PATCH 53/64] make gain mode a parameter of fly_jungfrau plan --- .../beamlines/i24/jungfrau_commissioning/do_darks.py | 3 +-- .../do_external_acquisition.py | 5 ++++- .../do_internal_acquisition.py | 5 ++++- .../i24/jungfrau_commissioning/plan_utils.py | 12 +++++++----- .../i24/jungfrau_commissioning/rotation_scan_plan.py | 2 ++ .../i24/jungfrau_commissioning/test_do_darks.py | 6 +++++- .../test_do_external_acquisition.py | 11 +++++------ .../test_do_internal_acquisition.py | 7 +++++-- .../i24/jungfrau_commissioning/test_plan_utils.py | 8 ++++++-- 9 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py index 2cb378844a..5ca377c00a 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py @@ -58,8 +58,6 @@ def _do_decorated_plan(): yield from bps.mv( jungfrau.drv.acquisition_type, AcquisitionType.PEDESTAL, - jungfrau.drv.gain_mode, - GainMode.DYNAMIC, ) trigger_info = create_jungfrau_pedestal_triggering_info( @@ -69,6 +67,7 @@ def _do_decorated_plan(): yield from fly_jungfrau( jungfrau, trigger_info, + GainMode.DYNAMIC, wait=True, log_on_percentage_prefix="Jungfrau pedestal dynamic gain mode darks triggers recieved", ) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py index f5a9416a17..e62796f4a6 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_external_acquisition.py @@ -5,6 +5,7 @@ WatchableAsyncStatus, ) from ophyd_async.fastcs.jungfrau import ( + GainMode, create_jungfrau_external_triggering_info, ) from pydantic import PositiveInt @@ -17,6 +18,7 @@ def do_external_acquisition( exp_time_s: float, + gain_mode: GainMode, total_triggers: PositiveInt = 1, output_file_path: str | None = None, wait: bool = False, @@ -29,6 +31,7 @@ def do_external_acquisition( Args: exp_time_s: Length of detector exposure for each frame. + gain_mode: Which gain mode to put the Jungfrau into before starting the acquisition. total_triggers: Number of external triggers recieved before acquisition is marked as complete. jungfrau: Jungfrau device output_file_name: Absolute path of the detector file output, including file name. If None, then use the PathProvider @@ -40,5 +43,5 @@ def do_external_acquisition( override_file_path(jungfrau, output_file_path) trigger_info = create_jungfrau_external_triggering_info(total_triggers, exp_time_s) - status = yield from fly_jungfrau(jungfrau, trigger_info, wait) + status = yield from fly_jungfrau(jungfrau, trigger_info, gain_mode, wait) return status diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py index e97eb61135..1a1e4bb701 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_internal_acquisition.py @@ -5,6 +5,7 @@ WatchableAsyncStatus, ) from ophyd_async.fastcs.jungfrau import ( + GainMode, create_jungfrau_internal_triggering_info, ) from pydantic import PositiveInt @@ -17,6 +18,7 @@ def do_internal_acquisition( exp_time_s: float, + gain_mode: GainMode, total_frames: PositiveInt = 1, jungfrau: CommissioningJungfrau = inject("jungfrau"), path_of_output_file: str | None = None, @@ -30,6 +32,7 @@ def do_internal_acquisition( Args: exp_time_s: Length of detector exposure for each frame. + gain_mode: Which gain mode to put the Jungfrau into before starting the acquisition. total_frames: Number of frames taken after being internally triggered. period_between_frames_s: Time between each detector frame, including deadtime. Not needed if frames_per_triggers is 1. jungfrau: Jungfrau device @@ -42,5 +45,5 @@ def do_internal_acquisition( override_file_path(jungfrau, path_of_output_file) trigger_info = create_jungfrau_internal_triggering_info(total_frames, exp_time_s) - status = yield from fly_jungfrau(jungfrau, trigger_info, wait) + status = yield from fly_jungfrau(jungfrau, trigger_info, gain_mode, wait) return status diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py index a888dbdedb..1aab604f84 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_utils.py @@ -12,6 +12,7 @@ TriggerInfo, WatchableAsyncStatus, ) +from ophyd_async.fastcs.jungfrau import GainMode from mx_bluesky.common.utils.log import LOGGER @@ -21,6 +22,7 @@ def fly_jungfrau( jungfrau: CommissioningJungfrau, trigger_info: TriggerInfo, + gain_mode: GainMode, wait: bool = False, log_on_percentage_prefix="Jungfrau data collection triggers recieved", ) -> MsgGenerator[WatchableAsyncStatus]: @@ -34,6 +36,7 @@ def fly_jungfrau( jungfrau: Jungfrau device. trigger_info: TriggerInfo which should be acquired using jungfrau util functions create_jungfrau_internal_triggering_info. or create_jungfrau_external_triggering_info. + gain_mode: Which gain mode to put the Jungfrau into before starting the acquisition. wait: Optionally block until data collection is complete. log_on_percentage_prefix: String that will be appended to the "percentage completion" logging message. """ @@ -42,10 +45,11 @@ def fly_jungfrau( except_plan=lambda _: (yield from bps.unstage(jungfrau, wait=True)) ) def _fly_with_unstage_contingency(): + LOGGER.info("Setting up Jungfrau...") yield from bps.stage(jungfrau) - LOGGER.info("Setting up detector...") + yield from bps.abs_set(jungfrau.drv.gain_mode, gain_mode, wait=True) yield from bps.prepare(jungfrau, trigger_info, wait=True) - LOGGER.info("Detector prepared. Starting acquisition") + LOGGER.info("Jungfrau prepared. Starting acquisition") yield from bps.kickoff(jungfrau, wait=True) LOGGER.info("Waiting for acquisition to complete...") status = yield from bps.complete(jungfrau, group=JF_COMPLETE_GROUP) @@ -53,9 +57,7 @@ def _fly_with_unstage_contingency(): # StandardDetector.complete converts regular status to watchable status, # but bluesky plan stubs can't see this currently status = cast(WatchableAsyncStatus, status) - log_on_percentage_complete( - status, "Jungfrau data collection triggers recieved", 10 - ) + log_on_percentage_complete(status, log_on_percentage_prefix, 10) if wait: yield from bps.wait(JF_COMPLETE_GROUP) return status diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py index 938190fab1..c4e44bdc9c 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py @@ -15,6 +15,7 @@ from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter from dodal.plan_stubs.check_topup import check_topup_and_wait_if_necessary from ophyd_async.fastcs.jungfrau import ( + GainMode, create_jungfrau_external_triggering_info, ) @@ -230,6 +231,7 @@ def _do_read(): yield from fly_jungfrau( composite.jungfrau, _jf_trigger_info, + GainMode.DYNAMIC, wait=False, log_on_percentage_prefix="Jungfrau rotation scan triggers received", ) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py index 6e3ab2849c..0b7a1f89bd 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import bluesky.plan_stubs as bps +import bluesky.preprocessors as bpp import pytest from bluesky.callbacks import CallbackBase from bluesky.preprocessors import monitor_during_wrapper @@ -44,6 +45,7 @@ async def test_full_do_pedestal_darks( # Test that plan succeeds in RunEngine and pedestal-specific signals are changed as expected test_path = "path" + @bpp.set_run_key_decorator() def test_plan(): status = yield from do_pedestal_darks(0.001, 2, 2, jungfrau, test_path) assert not status.done @@ -115,4 +117,6 @@ def test_plan(): with pytest.raises(FakeException): RE(test_plan()) - mock_unstage.assert_called_once_with(jungfrau, wait=True) + assert ( + mock_unstage.call_count == 2 + ) # Once from fly_jungfrau, once from pedestal darks plan diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py index c05acb5f49..8789310537 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_external_acquisition.py @@ -3,11 +3,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import bluesky.plan_stubs as bps -import pytest from bluesky.preprocessors import run_decorator from bluesky.run_engine import RunEngine from bluesky.simulators import RunEngineSimulator, assert_message_and_return_remaining from dodal.beamlines.i24 import CommissioningJungfrau +from ophyd_async.fastcs.jungfrau import GainMode from ophyd_async.testing import set_mock_value from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_external_acquisition import ( @@ -16,15 +16,14 @@ from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import JF_COMPLETE_GROUP -@pytest.mark.skip( - reason="Waiting on ophyd-async PR https://github.com/bluesky/ophyd-async/pull/1038/files" -) def test_full_do_external_acquisition( jungfrau: CommissioningJungfrau, RE: RunEngine, caplog ): @run_decorator() def test_plan(): - status = yield from do_external_acquisition(0.001, 5, jungfrau=jungfrau) + status = yield from do_external_acquisition( + 0.001, GainMode.DYNAMIC, 5, jungfrau=jungfrau + ) assert not status.done val = 0 while not status.done: @@ -51,7 +50,7 @@ def test_do_external_acquisition_does_wait( jungfrau: CommissioningJungfrau, ): msgs = sim_run_engine.simulate_plan( - do_external_acquisition(0.01, 1, wait=True, jungfrau=jungfrau) + do_external_acquisition(0.01, GainMode.DYNAMIC, 1, wait=True, jungfrau=jungfrau) ) assert_message_and_return_remaining( msgs, diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py index 71c73127d7..3b552824fd 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_internal_acquisition.py @@ -7,6 +7,7 @@ from bluesky.run_engine import RunEngine from bluesky.simulators import RunEngineSimulator, assert_message_and_return_remaining from dodal.beamlines.i24 import CommissioningJungfrau +from ophyd_async.fastcs.jungfrau import GainMode from ophyd_async.testing import set_mock_value from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_internal_acquisition import ( @@ -20,7 +21,9 @@ def test_full_do_internal_acquisition( ): @run_decorator() def test_plan(): - status = yield from do_internal_acquisition(0.001, 5, jungfrau) + status = yield from do_internal_acquisition( + 0.001, GainMode.DYNAMIC, 5, jungfrau=jungfrau + ) assert not status.done val = 0 while not status.done: @@ -43,7 +46,7 @@ def test_do_internal_acquisition_does_wait( jungfrau: CommissioningJungfrau, ): msgs = sim_run_engine.simulate_plan( - do_internal_acquisition(0.01, 1, jungfrau, wait=True) + do_internal_acquisition(0.01, GainMode.DYNAMIC, 1, jungfrau, wait=True) ) assert_message_and_return_remaining( msgs, diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py index 156f8aa1a4..679f204113 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_plan_utils.py @@ -10,6 +10,7 @@ from ophyd_async.core import ( TriggerInfo, ) +from ophyd_async.fastcs.jungfrau import GainMode from ophyd_async.testing import ( set_mock_value, ) @@ -32,7 +33,9 @@ async def test_fly_jungfrau( def _open_run_and_fly(): frames = 5 status = yield from fly_jungfrau( - jungfrau, TriggerInfo(livetime=1e-3, exposures_per_event=frames) + jungfrau, + TriggerInfo(livetime=1e-3, exposures_per_event=frames), + GainMode.DYNAMIC, ) val = 0 while not status.done: @@ -45,6 +48,7 @@ def _open_run_and_fly(): RE(_open_run_and_fly()) assert mock_stop.await_count == 2 # once when staging, once after run complete + assert await jungfrau.drv.gain_mode.get_value() == GainMode.DYNAMIC def test_fly_jungfrau_stops_if_exception_after_stage( @@ -56,7 +60,7 @@ def test_fly_jungfrau_stops_if_exception_after_stage( @run_decorator() def do_fly(): - yield from fly_jungfrau(jungfrau, bad_trigger_info) + yield from fly_jungfrau(jungfrau, bad_trigger_info, GainMode.DYNAMIC) with pytest.raises(FailedStatus): RE(do_fly()) From b067ec65c9abed30df2306d5122fcd692795bfc3 Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Mon, 13 Oct 2025 17:12:41 +0100 Subject: [PATCH 54/64] Expose jungfrau do dark plan to web ui --- src/mx_bluesky/beamlines/i24/serial/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/mx_bluesky/beamlines/i24/serial/__init__.py b/src/mx_bluesky/beamlines/i24/serial/__init__.py index 1d5eb3edd0..a92ad560d9 100644 --- a/src/mx_bluesky/beamlines/i24/serial/__init__.py +++ b/src/mx_bluesky/beamlines/i24/serial/__init__.py @@ -1,3 +1,4 @@ +from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks import do_pedestal_darks from mx_bluesky.beamlines.i24.serial.web_gui_plans.general_plans import ( gui_gonio_move_on_click, gui_move_backlight, @@ -61,4 +62,6 @@ "gui_run_chip_collection", "gui_move_backlight", "gui_set_zoom_level", + # Jungfrau + "do_pedestal_darks", ] From 50afbe98181d24bd85d8f4032ec884ad95083202 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Mon, 20 Oct 2025 09:51:57 +0000 Subject: [PATCH 55/64] Remove beam xy from metadata writer --- .../callbacks/metadata_writer.py | 11 ++--------- .../callbacks/test_metadata_writer.py | 2 -- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py index 530029942c..15131e73af 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py @@ -24,7 +24,6 @@ class JsonMetadataWriter(CallbackBase): """ def __init__(self): - self.beam_xy = None self.wavelength_in_a = None self.energy_in_kev = None self.detector_distance_mm = None @@ -57,15 +56,10 @@ def event(self, doc: dict): # type: ignore assert data is not None self.wavelength_in_a = data.get("dcm-wavelength_in_a") self.energy_in_kev = data.get("dcm-energy_in_kev") - self.detector_distance_mm = data.get("det_stage-z") - - if self.detector_distance_mm: - self.beam_xy = self.parameters.detector_params.get_beam_position_mm( - self.detector_distance_mm - ) + self.detector_distance_mm = data.get("detector_motion-z") LOGGER.info( - f"Metadata writer received parameters, transmission: {self.transmission}, flux: {self.flux}, wavelength: {self.wavelength_in_a}, det distance: {self.detector_distance_mm}, beam_xy: {self.beam_xy}" + f"Metadata writer received parameters, transmission: {self.transmission}, flux: {self.flux}, wavelength: {self.wavelength_in_a}" ) def stop(self, doc: dict): # type: ignore @@ -83,7 +77,6 @@ def stop(self, doc: dict): # type: ignore "wavelength_in_a": self.wavelength_in_a, "energy_kev": self.energy_in_kev, "angular_increment_deg": self.parameters.rotation_increment_deg, - "beam_xy_mm": self.beam_xy, "detector_distance_mm": self.detector_distance_mm, } ) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/test_metadata_writer.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/test_metadata_writer.py index 437981faeb..4be4db0f85 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/test_metadata_writer.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/test_metadata_writer.py @@ -51,14 +51,12 @@ def _do_read(): await rotation_composite.dcm.wavelength_in_a.set(wavelength) await rotation_composite.dcm.energy_in_kev.set(energy) await rotation_composite.det_stage.z.set(det_z) - beam_center = params.detector_params.get_beam_position_mm(det_z) expected_output = { "wavelength_in_a": wavelength, "energy_kev": energy, "detector_distance_mm": det_z, "angular_increment_deg": 0.1, - "beam_xy_mm": beam_center, } RE(_do_read()) From a9886ef7ad6d1c35ce806b2df63451ea61425f69 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Mon, 20 Oct 2025 09:59:59 +0000 Subject: [PATCH 56/64] Put gain mode into fly_jungfrau --- .../i24/jungfrau_commissioning/do_darks.py | 76 ------------------- .../experiment_plans/do_darks.py | 3 +- .../rotation_scan_plan.py | 4 +- .../plan_stubs/do_external_acquisition.py | 2 +- .../plan_stubs/do_internal_acquisition.py | 2 +- .../plan_stubs/plan_utils.py | 5 ++ .../callbacks/test_metadata_writer.py | 2 +- .../i24/jungfrau_commissioning/conftest.py | 2 +- .../plan_stubs/test_plan_utils.py | 5 +- .../test_rotation_scan.py | 6 +- 10 files changed, 19 insertions(+), 88 deletions(-) delete mode 100644 src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py rename src/mx_bluesky/beamlines/i24/jungfrau_commissioning/{ => experiment_plans}/rotation_scan_plan.py (98%) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py deleted file mode 100644 index 5ca377c00a..0000000000 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/do_darks.py +++ /dev/null @@ -1,76 +0,0 @@ -import bluesky.plan_stubs as bps -import bluesky.preprocessors as bpp -from bluesky.utils import MsgGenerator -from dodal.common import inject -from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau -from ophyd_async.core import WatchableAsyncStatus -from ophyd_async.fastcs.jungfrau import ( - AcquisitionType, - GainMode, - create_jungfrau_pedestal_triggering_info, -) -from pydantic import PositiveInt - -from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( - fly_jungfrau, - override_file_path, -) - -PEDESTAL_DARKS_RUN = "PEDESTAL DARKS RUN" - - -def do_pedestal_darks( - exp_time_s: float = 0.001, - pedestal_frames: PositiveInt = 20, - pedestal_loops: PositiveInt = 200, - jungfrau: CommissioningJungfrau = inject("jungfrau"), - path_of_output_file: str | None = None, -) -> MsgGenerator[WatchableAsyncStatus]: - """Acquire darks in pedestal mode, using dynamic gain mode. This calibrates the offsets - for the jungfrau, and must be performed before acquiring real data in dynamic gain mode. - - When Bluesky triggers the detector in pedestal mode, with pedestal frames F and pedestal loops L, - the acquisition is managed at the driver level to: - 1. Acquire F-1 frames in dynamic gain mode - 2. Acquire 1 frame in ForceSwitchG1 gain mode - 3. Repeat steps 1-2 L times - 4. Do the first three steps a second time, except use ForceSwitchG2 instead of ForceSwitchG1 - during step 2. - - Args: - exp_time_s: Length of detector exposure for each frame. - pedestal_frames: Number of frames acquired per pedestal loop. - pedestal_loops: Number of times to acquire a set of pedestal_frames - jungfrau: Jungfrau device - path_of_output_file: Absolute path of the detector file output, including file name. If None, then use the PathProvider - set during Jungfrau device instantiation - """ - - @bpp.contingency_decorator( - except_plan=lambda _: (yield from bps.unstage(jungfrau, wait=True)) - ) - @bpp.set_run_key_decorator(PEDESTAL_DARKS_RUN) - @bpp.run_decorator(md={"subplan_name": PEDESTAL_DARKS_RUN}) - def _do_decorated_plan(): - if path_of_output_file: - override_file_path(jungfrau, path_of_output_file) - - yield from bps.mv( - jungfrau.drv.acquisition_type, - AcquisitionType.PEDESTAL, - ) - - trigger_info = create_jungfrau_pedestal_triggering_info( - exp_time_s, pedestal_frames, pedestal_loops - ) - return ( - yield from fly_jungfrau( - jungfrau, - trigger_info, - GainMode.DYNAMIC, - wait=True, - log_on_percentage_prefix="Jungfrau pedestal dynamic gain mode darks triggers recieved", - ) - ) - - return (yield from _do_decorated_plan()) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/do_darks.py index e03b997445..9028f303e4 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/do_darks.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/do_darks.py @@ -63,13 +63,12 @@ def _do_decorated_plan(): yield from bps.mv( jungfrau.drv.acquisition_type, AcquisitionType.PEDESTAL, - jungfrau.drv.gain_mode, - GainMode.DYNAMIC, ) return ( yield from fly_jungfrau( jungfrau, trigger_info, + GainMode.DYNAMIC, wait=True, log_on_percentage_prefix="Jungfrau pedestal dynamic gain mode darks triggers recieved", ) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/rotation_scan_plan.py similarity index 98% rename from src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py rename to src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/rotation_scan_plan.py index c4e44bdc9c..88d8a03e2c 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/rotation_scan_plan.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/rotation_scan_plan.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from copy import deepcopy from functools import partial @@ -25,7 +23,7 @@ from mx_bluesky.beamlines.i24.jungfrau_commissioning.composites import ( RotationScanComposite, ) -from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import ( +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_stubs.plan_utils import ( JF_COMPLETE_GROUP, fly_jungfrau, override_file_path, diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_stubs/do_external_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_stubs/do_external_acquisition.py index dc4bcaa55b..31881c9958 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_stubs/do_external_acquisition.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_stubs/do_external_acquisition.py @@ -44,5 +44,5 @@ def do_external_acquisition( override_file_path(jungfrau, output_file_path) trigger_info = create_jungfrau_external_triggering_info(total_triggers, exp_time_s) - status = yield from fly_jungfrau(jungfrau, trigger_info, wait=wait) + status = yield from fly_jungfrau(jungfrau, trigger_info, gain_mode, wait=wait) return status diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_stubs/do_internal_acquisition.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_stubs/do_internal_acquisition.py index faff29bda3..c2e9355d23 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_stubs/do_internal_acquisition.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_stubs/do_internal_acquisition.py @@ -46,5 +46,5 @@ def do_internal_acquisition( override_file_path(jungfrau, path_of_output_file) trigger_info = create_jungfrau_internal_triggering_info(total_frames, exp_time_s) - status = yield from fly_jungfrau(jungfrau, trigger_info, wait=wait) + status = yield from fly_jungfrau(jungfrau, trigger_info, gain_mode, wait=wait) return status diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_stubs/plan_utils.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_stubs/plan_utils.py index 92a4d13836..f833a15049 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_stubs/plan_utils.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/plan_stubs/plan_utils.py @@ -11,6 +11,7 @@ TriggerInfo, WatchableAsyncStatus, ) +from ophyd_async.fastcs.jungfrau import GainMode from mx_bluesky.common.utils.log import LOGGER @@ -20,6 +21,7 @@ def fly_jungfrau( jungfrau: CommissioningJungfrau, trigger_info: TriggerInfo, + gain_mode: GainMode, wait: bool = False, log_on_percentage_prefix="Jungfrau data collection triggers recieved", ) -> MsgGenerator[WatchableAsyncStatus]: @@ -32,10 +34,13 @@ def fly_jungfrau( Args: jungfrau: Jungfrau device. trigger_info: TriggerInfo which should be acquired using jungfrau util functions. + gain_mode: Which gain mode to put the Jungfrau into before starting the acquisition. wait: Optionally block until data collection is complete. log_on_percentage_prefix: String that will be appended to the "percentage completion" logging message. """ + LOGGER.info(f"Setting Jungfrau to gain mode {gain_mode}") + yield from bps.mv(jungfrau.drv.gain_mode, gain_mode) LOGGER.info("Preparing detector...") yield from bps.prepare(jungfrau, trigger_info, wait=True) LOGGER.info("Detector prepared. Starting acquisition") diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/test_metadata_writer.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/test_metadata_writer.py index 4be4db0f85..b243cd039f 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/test_metadata_writer.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/callbacks/test_metadata_writer.py @@ -10,7 +10,7 @@ from mx_bluesky.beamlines.i24.jungfrau_commissioning.callbacks.metadata_writer import ( JsonMetadataWriter, ) -from mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan import ( +from mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan import ( READING_DUMP_FILENAME, RotationScanComposite, ) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/conftest.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/conftest.py index a7779dc98d..7b8dc08118 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/conftest.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/conftest.py @@ -15,7 +15,7 @@ from dodal.testing import patch_all_motors from ophyd_async.core import init_devices -from mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan import ( +from mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan import ( RotationScanComposite, ) diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/plan_stubs/test_plan_utils.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/plan_stubs/test_plan_utils.py index dee3dcab22..84e1129afe 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/plan_stubs/test_plan_utils.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/plan_stubs/test_plan_utils.py @@ -9,6 +9,7 @@ from ophyd_async.core import ( TriggerInfo, ) +from ophyd_async.fastcs.jungfrau import GainMode from ophyd_async.testing import ( set_mock_value, ) @@ -31,7 +32,9 @@ async def test_fly_jungfrau( def _open_run_and_fly(): frames = 5 status = yield from fly_jungfrau( - jungfrau, TriggerInfo(livetime=1e-3, exposures_per_event=frames) + jungfrau, + TriggerInfo(livetime=1e-3, exposures_per_event=frames), + GainMode.DYNAMIC, ) val = 0 while not status.done: diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py index ce151cc3d9..37dcbb4075 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py @@ -10,8 +10,7 @@ from dodal.devices.i24.dual_backlight import BacklightPositions from ophyd_async.testing import set_mock_value -from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_utils import JF_COMPLETE_GROUP -from mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan import ( +from mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan import ( DEFAULT_DETECTOR_DISTANCE_MM, JF_DET_STAGE_Y_POSITION_MM, HutchClosedException, @@ -21,6 +20,9 @@ set_up_beamline_for_rotation, single_rotation_plan, ) +from mx_bluesky.beamlines.i24.jungfrau_commissioning.plan_stubs.plan_utils import ( + JF_COMPLETE_GROUP, +) from mx_bluesky.beamlines.i24.parameters.constants import PlanNameConstants from mx_bluesky.beamlines.i24.parameters.rotation import ( MultiRotationScanByTransmissions, From cfae73b49c314446cf2ef5281a737fa5f7775b27 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Tue, 21 Oct 2025 20:12:57 +0000 Subject: [PATCH 57/64] remove flux from metadata --- .../i24/jungfrau_commissioning/callbacks/metadata_writer.py | 3 +-- .../i24/jungfrau_commissioning/experiment_plans/do_darks.py | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py index 15131e73af..896a20608d 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/callbacks/metadata_writer.py @@ -28,7 +28,6 @@ def __init__(self): self.energy_in_kev = None self.detector_distance_mm = None self.descriptors: dict[str, dict] = {} - self.flux: float | None = None self.transmission: float | None = None self.parameters: SingleRotationScan | None = None @@ -59,7 +58,7 @@ def event(self, doc: dict): # type: ignore self.detector_distance_mm = data.get("detector_motion-z") LOGGER.info( - f"Metadata writer received parameters, transmission: {self.transmission}, flux: {self.flux}, wavelength: {self.wavelength_in_a}" + f"Metadata writer received parameters, transmission: {self.transmission}, wavelength: {self.wavelength_in_a}" ) def stop(self, doc: dict): # type: ignore diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/do_darks.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/do_darks.py index 34907483a7..f04e45e93d 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/do_darks.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/do_darks.py @@ -71,6 +71,7 @@ def _do_decorated_plan(): yield from fly_jungfrau( jungfrau, trigger_info, + GainMode.DYNAMIC, wait=True, log_on_percentage_prefix="Jungfrau pedestal dynamic gain mode darks triggers received", ) @@ -114,6 +115,7 @@ def _do_decorated_plan(): yield from fly_jungfrau( jungfrau, trigger_info, + gain_mode, wait=True, log_on_percentage_prefix=f"Jungfrau {gain_mode} gain mode darks triggers received", ) From 63a43048600fc952a254f5630e346b662c00fbce Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Tue, 21 Oct 2025 20:16:13 +0000 Subject: [PATCH 58/64] Fix namings --- src/mx_bluesky/common/experiment_plans/setup_zebra.py | 4 ++-- tests/conftest.py | 6 +++--- .../i24/jungfrau_commissioning/test_do_darks.py | 10 +++++++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/mx_bluesky/common/experiment_plans/setup_zebra.py b/src/mx_bluesky/common/experiment_plans/setup_zebra.py index b0b30e2d34..2c54fbe190 100644 --- a/src/mx_bluesky/common/experiment_plans/setup_zebra.py +++ b/src/mx_bluesky/common/experiment_plans/setup_zebra.py @@ -6,7 +6,7 @@ RotationDirection, Zebra, ) -from dodal.devices.zebra.zebra_constants_mapping import UnmappedZebraException +from dodal.devices.zebra.zebra_constants_mapping import UnmappedZebraError from dodal.devices.zebra.zebra_controlled_shutter import ( ZebraShutter, ZebraShutterControl, @@ -111,7 +111,7 @@ def setup_zebra_for_rotation( zebra.mapping.sources.DISCONNECT, group=group, ) - except UnmappedZebraException: + except UnmappedZebraError: ... yield from bps.abs_set( zebra.output.pulse_1.input, zebra.mapping.sources.DISCONNECT, group=group diff --git a/tests/conftest.py b/tests/conftest.py index da04cc42aa..c70919503d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,8 +38,8 @@ EnumFilterAttenuator, ) from dodal.devices.attenuator.filter_selections import ( - I24_FilterOneSelections, - I24_FilterTwoSelections, + I24FilterOneSelections, + I24FilterTwoSelections, ) from dodal.devices.backlight import Backlight from dodal.devices.baton import Baton @@ -892,7 +892,7 @@ def zocalo(done_status, RE: RunEngine): async def enum_attenuator(RE: RunEngine) -> EnumFilterAttenuator: with init_devices(mock=True): attenuator = EnumFilterAttenuator( - "", filter_selection=(I24_FilterOneSelections, I24_FilterTwoSelections) + "", filter_selection=(I24FilterOneSelections, I24FilterTwoSelections) ) @AsyncStatus.wrap diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py index 0b7a1f89bd..102bde6ddb 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_do_darks.py @@ -16,7 +16,7 @@ ) from ophyd_async.testing import set_mock_value -from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks import ( +from mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.do_darks import ( do_pedestal_darks, ) @@ -38,7 +38,9 @@ def event(self, doc): return doc -@patch("mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks.override_file_path") +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.do_darks.override_file_path" +) async def test_full_do_pedestal_darks( mock_override_path: MagicMock, jungfrau: CommissioningJungfrau, RE: RunEngine ): @@ -102,7 +104,9 @@ def test_plan(): class FakeException(Exception): ... -@patch("mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks.override_file_path") +@patch( + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.do_darks.override_file_path" +) @patch("bluesky.plan_stubs.unstage") async def test_jungfrau_unstage( mock_unstage: MagicMock, From 76b5df23e68047448f62b8f6ed75a3c284db61d0 Mon Sep 17 00:00:00 2001 From: Ollie Silvester Date: Wed, 22 Oct 2025 15:06:44 +0000 Subject: [PATCH 59/64] Review response --- .../experiment_plans/rotation_scan_plan.py | 10 +- .../setup_zebra_and_shutter.py | 7 +- .../common/experiment_plans/setup_zebra.py | 121 ------------------ .../common/parameters/components.py | 6 +- .../common/parameters/device_composites.py | 11 -- .../experiment_plans/rotation_scan_plan.py | 8 +- .../test_rotation_scan.py | 35 ++--- .../test_rotation_scan_plan.py | 4 +- 8 files changed, 33 insertions(+), 169 deletions(-) delete mode 100644 src/mx_bluesky/common/experiment_plans/setup_zebra.py diff --git a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/rotation_scan_plan.py b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/rotation_scan_plan.py index 88d8a03e2c..f3a36424a7 100644 --- a/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/rotation_scan_plan.py +++ b/src/mx_bluesky/beamlines/i24/jungfrau_commissioning/experiment_plans/rotation_scan_plan.py @@ -9,7 +9,7 @@ from dodal.devices.i24.beamstop import BeamstopPositions from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau from dodal.devices.i24.dual_backlight import BacklightPositions -from dodal.devices.zebra.zebra import I24Axes, Zebra +from dodal.devices.zebra.zebra import ArmDemand, I24Axes, Zebra from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter from dodal.plan_stubs.check_topup import check_topup_and_wait_if_necessary from ophyd_async.fastcs.jungfrau import ( @@ -38,16 +38,13 @@ MultiRotationScanByTransmissions, ) from mx_bluesky.common.device_setup_plans.setup_zebra_and_shutter import ( + setup_zebra_for_rotation, tidy_up_zebra_after_rotation_scan, ) from mx_bluesky.common.experiment_plans.rotation.rotation_utils import ( RotationMotionProfile, calculate_motion_profile, ) -from mx_bluesky.common.experiment_plans.setup_zebra import ( - arm_zebra, - setup_zebra_for_rotation, -) from mx_bluesky.common.parameters.constants import ( PlanGroupCheckpointConstants, PlanNameConstants, @@ -187,7 +184,6 @@ def _rotation_scan_plan( direction=motion_values.direction, shutter_opening_deg=motion_values.shutter_opening_deg, shutter_opening_s=motion_values.shutter_time_s, - group=PlanGroupCheckpointConstants.SETUP_ZEBRA_FOR_ROTATION, ) yield from bps.wait(PlanGroupCheckpointConstants.ROTATION_READY_FOR_DC) @@ -196,7 +192,7 @@ def _rotation_scan_plan( yield from bps.abs_set( axis.velocity, motion_values.speed_for_rotation_deg_s, wait=True ) - yield from arm_zebra(composite.zebra) + yield from bps.abs_set(composite.zebra.pc.arm, ArmDemand.ARM, wait=True) # Check topup gate yield from check_topup_and_wait_if_necessary( diff --git a/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py b/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py index 356969e957..844b2fbef5 100644 --- a/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py +++ b/src/mx_bluesky/common/device_setup_plans/setup_zebra_and_shutter.py @@ -14,7 +14,10 @@ ZebraShutterControl, ) -from mx_bluesky.common.parameters.constants import ZEBRA_STATUS_TIMEOUT +from mx_bluesky.common.parameters.constants import ( + ZEBRA_STATUS_TIMEOUT, + PlanGroupCheckpointConstants, +) from mx_bluesky.common.utils.log import LOGGER """Plans in this file will work as intended if the zebra has the following configuration: @@ -154,7 +157,7 @@ def setup_zebra_for_rotation( shutter_opening_deg: float = 2.5, shutter_opening_s: float = 0.04, direction: RotationDirection = RotationDirection.POSITIVE, - group: str = "setup_zebra_for_rotation", + group: str = PlanGroupCheckpointConstants.SETUP_ZEBRA_FOR_ROTATION, wait: bool = True, ttl_input_for_detector_to_use: int | None = None, ): diff --git a/src/mx_bluesky/common/experiment_plans/setup_zebra.py b/src/mx_bluesky/common/experiment_plans/setup_zebra.py deleted file mode 100644 index 2c54fbe190..0000000000 --- a/src/mx_bluesky/common/experiment_plans/setup_zebra.py +++ /dev/null @@ -1,121 +0,0 @@ -import bluesky.plan_stubs as bps -from dodal.devices.zebra.zebra import ( - ArmDemand, - EncEnum, - I03Axes, - RotationDirection, - Zebra, -) -from dodal.devices.zebra.zebra_constants_mapping import UnmappedZebraError -from dodal.devices.zebra.zebra_controlled_shutter import ( - ZebraShutter, - ZebraShutterControl, -) - -from mx_bluesky.common.device_setup_plans.setup_zebra_and_shutter import ( - configure_zebra_and_shutter_for_auto_shutter, -) -from mx_bluesky.common.parameters.constants import ZEBRA_STATUS_TIMEOUT -from mx_bluesky.common.utils.log import LOGGER - - -def arm_zebra(zebra: Zebra): - LOGGER.info("Arming zebra position compare...") - yield from bps.abs_set(zebra.pc.arm, ArmDemand.ARM, wait=True) - - -def tidy_up_zebra_after_rotation_scan( - zebra: Zebra, - zebra_shutter: ZebraShutter, - group="tidy_up_zebra_after_rotation", - wait=True, -): - yield from bps.abs_set(zebra.pc.arm, ArmDemand.DISARM, group=group) - yield from bps.abs_set( - zebra_shutter.control_mode, ZebraShutterControl.MANUAL, group=group - ) - if wait: - yield from bps.wait(group, timeout=ZEBRA_STATUS_TIMEOUT) - - -def setup_zebra_for_rotation( - zebra: Zebra, - zebra_shutter: ZebraShutter, - axis: EncEnum = I03Axes.OMEGA, - start_angle: float = 0, - scan_width: float = 360, - shutter_opening_deg: float = 2.5, - shutter_opening_s: float = 0.04, - direction: RotationDirection = RotationDirection.POSITIVE, - group: str = "setup_zebra_for_rotation", - wait: bool = True, -): - """Set up the Zebra to collect a rotation dataset. Any plan using this is - responsible for setting the smargon velocity appropriately so that the desired - image width is achieved with the exposure time given here. - - Parameters: - zebra: The zebra device to use - axis: I03 axes enum representing which axis to use for position - compare. Currently always omega. - start_angle: Position at which the scan should begin, in degrees. - scan_width: Total angle through which to collect, in degrees. - shutter_opening_deg:How many degrees of rotation it takes for the fast shutter - to open. Increases the gate width. - shutter_opening_s: How many seconds it takes for the fast shutter to open. The - detector pulse is delayed after the shutter signal by this - amount. - direction: RotationDirection enum for positive or negative. - Defaults to Positive. - group: A name for the group of statuses generated - wait: Block until all the settings have completed - """ - - if not isinstance(direction, RotationDirection): - raise ValueError( - "Disallowed rotation direction provided to Zebra setup plan. " - "Use RotationDirection.POSITIVE or RotationDirection.NEGATIVE." - ) - yield from bps.abs_set(zebra.pc.dir, direction.value, group=group) - LOGGER.info("ZEBRA SETUP: START") - # Set gate start, adjust for shutter opening time if necessary - LOGGER.info(f"ZEBRA SETUP: degrees to adjust for shutter = {shutter_opening_deg}") - LOGGER.info(f"ZEBRA SETUP: start angle start: {start_angle}") - LOGGER.info(f"ZEBRA SETUP: start angle adjusted, gate start set to: {start_angle}") - yield from bps.abs_set(zebra.pc.gate_start, start_angle, group=group) - # set gate width to total width - yield from bps.abs_set( - zebra.pc.gate_width, scan_width + shutter_opening_deg, group=group - ) - LOGGER.info( - f"Pulse start set to shutter open time, set to: {abs(shutter_opening_s)}" - ) - yield from bps.abs_set(zebra.pc.pulse_start, abs(shutter_opening_s), group=group) - # Set gate position to be angle of interest - yield from bps.abs_set(zebra.pc.gate_trigger, axis.value, group=group) - # Set shutter to automatic and to trigger via PC_GATE - yield from configure_zebra_and_shutter_for_auto_shutter( - zebra, zebra_shutter, zebra.mapping.sources.PC_GATE, group=group - ) - # Trigger the detector with a pulse - yield from bps.abs_set( - zebra.output.out_pvs[zebra.mapping.outputs.TTL_DETECTOR], - zebra.mapping.sources.PC_PULSE, - group=group, - ) - - # Don't use the fluorescence detector if connected - try: - yield from bps.abs_set( - zebra.output.out_pvs[zebra.mapping.outputs.TTL_XSPRESS3], - zebra.mapping.sources.DISCONNECT, - group=group, - ) - except UnmappedZebraError: - ... - yield from bps.abs_set( - zebra.output.pulse_1.input, zebra.mapping.sources.DISCONNECT, group=group - ) - LOGGER.info(f"ZEBRA SETUP: END - {'' if wait else 'not'} waiting for completion") - if wait: - yield from bps.wait(group, timeout=ZEBRA_STATUS_TIMEOUT) diff --git a/src/mx_bluesky/common/parameters/components.py b/src/mx_bluesky/common/parameters/components.py index 1fe30f7455..58b78ccf4b 100644 --- a/src/mx_bluesky/common/parameters/components.py +++ b/src/mx_bluesky/common/parameters/components.py @@ -12,7 +12,7 @@ DetectorParams, TriggerMode, ) -from dodal.utils import BeamlinePrefix, get_beamline_name +from dodal.utils import get_beamline_name from pydantic import ( BaseModel, ConfigDict, @@ -25,7 +25,6 @@ from semver import Version from mx_bluesky.common.parameters.constants import ( - TEST_MODE, DetectorParamConstants, GridscanParamConstants, ) @@ -155,9 +154,6 @@ class WithVisit(BaseModel): det_dist_to_beam_converter_path: str = Field( default=DetectorParamConstants.BEAM_XY_LUT_PATH ) - insertion_prefix: str = ( - f"{BeamlinePrefix(BL).insertion_prefix}" if TEST_MODE else "SR03I" - ) detector_distance_mm: float | None = Field(default=None, gt=0) diff --git a/src/mx_bluesky/common/parameters/device_composites.py b/src/mx_bluesky/common/parameters/device_composites.py index 33f797323c..fa47c1d1fd 100644 --- a/src/mx_bluesky/common/parameters/device_composites.py +++ b/src/mx_bluesky/common/parameters/device_composites.py @@ -1,7 +1,4 @@ -from typing import Protocol - import pydantic -from bluesky.protocols import Readable from dodal.devices.aperturescatterguard import ( ApertureScatterguard, ) @@ -26,7 +23,6 @@ from dodal.devices.zebra.zebra import Zebra from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter from dodal.devices.zocalo import ZocaloResults -from ophyd_async.epics.motor import Motor @pydantic.dataclasses.dataclass(config={"arbitrary_types_allowed": True}) @@ -67,10 +63,3 @@ class GridDetectThenXRayCentreComposite(FlyScanEssentialDevices): zebra: Zebra robot: BartRobot sample_shutter: ZebraShutter - - -class GonioWithXYZOmega(Readable, Protocol): - omega: Motor - x: Motor - y: Motor - z: Motor diff --git a/src/mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py b/src/mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py index 14a4f97978..d860fa42c3 100644 --- a/src/mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py +++ b/src/mx_bluesky/hyperion/experiment_plans/rotation_scan_plan.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import bluesky.plan_stubs as bps import bluesky.preprocessors as bpp import pydantic @@ -53,9 +51,6 @@ RotationMotionProfile, calculate_motion_profile, ) -from mx_bluesky.common.experiment_plans.setup_zebra import ( - arm_zebra, -) from mx_bluesky.common.parameters.components import WithSnapshot from mx_bluesky.common.parameters.rotation import ( RotationScan, @@ -66,6 +61,9 @@ ) from mx_bluesky.common.utils.context import device_composite_from_context from mx_bluesky.common.utils.log import LOGGER +from mx_bluesky.hyperion.device_setup_plans.setup_zebra import ( + arm_zebra, +) from mx_bluesky.hyperion.parameters.constants import CONST diff --git a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py index 37dcbb4075..ab7d43bc33 100644 --- a/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py +++ b/tests/unit_tests/beamlines/i24/jungfrau_commissioning/test_rotation_scan.py @@ -8,6 +8,7 @@ from dodal.devices.i24.aperture import AperturePositions from dodal.devices.i24.beamstop import BeamstopPositions from dodal.devices.i24.dual_backlight import BacklightPositions +from ophyd_async.core import completed_status from ophyd_async.testing import set_mock_value from mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan import ( @@ -48,33 +49,31 @@ def get_good_multi_rotation_params(transmissions: list[float], tmp_path): @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.read_devices_for_metadata" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan.read_devices_for_metadata" ) @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan._cleanup_plan" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan._cleanup_plan" ) @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.check_topup_and_wait_if_necessary" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan.check_topup_and_wait_if_necessary" ) -@patch("mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.arm_zebra") @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.setup_zebra_for_rotation" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan.setup_zebra_for_rotation" ) @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.calculate_motion_profile" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan.calculate_motion_profile" ) @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.set_up_beamline_for_rotation" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan.set_up_beamline_for_rotation" ) @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.fly_jungfrau" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan.fly_jungfrau" ) async def test_single_rotation_plan_in_re( mock_fly: MagicMock, mock_setup_beamline: MagicMock, mock_calc_motion_profile: MagicMock, mock_setup_zebra: MagicMock, - mock_arm_zebra: MagicMock, mock_check_topup: MagicMock, mock_cleanup: MagicMock, mock_metadata_read: MagicMock, @@ -83,6 +82,8 @@ async def test_single_rotation_plan_in_re( rotation_composite: RotationScanComposite, ): # Test correct functions are called, but don't test bluesky messages + mock_zebra_arm = MagicMock(side_effect=lambda _: completed_status()) + rotation_composite.zebra.pc.arm.set = mock_zebra_arm params = get_good_single_rotation_params(tmp_path) mock_calc_motion_profile.return_value = calculate_motion_profile(params, 1, 1) RE(single_rotation_plan(rotation_composite, params)) @@ -93,7 +94,7 @@ async def test_single_rotation_plan_in_re( params, 1, await rotation_composite.gonio.omega.max_velocity.get_value() ) mock_setup_zebra.assert_called_once() - mock_arm_zebra.assert_called_once() + mock_zebra_arm.assert_called_once() mock_check_topup.assert_called_once() mock_metadata_read.assert_called_once() mock_fly.assert_called_once() @@ -101,13 +102,13 @@ async def test_single_rotation_plan_in_re( @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.check_topup_and_wait_if_necessary" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan.check_topup_and_wait_if_necessary" ) @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.set_up_beamline_for_rotation" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan.set_up_beamline_for_rotation" ) @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.fly_jungfrau" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan.fly_jungfrau" ) def test_single_rotation_plan_in_simulator( _mock_fly: MagicMock, @@ -164,7 +165,7 @@ def test_single_rotation_plan_in_simulator( @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.single_rotation_plan" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan.single_rotation_plan" ) def test_multi_rotation_plan_in_re( mock_single_rotation: MagicMock, @@ -225,11 +226,11 @@ class FakeException(Exception): ... @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.check_topup_and_wait_if_necessary", + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan.check_topup_and_wait_if_necessary", new=MagicMock(side_effect=FakeException), # Exit test early by inserting exception ) @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.set_up_beamline_for_rotation" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan.set_up_beamline_for_rotation" ) def test_single_rotation_plan_uses_default_if_no_det_distance( mock_set_up_beamline: MagicMock, @@ -247,7 +248,7 @@ def test_single_rotation_plan_uses_default_if_no_det_distance( @patch( - "mx_bluesky.beamlines.i24.jungfrau_commissioning.rotation_scan_plan.tidy_up_zebra_after_rotation_scan" + "mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan.tidy_up_zebra_after_rotation_scan" ) def test_cleanup_plan( mock_tidy_zebra: MagicMock, rotation_composite: RotationScanComposite, RE: RunEngine diff --git a/tests/unit_tests/hyperion/experiment_plans/test_rotation_scan_plan.py b/tests/unit_tests/hyperion/experiment_plans/test_rotation_scan_plan.py index 03d08f223e..4dc1e54336 100644 --- a/tests/unit_tests/hyperion/experiment_plans/test_rotation_scan_plan.py +++ b/tests/unit_tests/hyperion/experiment_plans/test_rotation_scan_plan.py @@ -882,7 +882,9 @@ def test_rotation_scan_moves_beamstop_into_place( "mx_bluesky.common.external_interaction.callbacks.common.zocalo_callback.ZocaloTrigger", MagicMock(), ) -@patch("mx_bluesky.common.experiment_plans.setup_zebra.setup_zebra_for_rotation") +@patch( + "mx_bluesky.common.device_setup_plans.setup_zebra_and_shutter.setup_zebra_for_rotation" +) def test_rotation_scan_plan_with_omega_flip_inverts_motor_movements_but_not_event_params( mock_setup_zebra_for_rotation: MagicMock, omega_flip: bool, From 31fbce736c058f871e15b903b224284dcd74bc2d Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Wed, 19 Nov 2025 10:34:02 +0000 Subject: [PATCH 60/64] Start on jf ui plans --- .../serial/web_gui_plans/jungfrau_plans.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/mx_bluesky/beamlines/i24/serial/web_gui_plans/jungfrau_plans.py diff --git a/src/mx_bluesky/beamlines/i24/serial/web_gui_plans/jungfrau_plans.py b/src/mx_bluesky/beamlines/i24/serial/web_gui_plans/jungfrau_plans.py new file mode 100644 index 0000000000..e05e1f5c0a --- /dev/null +++ b/src/mx_bluesky/beamlines/i24/serial/web_gui_plans/jungfrau_plans.py @@ -0,0 +1,54 @@ +import bluesky.plan_stubs as bps +import bluesky.preprocessors as bpp +from bluesky.utils import MsgGenerator + +PARAM_MODEL_VERSION = "5.0.0" +BEAMLINE = "BL24I" + + +@bpp.run_decorator() +def run_single_rotation_plan( + exposure_time_s: float, + omega_start_deg: float, + omega_increment_deg: float, + total_scan_width_deg: float, + detector_distance_mm: float, + shutter_opening_time_s: float, + visit: str, + file_name: str, + storage_directory: str, + transmission: float, +) -> MsgGenerator: + # params = SingleRotationScan( + # exposure_time_s=exposure_time_s, + # omega_start_deg=omega_start_deg, + # rotation_increment_deg=omega_increment_deg, + # scan_width_deg=total_scan_width_deg, + # detector_distance_mm=detector_distance_mm, + # visit=visit, + # file_name=file_name, + # storage_directory=storage_directory, + # shutter_opening_time_s=shutter_opening_time_s, + # transmission_frac=transmission, + # parameter_model_version=PARAM_MODEL_VERSION, + # beamline="BL24I", + # sample_id=0, + # ) + + yield from bps.null() + + +@bpp.run_decorator() +def run_multi_rotation_plan( + exposure_time_s: float, + omega_start_deg: float, + omega_increment_deg: float, + total_scan_width_deg: float, + detector_distance_mm: float, + shutter_opening_time_s: float, + visit: str, + file_name: str, + storage_directory: str, + transmission_fractions: list[float], +) -> MsgGenerator: + yield from bps.null() From 7bb516b3b9934b5dcdce7ff834ff7bf86cc6d01e Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Wed, 19 Nov 2025 14:19:00 +0000 Subject: [PATCH 61/64] Just a try --- pyproject.toml | 3 +- .../serial/web_gui_plans/jungfrau_plans.py | 157 ++++++++++++++---- 2 files changed, 131 insertions(+), 29 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9e76a151d4..4fbf20297a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,8 @@ dependencies = [ "ophyd >= 1.10.5", "ophyd-async >= 0.13.7", "bluesky >= 1.14.6", - "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@main", + "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@c8db0925b52da2c121540b3a594165d9309cbf85", + # Pin to temp dodal branch till https://github.com/DiamondLightSource/dodal/pull/1473 is updated and merged ] diff --git a/src/mx_bluesky/beamlines/i24/serial/web_gui_plans/jungfrau_plans.py b/src/mx_bluesky/beamlines/i24/serial/web_gui_plans/jungfrau_plans.py index e05e1f5c0a..883b5e502e 100644 --- a/src/mx_bluesky/beamlines/i24/serial/web_gui_plans/jungfrau_plans.py +++ b/src/mx_bluesky/beamlines/i24/serial/web_gui_plans/jungfrau_plans.py @@ -1,6 +1,31 @@ -import bluesky.plan_stubs as bps import bluesky.preprocessors as bpp from bluesky.utils import MsgGenerator +from dodal.common import inject +from dodal.devices.attenuator.attenuator import EnumFilterAttenuator +from dodal.devices.hutch_shutter import HutchShutter +from dodal.devices.i24.aperture import Aperture +from dodal.devices.i24.beamstop import Beamstop +from dodal.devices.i24.commissioning_jungfrau import CommissioningJungfrau +from dodal.devices.i24.dcm import DCM +from dodal.devices.i24.dual_backlight import DualBacklight +from dodal.devices.i24.vgonio import VerticalGoniometer +from dodal.devices.motors import YZStage +from dodal.devices.synchrotron import Synchrotron +from dodal.devices.xbpm_feedback import XBPMFeedback +from dodal.devices.zebra.zebra import Zebra +from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter + +from mx_bluesky.beamlines.i24.jungfrau_commissioning.composites import ( + RotationScanComposite, +) +from mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.rotation_scan_plan import ( + multi_rotation_plan_varying_transmission, + single_rotation_plan, +) +from mx_bluesky.beamlines.i24.parameters.rotation import ( + MultiRotationScanByTransmissions, +) +from mx_bluesky.common.parameters.rotation import SingleRotationScan PARAM_MODEL_VERSION = "5.0.0" BEAMLINE = "BL24I" @@ -8,34 +33,65 @@ @bpp.run_decorator() def run_single_rotation_plan( - exposure_time_s: float, - omega_start_deg: float, - omega_increment_deg: float, - total_scan_width_deg: float, - detector_distance_mm: float, - shutter_opening_time_s: float, - visit: str, - file_name: str, - storage_directory: str, - transmission: float, + # exposure_time_s: float, + # omega_start_deg: float, + # omega_increment_deg: float, + # total_scan_width_deg: float, + # detector_distance_mm: float, + # shutter_opening_time_s: float, + # visit: str, + # file_name: str, + # storage_directory: str, + # transmission: float, + # **params + aperture: Aperture = inject("aperture"), + attenuator: EnumFilterAttenuator = inject("attenuator"), + jungfrau: CommissioningJungfrau = inject("commissioning_jungfrau"), + gonio: VerticalGoniometer = inject("vgonio"), + synchrotron: Synchrotron = inject("synchrotron"), + sample_shutter: ZebraShutter = inject("sample_shutter"), + zebra: Zebra = inject("zebra"), + xbpm_feedback: XBPMFeedback = inject("xbpm_feedback"), + hutch_shutter: HutchShutter = inject("shutter"), + beamstop: Beamstop = inject("beamstop"), + detector_stage: YZStage = inject("detector_motion"), + backlight: DualBacklight = inject("backlight"), + dcm: DCM = inject("dcm"), + **params, ) -> MsgGenerator: - # params = SingleRotationScan( - # exposure_time_s=exposure_time_s, - # omega_start_deg=omega_start_deg, - # rotation_increment_deg=omega_increment_deg, - # scan_width_deg=total_scan_width_deg, - # detector_distance_mm=detector_distance_mm, - # visit=visit, - # file_name=file_name, - # storage_directory=storage_directory, - # shutter_opening_time_s=shutter_opening_time_s, - # transmission_frac=transmission, - # parameter_model_version=PARAM_MODEL_VERSION, - # beamline="BL24I", - # sample_id=0, - # ) + composite = RotationScanComposite( + aperture, + attenuator, + jungfrau, + gonio, + synchrotron, + sample_shutter, + zebra, + xbpm_feedback, + hutch_shutter, + beamstop, + detector_stage, + backlight, + dcm, + ) + parameters = SingleRotationScan( + exposure_time_s=params["exposure_time_s"], + omega_start_deg=params["omega_start_deg"], + rotation_increment_deg=params["omega_increment_deg"], + scan_width_deg=params["total_scan_width_deg"], + detector_distance_mm=params["detector_distance_mm"], + visit=params["visit"], + file_name=params["file_name"], + storage_directory=params["storage_directory"], + shutter_opening_time_s=params["shutter_opening_time_s"], + transmission_frac=params["transmission"], + parameter_model_version=PARAM_MODEL_VERSION, + beamline="BL24I", + sample_id=0, + snapshot_directory=None, + ) - yield from bps.null() + yield from single_rotation_plan(composite, parameters) @bpp.run_decorator() @@ -50,5 +106,50 @@ def run_multi_rotation_plan( file_name: str, storage_directory: str, transmission_fractions: list[float], + aperture: Aperture = inject("aperture"), + attenuator: EnumFilterAttenuator = inject("attenuator"), + jungfrau: CommissioningJungfrau = inject("commissioning_jungfrau"), + gonio: VerticalGoniometer = inject("vgonio"), + synchrotron: Synchrotron = inject("synchrotron"), + sample_shutter: ZebraShutter = inject("sample_shutter"), + zebra: Zebra = inject("zebra"), + xbpm_feedback: XBPMFeedback = inject("xbpm_feedback"), + hutch_shutter: HutchShutter = inject("shutter"), + beamstop: Beamstop = inject("beamstop"), + detector_stage: YZStage = inject("detector_motion"), + backlight: DualBacklight = inject("backlight"), + dcm: DCM = inject("dcm"), ) -> MsgGenerator: - yield from bps.null() + composite = RotationScanComposite( + aperture, + attenuator, + jungfrau, + gonio, + synchrotron, + sample_shutter, + zebra, + xbpm_feedback, + hutch_shutter, + beamstop, + detector_stage, + backlight, + dcm, + ) + params = MultiRotationScanByTransmissions( + exposure_time_s=exposure_time_s, + omega_start_deg=omega_start_deg, + rotation_increment_deg=omega_increment_deg, + scan_width_deg=total_scan_width_deg, + detector_distance_mm=detector_distance_mm, + visit=visit, + file_name=file_name, + storage_directory=storage_directory, + shutter_opening_time_s=shutter_opening_time_s, + transmission_frac=-1, + transmission_fractions=transmission_fractions, + parameter_model_version=PARAM_MODEL_VERSION, + beamline="BL24I", + sample_id=0, + snapshot_directory=None, + ) + yield from multi_rotation_plan_varying_transmission(composite, params) From 20b771c8f7c2ef8b17a1b0c901a115be48aeed3e Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Wed, 19 Nov 2025 14:20:50 +0000 Subject: [PATCH 62/64] Expose plans to blueapi --- src/mx_bluesky/beamlines/i24/serial/__init__.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/serial/__init__.py b/src/mx_bluesky/beamlines/i24/serial/__init__.py index 02f9de868d..d12631dd5a 100644 --- a/src/mx_bluesky/beamlines/i24/serial/__init__.py +++ b/src/mx_bluesky/beamlines/i24/serial/__init__.py @@ -1,4 +1,6 @@ -from mx_bluesky.beamlines.i24.jungfrau_commissioning.do_darks import do_pedestal_darks +from mx_bluesky.beamlines.i24.jungfrau_commissioning.experiment_plans.do_darks import ( + do_pedestal_darks, +) from mx_bluesky.beamlines.i24.serial.web_gui_plans.general_plans import ( gui_gonio_move_on_click, gui_move_backlight, @@ -33,6 +35,10 @@ ) from .log import clean_up_log_config_at_end, setup_collection_logs from .setup_beamline.setup_detector import setup_detector_stage +from .web_gui_plans.jungfrau_plans import ( + run_multi_rotation_plan, + run_single_rotation_plan, +) __all__ = [ "setup_detector_stage", @@ -62,8 +68,10 @@ "gui_run_chip_collection", "gui_move_backlight", "gui_set_zoom_level", - # Jungfrau - "do_pedestal_darks", "gui_set_fiducial_0", "gui_run_extruder_collection", + # Jungfrau + "do_pedestal_darks", + "run_single_rotation_plan", + "run_multi_rotation_plan", ] From d08cd98a53c3456031fb5e6fd0fd7a0a240c6bc6 Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Fri, 21 Nov 2025 15:02:04 +0000 Subject: [PATCH 63/64] Do not use kwargs --- .../serial/web_gui_plans/jungfrau_plans.py | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/src/mx_bluesky/beamlines/i24/serial/web_gui_plans/jungfrau_plans.py b/src/mx_bluesky/beamlines/i24/serial/web_gui_plans/jungfrau_plans.py index 883b5e502e..71e86d5f7d 100644 --- a/src/mx_bluesky/beamlines/i24/serial/web_gui_plans/jungfrau_plans.py +++ b/src/mx_bluesky/beamlines/i24/serial/web_gui_plans/jungfrau_plans.py @@ -33,17 +33,16 @@ @bpp.run_decorator() def run_single_rotation_plan( - # exposure_time_s: float, - # omega_start_deg: float, - # omega_increment_deg: float, - # total_scan_width_deg: float, - # detector_distance_mm: float, - # shutter_opening_time_s: float, - # visit: str, - # file_name: str, - # storage_directory: str, - # transmission: float, - # **params + exposure_time_s: float, + omega_start_deg: float, + omega_increment_deg: float, + total_scan_width_deg: float, + detector_distance_mm: float, + shutter_opening_time_s: float, + visit: str, + file_name: str, + storage_directory: str, + transmission: float, aperture: Aperture = inject("aperture"), attenuator: EnumFilterAttenuator = inject("attenuator"), jungfrau: CommissioningJungfrau = inject("commissioning_jungfrau"), @@ -57,7 +56,6 @@ def run_single_rotation_plan( detector_stage: YZStage = inject("detector_motion"), backlight: DualBacklight = inject("backlight"), dcm: DCM = inject("dcm"), - **params, ) -> MsgGenerator: composite = RotationScanComposite( aperture, @@ -75,16 +73,16 @@ def run_single_rotation_plan( dcm, ) parameters = SingleRotationScan( - exposure_time_s=params["exposure_time_s"], - omega_start_deg=params["omega_start_deg"], - rotation_increment_deg=params["omega_increment_deg"], - scan_width_deg=params["total_scan_width_deg"], - detector_distance_mm=params["detector_distance_mm"], - visit=params["visit"], - file_name=params["file_name"], - storage_directory=params["storage_directory"], - shutter_opening_time_s=params["shutter_opening_time_s"], - transmission_frac=params["transmission"], + exposure_time_s=exposure_time_s, + omega_start_deg=omega_start_deg, + rotation_increment_deg=omega_increment_deg, + scan_width_deg=total_scan_width_deg, + detector_distance_mm=detector_distance_mm, + visit=visit, + file_name=file_name, + storage_directory=storage_directory, + shutter_opening_time_s=shutter_opening_time_s, + transmission_frac=transmission, parameter_model_version=PARAM_MODEL_VERSION, beamline="BL24I", sample_id=0, From 41603f0e50d8616a45be1e6ff5e897a3988d2abb Mon Sep 17 00:00:00 2001 From: Noemi Frisina Date: Tue, 20 Jan 2026 11:13:04 +0000 Subject: [PATCH 64/64] Pin dodal to main --- pyproject.toml | 7 +++---- uv.lock | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 69cfaa2f37..bc7c09702a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,8 +50,7 @@ dependencies = [ "ophyd >= 1.10.5", "ophyd-async >= 0.14.0", "bluesky >= 1.14.6", - "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@c8db0925b52da2c121540b3a594165d9309cbf85", - # Pin to temp dodal branch till https://github.com/DiamondLightSource/dodal/pull/1473 is updated and merged + "dls-dodal @ git+https://github.com/DiamondLightSource/dodal.git@main", ] @@ -209,7 +208,7 @@ commands = [ description = "Run tests with coverage" commands = [ [ - "pytest", + "pytest", "--cov=mx_bluesky", "--cov-report", "term", @@ -246,7 +245,7 @@ setenv = [ ] commands = [ [ - "pytest", + "pytest", "-m", "system_test", "--timeout=60", diff --git a/uv.lock b/uv.lock index e55cf34495..d45a1767e6 100644 --- a/uv.lock +++ b/uv.lock @@ -1260,8 +1260,8 @@ wheels = [ [[package]] name = "dls-dodal" -version = "1.65.1.dev21+gc8db0925b" -source = { git = "https://github.com/DiamondLightSource/dodal.git?rev=c8db0925b52da2c121540b3a594165d9309cbf85#c8db0925b52da2c121540b3a594165d9309cbf85" } +version = "1.69.0" +source = { git = "https://github.com/DiamondLightSource/dodal.git?rev=main#d245f0ae6b37f73590919b1e645bb3b6969f343a" } dependencies = [ { name = "aiofiles" }, { name = "aiohttp" }, @@ -3372,7 +3372,7 @@ requires-dist = [ { name = "caproto" }, { name = "daq-config-server", specifier = ">=1.0.0rc2" }, { name = "deepdiff" }, - { name = "dls-dodal", git = "https://github.com/DiamondLightSource/dodal.git?rev=c8db0925b52da2c121540b3a594165d9309cbf85" }, + { name = "dls-dodal", git = "https://github.com/DiamondLightSource/dodal.git?rev=main" }, { name = "fastapi", extras = ["all"] }, { name = "flask-restful" }, { name = "jupyterlab" },