From a658ca94d46fb967df7aac2bf06381acd9487df4 Mon Sep 17 00:00:00 2001 From: Tom Kane Date: Tue, 13 Oct 2026 11:09:08 +0000 Subject: [PATCH 01/13] use diodes and pandabox as a detector in plan --- src/test_rig_bluesky/plans.py | 25 +++- src/test_rig_bluesky/spectroscopy_plans.py | 19 ++- tests/unit_tests/test_plans.py | 145 +-------------------- 3 files changed, 41 insertions(+), 148 deletions(-) diff --git a/src/test_rig_bluesky/plans.py b/src/test_rig_bluesky/plans.py index edad977..7161836 100644 --- a/src/test_rig_bluesky/plans.py +++ b/src/test_rig_bluesky/plans.py @@ -124,7 +124,13 @@ def fly_scan( panda_whitelist: list[str] | None = None, plan_name: str = "fly_scan", metadata: dict[str, Any] | None = None, + pandabox: HDFPanda | None = None, + diodes: Readable | None = None, ): + """Fly scan a triggered detector along a spec.""" + + detectors = [detector] if pandabox is None else [pandabox, detector] + yield from load_panda_settings( panda=pandabrick, design_name="pandabrick_baseline", @@ -155,7 +161,7 @@ def fly_scan( # Reproduce metadata present in spec_scan _md = { "plan_args": { - "detectors": {det.name for det in [pandabrick, detector]}, + "detectors": {det.name for det in [pandabrick, *detectors]}, "spec": repr(fly_spec), }, "plan_name": plan_name, @@ -183,21 +189,28 @@ def fly_scan( # @attach_data_session_metadata_decorator() @bpp.run_decorator(md=_md) - @bpp.stage_decorator([pandabrick, pandabrick_seq_flyer, detector, pmac_flyer]) + @bpp.stage_decorator([pandabrick, pandabrick_seq_flyer, *detectors, pmac_flyer]) def inner_plan(): + if diodes is not None: + yield from bps.create(name="diodes") + yield from bps.read(diodes) + yield from bps.save() + # Prepare pmac with the trajectory yield from bps.prepare(pmac_flyer, pmac_scan_info) # prepare sequencer table yield from bps.prepare(pandabrick_seq_flyer, scan_spec_info) # prepare panda and hdf writer once, at start of scan yield from bps.prepare(pandabrick, panda_hdf_info) + if pandabox is not None: + yield from bps.prepare(pandabox, panda_hdf_info) # prepare detector and info # waiting for this last prepare means all prepare functions will be complete yield from bps.prepare(detector, detector_info, wait=True) # Need to run this after detectors are prepared yield from bps.declare_stream( - pandabrick, detector, name="primary", collect=True + pandabrick, *detectors, name="primary", collect=True ) # Start the detectors and hdf writers acquiring. @@ -206,6 +219,8 @@ def inner_plan(): # its own with wait=true. yield from bps.kickoff(pandabrick) yield from bps.kickoff(pandabrick_seq_flyer) + if pandabox is not None: + yield from bps.kickoff(pandabox) yield from bps.kickoff(detector, wait=True) # Start the trajectory. @@ -217,11 +232,11 @@ def inner_plan(): pmac_flyer, pandabrick_seq_flyer, pandabrick, - detector, + *detectors, ), dets=( pandabrick, - detector, + *detectors, ), stream_name="primary", flush_period=0.5, diff --git a/src/test_rig_bluesky/spectroscopy_plans.py b/src/test_rig_bluesky/spectroscopy_plans.py index 3176140..bb9f7dc 100644 --- a/src/test_rig_bluesky/spectroscopy_plans.py +++ b/src/test_rig_bluesky/spectroscopy_plans.py @@ -8,6 +8,7 @@ from bluesky.protocols import Movable from bluesky.utils import MsgGenerator from dodal.common import inject +from dodal.devices.beamlines.b01_1.diodes import RGBDiodes from dodal.devices.motors import XYZStage from dodal.plans import spec_scan from ophyd_async.epics.adaravis import AravisDetector @@ -26,6 +27,8 @@ LOGGER = logging.getLogger(__name__) spectroscopy_detector = inject("spectroscopy_detector") +spectroscopy_diodes = inject("spectroscopy_diodes") +pandabox = inject("pandabox") sample_stage = inject("sample_stage") # The encoder entries map to specific axes, so despite the PandA being shared @@ -44,12 +47,18 @@ def spectroscopy( sample_stage: XYZStage = sample_stage, pmac: PmacIO = pmac, pandabrick: HDFPanda = pandabrick, + pandabox: HDFPanda = pandabox, + spectroscopy_diodes: RGBDiodes = spectroscopy_diodes, spec: Spec[Movable] | None = None, exposure_time: float = 0.1, fly: bool = True, + gain: str | None = None, metadata: dict[str, Any] | None = None, ) -> MsgGenerator[None]: - """Do a spectroscopy scan.""" + """Do a spectroscopy scan, acquiring from the camera and the RGB diodes.""" + if gain is not None: + yield from bps.abs_set(spectroscopy_diodes.gain, gain, wait=True) + yield from load_settings( device=spectroscopy_detector, design_name="spectroscopy_detector_baseline", @@ -143,11 +152,13 @@ def spectroscopy( detector_deadtime=MANTA_DETECTOR_DEADTIME, panda_whitelist=PANDA_WHITELIST, metadata=metadata, + pandabox=pandabox, + diodes=spectroscopy_diodes, ) else: LOGGER.info("Performing a step scan.") yield from spec_scan( - {spectroscopy_detector, sample_stage}, + {spectroscopy_detector, spectroscopy_diodes, sample_stage}, spec, # type: ignore metadata=metadata, ) @@ -158,6 +169,8 @@ def demo_spectroscopy( sample_stage: XYZStage = sample_stage, pmac: PmacIO = pmac, pandabrick: HDFPanda = pandabrick, + pandabox: HDFPanda = pandabox, + spectroscopy_diodes: RGBDiodes = spectroscopy_diodes, total_number_of_scan_points: int = 25, grid_size: float = 5.0, grid_origin_x: float = 0.0, @@ -205,6 +218,8 @@ def demo_spectroscopy( sample_stage=sample_stage, pmac=pmac, pandabrick=pandabrick, + pandabox=pandabox, + spectroscopy_diodes=spectroscopy_diodes, spec=grid, exposure_time=exposure_time, fly=fly, diff --git a/tests/unit_tests/test_plans.py b/tests/unit_tests/test_plans.py index 24b1893..2eb2025 100644 --- a/tests/unit_tests/test_plans.py +++ b/tests/unit_tests/test_plans.py @@ -1,5 +1,4 @@ import asyncio -import unittest.mock from collections import defaultdict from unittest.mock import ANY, AsyncMock, Mock, patch @@ -7,7 +6,10 @@ import pytest from bluesky import RunEngine from dodal.devices.motors import XYZStage -from ophyd_async.core import callback_on_mock_put, set_mock_value +from ophyd_async.core import ( + callback_on_mock_put, + set_mock_value, +) from ophyd_async.epics.adaravis import AravisDetector from ophyd_async.epics.adcore import NDFileHDF5IO from ophyd_async.fastcs.panda import HDFPanda @@ -20,7 +22,6 @@ serialize_spec, snapshot, ) -from test_rig_bluesky.spectroscopy_plans import demo_spectroscopy, spectroscopy @pytest.fixture @@ -136,144 +137,6 @@ def test_snapshot( } -async def test_spectroscopy( - run_engine: RunEngine, - spectroscopy_detector: AravisDetector, - sample_stage: XYZStage, - pandabrick: HDFPanda, -): - docs = defaultdict(list) - run_engine.subscribe(lambda name, doc: docs[name].append(doc)) - - run_engine( - spectroscopy( - spectroscopy_detector=spectroscopy_detector, - sample_stage=sample_stage, - pandabrick=pandabrick, - spec=Line(sample_stage.y, 4.2, 6, 3) * Line(sample_stage.x, 0, 5, 10), - exposure_time=0.2, - fly=False, - ) - ) - - assert await spectroscopy_detector.driver.acquire_time.get_value() == 0.2 - - assert_emitted( - docs, - start=1, - descriptor=1, - stream_resource=4, - stream_datum=4 * 30, - event=30, - stop=1, - ) - - -async def test_spectroscopy_defaults( - run_engine: RunEngine, - spectroscopy_detector: AravisDetector, - sample_stage: XYZStage, - pandabrick: HDFPanda, -): - docs = defaultdict(list) - run_engine.subscribe(lambda name, doc: docs[name].append(doc)) - - run_engine( - spectroscopy( - spectroscopy_detector=spectroscopy_detector, - sample_stage=sample_stage, - pandabrick=pandabrick, - fly=False, - ) - ) - - assert await spectroscopy_detector.driver.acquire_time.get_value() == 0.1 - - assert_emitted( - docs, - start=1, - descriptor=1, - stream_resource=4, - stream_datum=4 * 5, - event=5, - stop=1, - ) - - -def test_spectroscopy_datasets( - run_engine: RunEngine, - spectroscopy_detector: AravisDetector, - sample_stage: XYZStage, - pandabrick: HDFPanda, -): - docs = defaultdict(list) - run_engine.subscribe(lambda name, doc: docs[name].append(doc)) - - run_engine( - spectroscopy( - spectroscopy_detector=spectroscopy_detector, - sample_stage=sample_stage, - pandabrick=pandabrick, - fly=False, - ) - ) - - data_keys = [resource.get("data_key") for resource in docs["stream_resource"]] - assert data_keys == ["spectroscopy_detector", "BlueTotal", "GreenTotal", "RedTotal"] - assert docs["event"][0]["data"] == { - "sample_stage-x": 0.0, - "sample_stage-y": 0.0, - "sample_stage-z": 0.0, - } - - -async def test_spectroscopy_sets_exposure_time_and_acquire_period( - run_engine: RunEngine, - spectroscopy_detector: AravisDetector, - sample_stage: XYZStage, - pandabrick: HDFPanda, -): - run_engine( - spectroscopy( - spectroscopy_detector=spectroscopy_detector, - sample_stage=sample_stage, - pandabrick=pandabrick, - exposure_time=1.0, - fly=False, - ) - ) - assert await spectroscopy_detector.driver.acquire_time.get_value() == 1.0 - assert ( - await spectroscopy_detector.driver.acquire_period.get_value() == 1.0 + 1961e-6 - ) - - -def test_demo_spectroscopy(): - fake_detector = unittest.mock.MagicMock(name="fake_detector") - fake_stage = unittest.mock.MagicMock(name="fake_stage") - with unittest.mock.patch( - "test_rig_bluesky.spectroscopy_plans.spectroscopy" - ) as mock_spec: - # Call the generator function and exhaust it - generator = demo_spectroscopy( - spectroscopy_detector=fake_detector, - sample_stage=fake_stage, - total_number_of_scan_points=25, - ) - - # Consume the generator so that the spectroscopy call is made - for _ in generator: - pass - - mock_spec.assert_called_once() - called_kwargs = mock_spec.call_args.kwargs - assert called_kwargs["spectroscopy_detector"] is fake_detector - assert called_kwargs["sample_stage"] is fake_stage - assert called_kwargs["spec"] == Line(fake_stage.y, 0.0, 5.0, 5) * ~Line( - fake_stage.x, 0.0, 5.0, 5 - ) - - def test_serialize_spec_names_device_axes(sample_stage: XYZStage): spec = Line(sample_stage.y, 4.2, 6, 3) * Line(sample_stage.x, 0, 5, 10) From e68cba158ffcef3eb3bf837db8aef18bc80f1d2e Mon Sep 17 00:00:00 2001 From: Tom Kane Date: Thu, 15 Oct 2026 16:28:54 +0000 Subject: [PATCH 02/13] update with EnzLocum as subdevice of diodes --- src/test_rig_bluesky/spectroscopy_plans.py | 9 +++-- tests/unit_tests/test_plans.py | 38 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/test_rig_bluesky/spectroscopy_plans.py b/src/test_rig_bluesky/spectroscopy_plans.py index bb9f7dc..2b4b554 100644 --- a/src/test_rig_bluesky/spectroscopy_plans.py +++ b/src/test_rig_bluesky/spectroscopy_plans.py @@ -9,6 +9,7 @@ from bluesky.utils import MsgGenerator from dodal.common import inject from dodal.devices.beamlines.b01_1.diodes import RGBDiodes +from dodal.devices.current_amplifiers import EnzLocumRange from dodal.devices.motors import XYZStage from dodal.plans import spec_scan from ophyd_async.epics.adaravis import AravisDetector @@ -52,12 +53,14 @@ def spectroscopy( spec: Spec[Movable] | None = None, exposure_time: float = 0.1, fly: bool = True, - gain: str | None = None, + amplifier_range: EnzLocumRange | None = None, metadata: dict[str, Any] | None = None, ) -> MsgGenerator[None]: """Do a spectroscopy scan, acquiring from the camera and the RGB diodes.""" - if gain is not None: - yield from bps.abs_set(spectroscopy_diodes.gain, gain, wait=True) + if amplifier_range is not None: + yield from bps.abs_set( + spectroscopy_diodes.amplifier.range, amplifier_range, wait=True + ) yield from load_settings( device=spectroscopy_detector, diff --git a/tests/unit_tests/test_plans.py b/tests/unit_tests/test_plans.py index 2eb2025..60afc13 100644 --- a/tests/unit_tests/test_plans.py +++ b/tests/unit_tests/test_plans.py @@ -5,6 +5,8 @@ import dodal.beamlines.b01_1 as b01_1 import pytest from bluesky import RunEngine +from dodal.devices.beamlines.b01_1.diodes import RGBDiodes +from dodal.devices.current_amplifiers import EnzLocumRange from dodal.devices.motors import XYZStage from ophyd_async.core import ( callback_on_mock_put, @@ -22,6 +24,7 @@ serialize_spec, snapshot, ) +from test_rig_bluesky.spectroscopy_plans import spectroscopy @pytest.fixture @@ -51,6 +54,11 @@ def pandabrick(run_engine: RunEngine) -> HDFPanda: return b01_1.pandabrick.build(connect_immediately=True, mock=True) +@pytest.fixture +def spectroscopy_diodes(run_engine: RunEngine) -> RGBDiodes: + return b01_1.spectroscopy_diodes.build(connect_immediately=True, mock=True) + + def _mock_detector_behavior(detector: AravisDetector) -> None: hdf = detector.get_plugin("hdf", NDFileHDF5IO) @@ -148,3 +156,33 @@ def test_serialize_spec_names_device_axes(sample_stage: XYZStage): sample_stage.y.name, sample_stage.x.name, ] + + +async def test_spectroscopy_with_diodes( + run_engine: RunEngine, + spectroscopy_detector: AravisDetector, + sample_stage: XYZStage, + pandabrick: HDFPanda, + spectroscopy_diodes: RGBDiodes, +): + docs = defaultdict(list) + run_engine.subscribe(lambda name, doc: docs[name].append(doc)) + + run_engine( + spectroscopy( + spectroscopy_detector=spectroscopy_detector, + sample_stage=sample_stage, + pandabrick=pandabrick, + spectroscopy_diodes=spectroscopy_diodes, + amplifier_range=EnzLocumRange.RANGE_10NA, + fly=False, + ) + ) + + assert ( + await spectroscopy_diodes.amplifier.range.get_value() + == EnzLocumRange.RANGE_10NA + ) + assert "spectroscopy_diodes-red" in docs["event"][0]["data"] + configuration = docs["descriptor"][0]["configuration"]["spectroscopy_diodes"] + assert "spectroscopy_diodes-amplifier-range_max" in configuration["data"] From 8e00d95fec3867d488a9681fbea5e9c2955fad13 Mon Sep 17 00:00:00 2001 From: Tom Kane Date: Thu, 15 Oct 2026 17:53:27 +0000 Subject: [PATCH 03/13] re-add tests with diodes mock, load panda settings, and dont allow AUTO for gain --- .../baselines/pandabox_baseline.yaml | 6 + src/test_rig_bluesky/spectroscopy_plans.py | 20 +- tests/unit_tests/test_plans.py | 174 +++++++++++++++++- 3 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 src/test_rig_bluesky/baselines/pandabox_baseline.yaml diff --git a/src/test_rig_bluesky/baselines/pandabox_baseline.yaml b/src/test_rig_bluesky/baselines/pandabox_baseline.yaml new file mode 100644 index 0000000..a3690a7 --- /dev/null +++ b/src/test_rig_bluesky/baselines/pandabox_baseline.yaml @@ -0,0 +1,6 @@ +fmc_in.val5_capture: Mean +fmc_in.val5_dataset: spectroscopy_diodes-red +fmc_in.val6_capture: Mean +fmc_in.val6_dataset: spectroscopy_diodes-green +fmc_in.val7_capture: Mean +fmc_in.val7_dataset: spectroscopy_diodes-blue diff --git a/src/test_rig_bluesky/spectroscopy_plans.py b/src/test_rig_bluesky/spectroscopy_plans.py index 2b4b554..26b1163 100644 --- a/src/test_rig_bluesky/spectroscopy_plans.py +++ b/src/test_rig_bluesky/spectroscopy_plans.py @@ -23,7 +23,14 @@ from ophyd_async.fastcs.panda import HDFPanda from scanspec.specs import Line, Spec -from .plans import fly_scan, load_settings, pandabrick, pmac, serialize_spec +from .plans import ( + fly_scan, + load_panda_settings, + load_settings, + pandabrick, + pmac, + serialize_spec, +) LOGGER = logging.getLogger(__name__) @@ -57,6 +64,14 @@ def spectroscopy( metadata: dict[str, Any] | None = None, ) -> MsgGenerator[None]: """Do a spectroscopy scan, acquiring from the camera and the RGB diodes.""" + # The range is only recorded once per scan, as configuration, so it must not + # change between points + scan_range = amplifier_range or ( + yield from bps.rd(spectroscopy_diodes.amplifier.range) + ) + if scan_range == EnzLocumRange.AUTO: + raise ValueError("The diode amplifier must be on a fixed range, not Auto") + if amplifier_range is not None: yield from bps.abs_set( spectroscopy_diodes.amplifier.range, amplifier_range, wait=True @@ -146,6 +161,9 @@ def spectroscopy( if fly: LOGGER.info("Performing a fly scan.") + # Captures the diodes' per-frame means under the same names as the step + # scan's readings + yield from load_panda_settings(panda=pandabox, design_name="pandabox_baseline") yield from fly_scan( spec, spectroscopy_detector, diff --git a/tests/unit_tests/test_plans.py b/tests/unit_tests/test_plans.py index 60afc13..48339e1 100644 --- a/tests/unit_tests/test_plans.py +++ b/tests/unit_tests/test_plans.py @@ -1,4 +1,5 @@ import asyncio +import unittest.mock from collections import defaultdict from unittest.mock import ANY, AsyncMock, Mock, patch @@ -24,7 +25,7 @@ serialize_spec, snapshot, ) -from test_rig_bluesky.spectroscopy_plans import spectroscopy +from test_rig_bluesky.spectroscopy_plans import demo_spectroscopy, spectroscopy @pytest.fixture @@ -145,6 +146,155 @@ def test_snapshot( } +async def test_spectroscopy( + run_engine: RunEngine, + spectroscopy_detector: AravisDetector, + sample_stage: XYZStage, + pandabrick: HDFPanda, + spectroscopy_diodes: RGBDiodes, +): + docs = defaultdict(list) + run_engine.subscribe(lambda name, doc: docs[name].append(doc)) + + run_engine( + spectroscopy( + spectroscopy_detector=spectroscopy_detector, + sample_stage=sample_stage, + pandabrick=pandabrick, + spectroscopy_diodes=spectroscopy_diodes, + spec=Line(sample_stage.y, 4.2, 6, 3) * Line(sample_stage.x, 0, 5, 10), + exposure_time=0.2, + fly=False, + ) + ) + + assert await spectroscopy_detector.driver.acquire_time.get_value() == 0.2 + + assert_emitted( + docs, + start=1, + descriptor=1, + stream_resource=4, + stream_datum=4 * 30, + event=30, + stop=1, + ) + + +async def test_spectroscopy_defaults( + run_engine: RunEngine, + spectroscopy_detector: AravisDetector, + sample_stage: XYZStage, + pandabrick: HDFPanda, + spectroscopy_diodes: RGBDiodes, +): + docs = defaultdict(list) + run_engine.subscribe(lambda name, doc: docs[name].append(doc)) + + run_engine( + spectroscopy( + spectroscopy_detector=spectroscopy_detector, + sample_stage=sample_stage, + pandabrick=pandabrick, + spectroscopy_diodes=spectroscopy_diodes, + fly=False, + ) + ) + + assert await spectroscopy_detector.driver.acquire_time.get_value() == 0.1 + + assert_emitted( + docs, + start=1, + descriptor=1, + stream_resource=4, + stream_datum=4 * 5, + event=5, + stop=1, + ) + + +def test_spectroscopy_datasets( + run_engine: RunEngine, + spectroscopy_detector: AravisDetector, + sample_stage: XYZStage, + pandabrick: HDFPanda, + spectroscopy_diodes: RGBDiodes, +): + docs = defaultdict(list) + run_engine.subscribe(lambda name, doc: docs[name].append(doc)) + + run_engine( + spectroscopy( + spectroscopy_detector=spectroscopy_detector, + sample_stage=sample_stage, + pandabrick=pandabrick, + spectroscopy_diodes=spectroscopy_diodes, + fly=False, + ) + ) + + data_keys = [resource.get("data_key") for resource in docs["stream_resource"]] + assert data_keys == ["spectroscopy_detector", "BlueTotal", "GreenTotal", "RedTotal"] + assert docs["event"][0]["data"] == { + "sample_stage-x": 0.0, + "sample_stage-y": 0.0, + "sample_stage-z": 0.0, + "spectroscopy_diodes-red": 0, + "spectroscopy_diodes-green": 0, + "spectroscopy_diodes-blue": 0, + } + + +async def test_spectroscopy_sets_exposure_time_and_acquire_period( + run_engine: RunEngine, + spectroscopy_detector: AravisDetector, + sample_stage: XYZStage, + pandabrick: HDFPanda, + spectroscopy_diodes: RGBDiodes, +): + run_engine( + spectroscopy( + spectroscopy_detector=spectroscopy_detector, + sample_stage=sample_stage, + pandabrick=pandabrick, + spectroscopy_diodes=spectroscopy_diodes, + exposure_time=1.0, + fly=False, + ) + ) + assert await spectroscopy_detector.driver.acquire_time.get_value() == 1.0 + assert ( + await spectroscopy_detector.driver.acquire_period.get_value() == 1.0 + 1961e-6 + ) + + +def test_demo_spectroscopy(): + fake_detector = unittest.mock.MagicMock(name="fake_detector") + fake_stage = unittest.mock.MagicMock(name="fake_stage") + with unittest.mock.patch( + "test_rig_bluesky.spectroscopy_plans.spectroscopy" + ) as mock_spec: + # Call the generator function and exhaust it + generator = demo_spectroscopy( + spectroscopy_detector=fake_detector, + sample_stage=fake_stage, + total_number_of_scan_points=25, + ) + + # Consume the generator so that the spectroscopy call is made + for _ in generator: + pass + + mock_spec.assert_called_once() + called_kwargs = mock_spec.call_args.kwargs + assert called_kwargs["spectroscopy_detector"] is fake_detector + assert called_kwargs["sample_stage"] is fake_stage + assert called_kwargs["spec"] == Line(fake_stage.y, 0.0, 5.0, 5) * ~Line( + fake_stage.x, 0.0, 5.0, 5 + ) + + def test_serialize_spec_names_device_axes(sample_stage: XYZStage): spec = Line(sample_stage.y, 4.2, 6, 3) * Line(sample_stage.x, 0, 5, 10) @@ -158,7 +308,7 @@ def test_serialize_spec_names_device_axes(sample_stage: XYZStage): ] -async def test_spectroscopy_with_diodes( +async def test_spectroscopy_sets_amplifier_range( run_engine: RunEngine, spectroscopy_detector: AravisDetector, sample_stage: XYZStage, @@ -183,6 +333,24 @@ async def test_spectroscopy_with_diodes( await spectroscopy_diodes.amplifier.range.get_value() == EnzLocumRange.RANGE_10NA ) - assert "spectroscopy_diodes-red" in docs["event"][0]["data"] configuration = docs["descriptor"][0]["configuration"]["spectroscopy_diodes"] assert "spectroscopy_diodes-amplifier-range_max" in configuration["data"] + + +def test_spectroscopy_rejects_auto_amplifier_range( + run_engine: RunEngine, + spectroscopy_detector: AravisDetector, + sample_stage: XYZStage, + spectroscopy_diodes: RGBDiodes, +): + set_mock_value(spectroscopy_diodes.amplifier.range, EnzLocumRange.AUTO) + + with pytest.raises(ValueError, match="Auto"): + run_engine( + spectroscopy( + spectroscopy_detector=spectroscopy_detector, + sample_stage=sample_stage, + spectroscopy_diodes=spectroscopy_diodes, + fly=False, + ) + ) From 5464f2bde6451e92901e7bff6375b77f1dac5622 Mon Sep 17 00:00:00 2001 From: Tom Kane Date: Mon, 19 Oct 2026 13:05:17 +0000 Subject: [PATCH 04/13] update to latest dodal release with enz-locum and diodes --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e654e84..86edb58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] description = "Bluesky plans to be run on Diamond's test rigs e.g. ViSR, P45, etc" -dependencies = ["dls-dodal>=1.56.0", "scanspec>=1.0.0"] +dependencies = ["dls-dodal>=3.0.4", "scanspec>=1.0.0"] dynamic = ["version"] license.file = "LICENSE" readme = "README.md" From 64d22484d95fb33a812fbce0e129e3614e10c749 Mon Sep 17 00:00:00 2001 From: Tom Kane Date: Tue, 20 Oct 2026 08:08:50 +0000 Subject: [PATCH 05/13] require pandabox and diodes together --- src/test_rig_bluesky/plans.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/test_rig_bluesky/plans.py b/src/test_rig_bluesky/plans.py index 7161836..cd36e0e 100644 --- a/src/test_rig_bluesky/plans.py +++ b/src/test_rig_bluesky/plans.py @@ -128,6 +128,12 @@ def fly_scan( diodes: Readable | None = None, ): """Fly scan a triggered detector along a spec.""" + # The pandabox captures the diode counts, which only mean something alongside + # the amplifier range recorded from the diodes, so we need both or neither + if pandabox is not None and diodes is None: + raise ValueError("pandabox was given without diodes") + if diodes is not None and pandabox is None: + raise ValueError("diodes were given without pandabox") detectors = [detector] if pandabox is None else [pandabox, detector] From f2765676231705ba793a83a1c92327e1bb51f844 Mon Sep 17 00:00:00 2001 From: Tom Kane Date: Tue, 20 Oct 2026 10:24:16 +0000 Subject: [PATCH 06/13] fix scan_type and fly boolean bugs --- src/test_rig_bluesky/spectroscopy_plans.py | 1 - tests/unit_tests/test_plans.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/test_rig_bluesky/spectroscopy_plans.py b/src/test_rig_bluesky/spectroscopy_plans.py index 97cadb1..c4293e8 100644 --- a/src/test_rig_bluesky/spectroscopy_plans.py +++ b/src/test_rig_bluesky/spectroscopy_plans.py @@ -60,7 +60,6 @@ def spectroscopy( spectroscopy_diodes: RGBDiodes = spectroscopy_diodes, spec: Spec[Movable] | None = None, exposure_time: float = 0.1, - fly: bool = True, amplifier_range: EnzLocumRange | None = None, metadata: dict[str, Any] | None = None, *, diff --git a/tests/unit_tests/test_plans.py b/tests/unit_tests/test_plans.py index 99c3c99..d4283c1 100644 --- a/tests/unit_tests/test_plans.py +++ b/tests/unit_tests/test_plans.py @@ -328,7 +328,7 @@ async def test_spectroscopy_sets_amplifier_range( pandabrick=pandabrick, spectroscopy_diodes=spectroscopy_diodes, amplifier_range=EnzLocumRange.RANGE_10NA, - scan_type=ScanType.FLY, + scan_type=ScanType.STEP, ) ) @@ -354,6 +354,6 @@ def test_spectroscopy_rejects_auto_amplifier_range( spectroscopy_detector=spectroscopy_detector, sample_stage=sample_stage, spectroscopy_diodes=spectroscopy_diodes, - scan_type=ScanType.FLY, + scan_type=ScanType.STEP, ) ) From 91eec0d96bec5df0226ca3bbe795d04f7a5eea5e Mon Sep 17 00:00:00 2001 From: Tom Kane Date: Tue, 3 Nov 2026 14:51:46 +0000 Subject: [PATCH 07/13] update dodal import name 'spectroscopy-stage' --- src/test_rig_bluesky/spectroscopy_plans.py | 2 +- tests/system_tests/test_plans_system.py | 14 +++++++------- tests/unit_tests/test_plans.py | 14 +++++++------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/test_rig_bluesky/spectroscopy_plans.py b/src/test_rig_bluesky/spectroscopy_plans.py index c4293e8..a0419f2 100644 --- a/src/test_rig_bluesky/spectroscopy_plans.py +++ b/src/test_rig_bluesky/spectroscopy_plans.py @@ -38,7 +38,7 @@ spectroscopy_detector = inject("spectroscopy_detector") spectroscopy_diodes = inject("spectroscopy_diodes") pandabox = inject("pandabox") -sample_stage = inject("sample_stage") +sample_stage = inject("spectroscopy_stage") # The encoder entries map to specific axes, so despite the PandA being shared # hardware this list is spectroscopy's. diff --git a/tests/system_tests/test_plans_system.py b/tests/system_tests/test_plans_system.py index 745a9b7..f0417dc 100644 --- a/tests/system_tests/test_plans_system.py +++ b/tests/system_tests/test_plans_system.py @@ -18,7 +18,7 @@ def test_snapshot( params={ "devices": [ "spectroscopy_detector", - "sample_stage", + "spectroscopy_stage", ] }, instrument_session=latest_commissioning_instrument_session, @@ -31,8 +31,8 @@ def test_snapshot( def test_spectroscopy_with_custom_trajectory( bluesky_plan_runner: BlueskyPlanRunner, latest_commissioning_instrument_session: str ): - scan_spec = Line("sample_stage.x", 0.0, 5.0, 5) * Line( - "sample_stage.y", 2.0, 5.0, 3 + scan_spec = Line("spectroscopy_stage.x", 0.0, 5.0, 5) * Line( + "spectroscopy_stage.y", 2.0, 5.0, 3 ) events = bluesky_plan_runner.run( TaskRequest( @@ -97,8 +97,8 @@ def test_generic_count( def test_generic_scan( bluesky_plan_runner: BlueskyPlanRunner, latest_commissioning_instrument_session: str ) -> None: - scan_spec = Line("sample_stage.x", 0.0, 5.0, 2) * Line( - "sample_stage.y", 2.0, 5.0, 2 + scan_spec = Line("spectroscopy_stage.x", 0.0, 5.0, 2) * Line( + "spectroscopy_stage.y", 2.0, 5.0, 2 ) bluesky_plan_runner.run( TaskRequest( @@ -117,7 +117,7 @@ def test_generic_scan( def test_spectroscopy_re(): run_engine = RunEngine() spectroscopy_detector = b01_1.spectroscopy_detector.build(connect_immediately=True) - sample_stage = b01_1.sample_stage.build(connect_immediately=True) + sample_stage = b01_1.spectroscopy_stage.build(connect_immediately=True) scan_spec = Line(sample_stage.y, 0, 5, 50) * Line(sample_stage.x, 2, 5, 30) @@ -168,7 +168,7 @@ def test_spectroscopy_re_run_engine(): run_engine = RunEngine() spectroscopy_detector = b01_1.spectroscopy_detector.build(connect_immediately=True) - sample_stage = b01_1.sample_stage.build(connect_immediately=True) + sample_stage = b01_1.spectroscopy_stage.build(connect_immediately=True) scan_spec = Line(sample_stage.y, 0, 5, 50) * Line(sample_stage.x, 2, 5, 30) diff --git a/tests/unit_tests/test_plans.py b/tests/unit_tests/test_plans.py index d4283c1..155b29f 100644 --- a/tests/unit_tests/test_plans.py +++ b/tests/unit_tests/test_plans.py @@ -38,7 +38,7 @@ def spectroscopy_detector(run_engine: RunEngine) -> AravisDetector: @pytest.fixture def sample_stage(run_engine: RunEngine) -> XYZStage: - stage = b01_1.sample_stage.build(connect_immediately=True, mock=True) + stage = b01_1.spectroscopy_stage.build(connect_immediately=True, mock=True) set_mock_value(stage.x.low_limit_travel, -10.0) set_mock_value(stage.x.high_limit_travel, 10.0) @@ -141,9 +141,9 @@ def test_snapshot( ) assert docs["stream_resource"][0].get("data_key") == "spectroscopy_detector" assert docs["event"][0]["data"] == { - "sample_stage-x": 0.0, - "sample_stage-y": 0.0, - "sample_stage-z": 0.0, + "spectroscopy_stage-x": 0.0, + "spectroscopy_stage-y": 0.0, + "spectroscopy_stage-z": 0.0, } @@ -238,9 +238,9 @@ def test_spectroscopy_datasets( data_keys = [resource.get("data_key") for resource in docs["stream_resource"]] assert data_keys == ["spectroscopy_detector", "BlueTotal", "GreenTotal", "RedTotal"] assert docs["event"][0]["data"] == { - "sample_stage-x": 0.0, - "sample_stage-y": 0.0, - "sample_stage-z": 0.0, + "spectroscopy_stage-x": 0.0, + "spectroscopy_stage-y": 0.0, + "spectroscopy_stage-z": 0.0, "spectroscopy_diodes-red": 0, "spectroscopy_diodes-green": 0, "spectroscopy_diodes-blue": 0, From 9c4e18dd823ab68158813437d6873b05aa436fa4 Mon Sep 17 00:00:00 2001 From: Tom Kane Date: Tue, 3 Nov 2026 15:05:27 +0000 Subject: [PATCH 08/13] update dodal --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 86edb58..239e45b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] description = "Bluesky plans to be run on Diamond's test rigs e.g. ViSR, P45, etc" -dependencies = ["dls-dodal>=3.0.4", "scanspec>=1.0.0"] +dependencies = ["dls-dodal>=3.0.7", "scanspec>=1.0.0"] dynamic = ["version"] license.file = "LICENSE" readme = "README.md" From 7f276cec341cad5dd4ec24307efdc149b994ae41 Mon Sep 17 00:00:00 2001 From: Tom Kane Date: Tue, 3 Nov 2026 17:11:34 +0000 Subject: [PATCH 09/13] Update tests/unit_tests/test_plans.py Co-authored-by: Shihab S <162436767+shihab-dls@users.noreply.github.com> --- tests/unit_tests/test_plans.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/test_plans.py b/tests/unit_tests/test_plans.py index 155b29f..848660c 100644 --- a/tests/unit_tests/test_plans.py +++ b/tests/unit_tests/test_plans.py @@ -332,9 +332,8 @@ async def test_spectroscopy_sets_amplifier_range( ) ) - assert ( - await spectroscopy_diodes.amplifier.range.get_value() - == EnzLocumRange.RANGE_10NA + get_mock_put(spectroscopy_diodes.amplifier.range).assert_called_once_with( + EnzLocumRange.RANGE_10NA ) configuration = docs["descriptor"][0]["configuration"]["spectroscopy_diodes"] assert "spectroscopy_diodes-amplifier-range_max" in configuration["data"] From 100972f8d319312a94e4c930440875e13a9b55df Mon Sep 17 00:00:00 2001 From: Tom Kane Date: Tue, 3 Nov 2026 17:46:43 +0000 Subject: [PATCH 10/13] add tests that cover pandabox and diodes --- tests/unit_tests/test_plans.py | 92 ++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/unit_tests/test_plans.py b/tests/unit_tests/test_plans.py index 848660c..e81f2c9 100644 --- a/tests/unit_tests/test_plans.py +++ b/tests/unit_tests/test_plans.py @@ -1,16 +1,19 @@ import asyncio import unittest.mock from collections import defaultdict +from pathlib import Path from unittest.mock import ANY, AsyncMock, Mock, patch import dodal.beamlines.b01_1 as b01_1 import pytest +import yaml from bluesky import RunEngine from dodal.devices.beamlines.b01_1.diodes import RGBDiodes from dodal.devices.current_amplifiers import EnzLocumRange from dodal.devices.motors import XYZStage from ophyd_async.core import ( callback_on_mock_put, + get_mock_put, set_mock_value, ) from ophyd_async.epics.adaravis import AravisDetector @@ -19,8 +22,10 @@ from ophyd_async.testing import assert_emitted from scanspec.specs import Line, Spec +import test_rig_bluesky from test_rig_bluesky.plans import ( ScanType, + fly_scan, load_settings, save_settings, serialize_spec, @@ -56,6 +61,11 @@ def pandabrick(run_engine: RunEngine) -> HDFPanda: return b01_1.pandabrick.build(connect_immediately=True, mock=True) +@pytest.fixture +def pandabox(run_engine: RunEngine) -> HDFPanda: + return b01_1.pandabox.build(connect_immediately=True, mock=True) + + @pytest.fixture def spectroscopy_diodes(run_engine: RunEngine) -> RGBDiodes: return b01_1.spectroscopy_diodes.build(connect_immediately=True, mock=True) @@ -356,3 +366,85 @@ def test_spectroscopy_rejects_auto_amplifier_range( scan_type=ScanType.STEP, ) ) + + +def test_fly_scan_rejects_pandabox_without_diodes( + run_engine: RunEngine, + spectroscopy_detector: AravisDetector, + sample_stage: XYZStage, + pandabox: HDFPanda, +): + with pytest.raises(ValueError, match="pandabox was given without diodes"): + run_engine( + fly_scan( + Line(sample_stage.x, 0, 5, 5), + spectroscopy_detector, + pandabox=pandabox, + ) + ) + + +def test_fly_scan_rejects_diodes_without_pandabox( + run_engine: RunEngine, + spectroscopy_detector: AravisDetector, + sample_stage: XYZStage, + spectroscopy_diodes: RGBDiodes, +): + with pytest.raises(ValueError, match="diodes were given without pandabox"): + run_engine( + fly_scan( + Line(sample_stage.x, 0, 5, 5), + spectroscopy_detector, + diodes=spectroscopy_diodes, + ) + ) + + +def test_spectroscopy_fly_scan_uses_pandabox_and_diodes( + run_engine: RunEngine, + spectroscopy_detector: AravisDetector, + sample_stage: XYZStage, + pandabrick: HDFPanda, + pandabox: HDFPanda, + spectroscopy_diodes: RGBDiodes, +): + with ( + unittest.mock.patch( + "test_rig_bluesky.spectroscopy_plans.load_panda_settings", + return_value=iter(()), + ) as mock_load_panda_settings, + unittest.mock.patch( + "test_rig_bluesky.spectroscopy_plans.fly_scan", return_value=iter(()) + ) as mock_fly_scan, + ): + run_engine( + spectroscopy( + spectroscopy_detector=spectroscopy_detector, + sample_stage=sample_stage, + pandabrick=pandabrick, + pandabox=pandabox, + spectroscopy_diodes=spectroscopy_diodes, + scan_type=ScanType.FLY, + ) + ) + + mock_load_panda_settings.assert_called_once_with( + panda=pandabox, design_name="pandabox_baseline" + ) + mock_fly_scan.assert_called_once() + called_kwargs = mock_fly_scan.call_args.kwargs + assert called_kwargs["pandabox"] is pandabox + assert called_kwargs["diodes"] is spectroscopy_diodes + + +def test_pandabox_baseline_captures_diodes_under_their_names( + spectroscopy_diodes: RGBDiodes, +): + baseline = ( + Path(test_rig_bluesky.__file__).parent / "baselines/pandabox_baseline.yaml" + ) + datasets = yaml.safe_load(baseline.read_text()) + + assert datasets["fmc_in.val5_dataset"] == spectroscopy_diodes.red.name + assert datasets["fmc_in.val6_dataset"] == spectroscopy_diodes.green.name + assert datasets["fmc_in.val7_dataset"] == spectroscopy_diodes.blue.name From b86af0155778dc4a9f46dd769b6c9dd98d52ed24 Mon Sep 17 00:00:00 2001 From: Tom Kane Date: Tue, 3 Nov 2026 17:56:08 +0000 Subject: [PATCH 11/13] add whitelist for pandabox for storing settings --- src/test_rig_bluesky/spectroscopy_plans.py | 19 ++++++++++++++++--- tests/unit_tests/test_plans.py | 10 ++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/test_rig_bluesky/spectroscopy_plans.py b/src/test_rig_bluesky/spectroscopy_plans.py index a0419f2..1fb855c 100644 --- a/src/test_rig_bluesky/spectroscopy_plans.py +++ b/src/test_rig_bluesky/spectroscopy_plans.py @@ -44,6 +44,16 @@ # hardware this list is spectroscopy's. PANDA_WHITELIST = ["incenc-3-val_dataset", "incenc-2-val_dataset"] +# Only the diode channels, so the rest of the pandabox's setup is left alone +PANDABOX_WHITELIST = [ + "fmc_in-val5_capture", + "fmc_in-val5_dataset", + "fmc_in-val6_capture", + "fmc_in-val6_dataset", + "fmc_in-val7_capture", + "fmc_in-val7_dataset", +] + MANTA_ACQUIRE_PERIOD_PAD = 1961e-6 MANTA_DETECTOR_DEADTIME = 2e-3 * 1.01 @@ -163,9 +173,12 @@ def spectroscopy( if scan_type == ScanType.FLY: LOGGER.info("Performing a fly scan.") - # Captures the diodes' per-frame means under the same names as the step - # scan's readings - yield from load_panda_settings(panda=pandabox, design_name="pandabox_baseline") + # Captures the diodes alongside the camera + yield from load_panda_settings( + panda=pandabox, + design_name="pandabox_baseline", + whitelist_pvs=PANDABOX_WHITELIST, + ) yield from fly_scan( spec, spectroscopy_detector, diff --git a/tests/unit_tests/test_plans.py b/tests/unit_tests/test_plans.py index e81f2c9..36260d8 100644 --- a/tests/unit_tests/test_plans.py +++ b/tests/unit_tests/test_plans.py @@ -31,7 +31,11 @@ serialize_spec, snapshot, ) -from test_rig_bluesky.spectroscopy_plans import demo_spectroscopy, spectroscopy +from test_rig_bluesky.spectroscopy_plans import ( + PANDABOX_WHITELIST, + demo_spectroscopy, + spectroscopy, +) @pytest.fixture @@ -429,7 +433,9 @@ def test_spectroscopy_fly_scan_uses_pandabox_and_diodes( ) mock_load_panda_settings.assert_called_once_with( - panda=pandabox, design_name="pandabox_baseline" + panda=pandabox, + design_name="pandabox_baseline", + whitelist_pvs=PANDABOX_WHITELIST, ) mock_fly_scan.assert_called_once() called_kwargs = mock_fly_scan.call_args.kwargs From 686519ce7fd4a5f0cc287e14c8115b1ed8f6fab5 Mon Sep 17 00:00:00 2001 From: Tom Kane Date: Mon, 9 Nov 2026 13:04:21 +0000 Subject: [PATCH 12/13] rename panda_whitelist to pandabrick_whitelist --- src/test_rig_bluesky/plans.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test_rig_bluesky/plans.py b/src/test_rig_bluesky/plans.py index e75548e..ddd2474 100644 --- a/src/test_rig_bluesky/plans.py +++ b/src/test_rig_bluesky/plans.py @@ -130,7 +130,7 @@ def fly_scan( exposure_time: float = 0.1, detector_deadtime: float = 2e-3 * 1.01, detector_trigger: DetectorTrigger = DetectorTrigger.EXTERNAL_EDGE, - panda_whitelist: list[str] | None = None, + pandabrick_whitelist: list[str] | None = None, plan_name: str = "fly_scan", metadata: dict[str, Any] | None = None, pandabox: HDFPanda | None = None, @@ -149,7 +149,7 @@ def fly_scan( yield from load_panda_settings( panda=pandabrick, design_name="pandabrick_baseline", - whitelist_pvs=panda_whitelist, + whitelist_pvs=pandabrick_whitelist, ) scan_frame_duration = exposure_time From 5d7e0474daded6299d3a3713739ed2236dfcb89c Mon Sep 17 00:00:00 2001 From: Tom Kane Date: Mon, 9 Nov 2026 13:05:48 +0000 Subject: [PATCH 13/13] fix fly scan calls --- src/test_rig_bluesky/spectroscopy_plans.py | 2 +- src/test_rig_bluesky/tomography_plans.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test_rig_bluesky/spectroscopy_plans.py b/src/test_rig_bluesky/spectroscopy_plans.py index 1fb855c..cd11372 100644 --- a/src/test_rig_bluesky/spectroscopy_plans.py +++ b/src/test_rig_bluesky/spectroscopy_plans.py @@ -186,7 +186,7 @@ def spectroscopy( pandabrick=pandabrick, exposure_time=exposure_time, detector_deadtime=MANTA_DETECTOR_DEADTIME, - panda_whitelist=PANDA_WHITELIST, + pandabrick_whitelist=PANDA_WHITELIST, metadata=metadata, pandabox=pandabox, diodes=spectroscopy_diodes, diff --git a/src/test_rig_bluesky/tomography_plans.py b/src/test_rig_bluesky/tomography_plans.py index 31c5b05..4c74a96 100644 --- a/src/test_rig_bluesky/tomography_plans.py +++ b/src/test_rig_bluesky/tomography_plans.py @@ -173,7 +173,7 @@ def tomography( exposure_time=exposure_time, detector_deadtime=MAKO_DETECTOR_DEADTIME, detector_trigger=TOMOGRAPHY_DETECTOR_TRIGGER, - panda_whitelist=PANDA_WHITELIST, + pandabrick_whitelist=PANDA_WHITELIST, plan_name="tomography_fly_scan", metadata=metadata, )