diff --git a/pyproject.toml b/pyproject.toml index e654e84..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>=1.56.0", "scanspec>=1.0.0"] +dependencies = ["dls-dodal>=3.0.7", "scanspec>=1.0.0"] dynamic = ["version"] license.file = "LICENSE" readme = "README.md" 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/plans.py b/src/test_rig_bluesky/plans.py index d1ac5c4..ddd2474 100644 --- a/src/test_rig_bluesky/plans.py +++ b/src/test_rig_bluesky/plans.py @@ -130,14 +130,26 @@ 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, + 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] + yield from load_panda_settings( panda=pandabrick, design_name="pandabrick_baseline", - whitelist_pvs=panda_whitelist, + whitelist_pvs=pandabrick_whitelist, ) scan_frame_duration = exposure_time @@ -164,7 +176,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, @@ -192,21 +204,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. @@ -215,6 +234,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. @@ -226,11 +247,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 bbf168e..cd11372 100644 --- a/src/test_rig_bluesky/spectroscopy_plans.py +++ b/src/test_rig_bluesky/spectroscopy_plans.py @@ -8,6 +8,8 @@ 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.current_amplifiers import EnzLocumRange from dodal.devices.motors import XYZStage from dodal.plans import spec_scan from ophyd_async.epics.adaravis import AravisDetector @@ -21,17 +23,37 @@ from ophyd_async.fastcs.panda import HDFPanda from scanspec.specs import Line, Spec -from .plans import ScanType, fly_scan, load_settings, pandabrick, pmac, serialize_spec +from .plans import ( + ScanType, + fly_scan, + load_panda_settings, + load_settings, + pandabrick, + pmac, + serialize_spec, +) LOGGER = logging.getLogger(__name__) spectroscopy_detector = inject("spectroscopy_detector") -sample_stage = inject("sample_stage") +spectroscopy_diodes = inject("spectroscopy_diodes") +pandabox = inject("pandabox") +sample_stage = inject("spectroscopy_stage") # The encoder entries map to specific axes, so despite the PandA being shared # 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 @@ -44,13 +66,28 @@ 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, + amplifier_range: EnzLocumRange | None = None, metadata: dict[str, Any] | None = None, *, scan_type: ScanType, ) -> MsgGenerator[None]: - """Do a spectroscopy scan.""" + """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 + ) yield from load_settings( device=spectroscopy_detector, @@ -136,6 +173,12 @@ def spectroscopy( if scan_type == ScanType.FLY: LOGGER.info("Performing a fly scan.") + # 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, @@ -143,13 +186,15 @@ 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, ) elif scan_type == ScanType.STEP: 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, ) @@ -160,6 +205,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, @@ -207,6 +254,8 @@ def demo_spectroscopy( sample_stage=sample_stage, pmac=pmac, pandabrick=pandabrick, + pandabox=pandabox, + spectroscopy_diodes=spectroscopy_diodes, spec=grid, exposure_time=exposure_time, metadata=metadata, 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, ) 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 1418735..36260d8 100644 --- a/tests/unit_tests/test_plans.py +++ b/tests/unit_tests/test_plans.py @@ -1,27 +1,41 @@ 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, set_mock_value +from ophyd_async.core import ( + callback_on_mock_put, + get_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 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, 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 @@ -33,7 +47,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) @@ -51,6 +65,16 @@ 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) + + def _mock_detector_behavior(detector: AravisDetector) -> None: hdf = detector.get_plugin("hdf", NDFileHDF5IO) @@ -131,9 +155,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, } @@ -142,6 +166,7 @@ async def test_spectroscopy( spectroscopy_detector: AravisDetector, sample_stage: XYZStage, pandabrick: HDFPanda, + spectroscopy_diodes: RGBDiodes, ): docs = defaultdict(list) run_engine.subscribe(lambda name, doc: docs[name].append(doc)) @@ -151,6 +176,7 @@ async def test_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, scan_type=ScanType.STEP, @@ -175,6 +201,7 @@ async def test_spectroscopy_defaults( spectroscopy_detector: AravisDetector, sample_stage: XYZStage, pandabrick: HDFPanda, + spectroscopy_diodes: RGBDiodes, ): docs = defaultdict(list) run_engine.subscribe(lambda name, doc: docs[name].append(doc)) @@ -184,6 +211,7 @@ async def test_spectroscopy_defaults( spectroscopy_detector=spectroscopy_detector, sample_stage=sample_stage, pandabrick=pandabrick, + spectroscopy_diodes=spectroscopy_diodes, scan_type=ScanType.STEP, ) ) @@ -206,6 +234,7 @@ def test_spectroscopy_datasets( spectroscopy_detector: AravisDetector, sample_stage: XYZStage, pandabrick: HDFPanda, + spectroscopy_diodes: RGBDiodes, ): docs = defaultdict(list) run_engine.subscribe(lambda name, doc: docs[name].append(doc)) @@ -215,6 +244,7 @@ def test_spectroscopy_datasets( spectroscopy_detector=spectroscopy_detector, sample_stage=sample_stage, pandabrick=pandabrick, + spectroscopy_diodes=spectroscopy_diodes, scan_type=ScanType.STEP, ) ) @@ -222,9 +252,12 @@ 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, } @@ -233,12 +266,14 @@ async def test_spectroscopy_sets_exposure_time_and_acquire_period( 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, scan_type=ScanType.STEP, ) @@ -288,3 +323,134 @@ def test_serialize_spec_names_device_axes(sample_stage: XYZStage): sample_stage.y.name, sample_stage.x.name, ] + + +async def test_spectroscopy_sets_amplifier_range( + 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, + scan_type=ScanType.STEP, + ) + ) + + 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"] + + +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, + 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", + whitelist_pvs=PANDABOX_WHITELIST, + ) + 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