diff --git a/src/ophyd_async/core/_detector.py b/src/ophyd_async/core/_detector.py index 3d18cccf31..d30fa7bb4a 100644 --- a/src/ophyd_async/core/_detector.py +++ b/src/ophyd_async/core/_detector.py @@ -3,8 +3,9 @@ import asyncio import time from abc import ABC, abstractmethod -from collections.abc import AsyncGenerator, AsyncIterator, Callable, Sequence +from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterator, Sequence from enum import Enum +from functools import cached_property from typing import ( Generic, ) @@ -20,7 +21,7 @@ WritesStreamAssets, ) from event_model import DataKey -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, NonNegativeInt, computed_field from ._device import Device from ._protocol import AsyncConfigurable, AsyncReadable @@ -45,8 +46,16 @@ class DetectorTrigger(str, Enum): class TriggerInfo(BaseModel): """Minimal set of information required to setup triggering on a detector""" - #: Number of triggers that will be sent, 0 means infinite - number: int = Field(ge=0) + #: Number of triggers that will be sent, (0 means infinite) Can be: + # - A single integer or + # - A list of integers for multiple triggers + # Example for tomography: TriggerInfo(number=[2,3,100,3]) + #: This would trigger: + #: - 2 times for dark field images + #: - 3 times for initial flat field images + #: - 100 times for projections + #: - 3 times for final flat field images + number_of_triggers: NonNegativeInt | list[NonNegativeInt] #: Sort of triggers that will be sent trigger: DetectorTrigger = Field(default=DetectorTrigger.internal) #: What is the minimum deadtime between triggers @@ -60,10 +69,15 @@ class TriggerInfo(BaseModel): #: e.g. if num=10 and multiplier=5 then the detector will take 10 frames, #: but publish 2 indices, and describe() will show a shape of (5, h, w) multiplier: int = 1 - #: The number of times the detector can go through a complete cycle of kickoff and - #: complete without needing to re-arm. This is important for detectors where the - #: process of arming is expensive in terms of time - iteration: int = 1 + + @computed_field + @cached_property + def total_number_of_triggers(self) -> int: + return ( + sum(self.number_of_triggers) + if isinstance(self.number_of_triggers, list) + else self.number_of_triggers + ) class DetectorControl(ABC): @@ -192,10 +206,14 @@ def __init__( # For kickoff self._watchers: list[Callable] = [] self._fly_status: WatchableAsyncStatus | None = None - self._fly_start: float - self._iterations_completed: int = 0 - self._initial_frame: int - self._last_frame: int + self._fly_start: float | None = None + self._frames_to_complete: int = 0 + # Represents the total number of frames that will have been completed at the + # end of the next `complete`. + self._completable_frames: int = 0 + self._number_of_triggers_iter: Iterator[int] | None = None + self._initial_frame: int = 0 + super().__init__(name) @property @@ -251,7 +269,7 @@ async def trigger(self) -> None: if self._trigger_info is None: await self.prepare( TriggerInfo( - number=1, + number_of_triggers=1, trigger=DetectorTrigger.internal, deadtime=None, livetime=None, @@ -301,8 +319,12 @@ async def prepare(self, value: TriggerInfo) -> None: f"but trigger logic provides only {value.deadtime}s" ) self._trigger_info = value + self._number_of_triggers_iter = iter( + self._trigger_info.number_of_triggers + if isinstance(self._trigger_info.number_of_triggers, list) + else [self._trigger_info.number_of_triggers] + ) self._initial_frame = await self.writer.get_indices_written() - self._last_frame = self._initial_frame + self._trigger_info.number self._describe, _ = await asyncio.gather( self.writer.open(value.multiplier), self.controller.prepare(value) ) @@ -312,39 +334,50 @@ async def prepare(self, value: TriggerInfo) -> None: @AsyncStatus.wrap async def kickoff(self): - assert self._trigger_info, "Prepare must be called before kickoff!" - if self._iterations_completed >= self._trigger_info.iteration: - raise Exception( + if self._trigger_info is None or self._number_of_triggers_iter is None: + raise RuntimeError("Prepare must be called before kickoff!") + try: + self._frames_to_complete = next(self._number_of_triggers_iter) + self._completable_frames += self._frames_to_complete + except StopIteration as err: + raise RuntimeError( f"Kickoff called more than the configured number of " - f"{self._trigger_info.iteration} iteration(s)!" - ) - self._iterations_completed += 1 + f"{self._trigger_info.total_number_of_triggers} iteration(s)!" + ) from err @WatchableAsyncStatus.wrap async def complete(self): assert self._trigger_info - async for index in self.writer.observe_indices_written( + indices_written = self.writer.observe_indices_written( self._trigger_info.frame_timeout or ( DEFAULT_TIMEOUT + (self._trigger_info.livetime or 0) + (self._trigger_info.deadtime or 0) ) - ): - yield WatcherUpdate( - name=self.name, - current=index, - initial=self._initial_frame, - target=self._trigger_info.number, - unit="", - precision=0, - time_elapsed=time.monotonic() - self._fly_start, - ) - if index >= self._trigger_info.number: - break - if self._iterations_completed == self._trigger_info.iteration: - self._iterations_completed = 0 - await self.controller.wait_for_idle() + ) + try: + async for index in indices_written: + yield WatcherUpdate( + name=self.name, + current=index, + initial=self._initial_frame, + target=self._frames_to_complete, + unit="", + precision=0, + time_elapsed=time.monotonic() - self._fly_start + if self._fly_start + else None, + ) + if index >= self._frames_to_complete: + break + finally: + await indices_written.aclose() + if self._completable_frames >= self._trigger_info.total_number_of_triggers: + self._completable_frames = 0 + self._frames_to_complete = 0 + self._number_of_triggers_iter = None + await self.controller.wait_for_idle() async def describe_collect(self) -> dict[str, DataKey]: return self._describe diff --git a/src/ophyd_async/epics/adaravis/_aravis_controller.py b/src/ophyd_async/epics/adaravis/_aravis_controller.py index 80b826db2d..f49556cdd6 100644 --- a/src/ophyd_async/epics/adaravis/_aravis_controller.py +++ b/src/ophyd_async/epics/adaravis/_aravis_controller.py @@ -30,7 +30,7 @@ def get_deadtime(self, exposure: float | None) -> float: return _HIGHEST_POSSIBLE_DEADTIME async def prepare(self, trigger_info: TriggerInfo): - if (num := trigger_info.number) == 0: + if trigger_info.total_number_of_triggers == 0: image_mode = adcore.ImageMode.continuous else: image_mode = adcore.ImageMode.multiple @@ -43,7 +43,7 @@ async def prepare(self, trigger_info: TriggerInfo): await asyncio.gather( self._drv.trigger_source.set(trigger_source), - self._drv.num_images.set(num), + self._drv.num_images.set(trigger_info.total_number_of_triggers), self._drv.image_mode.set(image_mode), ) diff --git a/src/ophyd_async/epics/adkinetix/_kinetix_controller.py b/src/ophyd_async/epics/adkinetix/_kinetix_controller.py index acf95850ef..3fafd7e84c 100644 --- a/src/ophyd_async/epics/adkinetix/_kinetix_controller.py +++ b/src/ophyd_async/epics/adkinetix/_kinetix_controller.py @@ -29,7 +29,7 @@ def get_deadtime(self, exposure: float | None) -> float: async def prepare(self, trigger_info: TriggerInfo): await asyncio.gather( self._drv.trigger_mode.set(KINETIX_TRIGGER_MODE_MAP[trigger_info.trigger]), - self._drv.num_images.set(trigger_info.number), + self._drv.num_images.set(trigger_info.total_number_of_triggers), self._drv.image_mode.set(adcore.ImageMode.multiple), ) if trigger_info.livetime is not None and trigger_info.trigger not in [ diff --git a/src/ophyd_async/epics/adpilatus/_pilatus_controller.py b/src/ophyd_async/epics/adpilatus/_pilatus_controller.py index 9e8bd54aef..875f2b94b2 100644 --- a/src/ophyd_async/epics/adpilatus/_pilatus_controller.py +++ b/src/ophyd_async/epics/adpilatus/_pilatus_controller.py @@ -40,7 +40,9 @@ async def prepare(self, trigger_info: TriggerInfo): await asyncio.gather( self._drv.trigger_mode.set(self._get_trigger_mode(trigger_info.trigger)), self._drv.num_images.set( - 999_999 if trigger_info.number == 0 else trigger_info.number + 999_999 + if trigger_info.total_number_of_triggers == 0 + else trigger_info.total_number_of_triggers ), self._drv.image_mode.set(adcore.ImageMode.multiple), ) diff --git a/src/ophyd_async/epics/adsimdetector/_sim_controller.py b/src/ophyd_async/epics/adsimdetector/_sim_controller.py index 10b8516ece..fbd44c1029 100644 --- a/src/ophyd_async/epics/adsimdetector/_sim_controller.py +++ b/src/ophyd_async/epics/adsimdetector/_sim_controller.py @@ -32,7 +32,7 @@ async def prepare(self, trigger_info: TriggerInfo): DEFAULT_TIMEOUT + await self.driver.acquire_time.get_value() ) await asyncio.gather( - self.driver.num_images.set(trigger_info.number), + self.driver.num_images.set(trigger_info.total_number_of_triggers), self.driver.image_mode.set(adcore.ImageMode.multiple), ) diff --git a/src/ophyd_async/epics/advimba/_vimba_controller.py b/src/ophyd_async/epics/advimba/_vimba_controller.py index f9ce2a8d02..f5ae79f37d 100644 --- a/src/ophyd_async/epics/advimba/_vimba_controller.py +++ b/src/ophyd_async/epics/advimba/_vimba_controller.py @@ -37,7 +37,7 @@ async def prepare(self, trigger_info: TriggerInfo): await asyncio.gather( self._drv.trigger_mode.set(TRIGGER_MODE[trigger_info.trigger]), self._drv.exposure_mode.set(EXPOSE_OUT_MODE[trigger_info.trigger]), - self._drv.num_images.set(trigger_info.number), + self._drv.num_images.set(trigger_info.total_number_of_triggers), self._drv.image_mode.set(adcore.ImageMode.multiple), ) if trigger_info.livetime is not None and trigger_info.trigger not in [ diff --git a/src/ophyd_async/epics/eiger/_eiger_controller.py b/src/ophyd_async/epics/eiger/_eiger_controller.py index bed28c2d49..bbdef7eb89 100644 --- a/src/ophyd_async/epics/eiger/_eiger_controller.py +++ b/src/ophyd_async/epics/eiger/_eiger_controller.py @@ -41,7 +41,7 @@ async def prepare(self, trigger_info: TriggerInfo): self._drv.trigger_mode.set( EIGER_TRIGGER_MODE_MAP[trigger_info.trigger].value ), - self._drv.num_images.set(trigger_info.number), + self._drv.num_images.set(trigger_info.total_number_of_triggers), ] if trigger_info.livetime is not None: coros.extend( diff --git a/src/ophyd_async/plan_stubs/_fly.py b/src/ophyd_async/plan_stubs/_fly.py index a9f0003dec..043da476f4 100644 --- a/src/ophyd_async/plan_stubs/_fly.py +++ b/src/ophyd_async/plan_stubs/_fly.py @@ -44,7 +44,6 @@ def prepare_static_seq_table_flyer_and_detectors_with_same_trigger( repeats: int = 1, period: float = 0.0, frame_timeout: float | None = None, - iteration: int = 1, ): """Prepare a hardware triggered flyable and one or more detectors. @@ -62,12 +61,11 @@ def prepare_static_seq_table_flyer_and_detectors_with_same_trigger( deadtime = max(det.controller.get_deadtime(exposure) for det in detectors) trigger_info = TriggerInfo( - number=number_of_frames * repeats, + number_of_triggers=number_of_frames * repeats, trigger=DetectorTrigger.constant_gate, deadtime=deadtime, livetime=exposure, frame_timeout=frame_timeout, - iteration=iteration, ) trigger_time = number_of_frames * (exposure + deadtime) pre_delay = max(period - 2 * shutter_time - trigger_time, 0) diff --git a/src/ophyd_async/sim/demo/_pattern_detector/_pattern_detector_controller.py b/src/ophyd_async/sim/demo/_pattern_detector/_pattern_detector_controller.py index 45dcddc9c0..93d67e2263 100644 --- a/src/ophyd_async/sim/demo/_pattern_detector/_pattern_detector_controller.py +++ b/src/ophyd_async/sim/demo/_pattern_detector/_pattern_detector_controller.py @@ -32,7 +32,9 @@ async def arm(self): assert self.period self.task = asyncio.create_task( self._coroutine_for_image_writing( - self._trigger_info.livetime, self.period, self._trigger_info.number + self._trigger_info.livetime, + self.period, + self._trigger_info.total_number_of_triggers, ) ) diff --git a/system_tests/epics/eiger/test_eiger_system.py b/system_tests/epics/eiger/test_eiger_system.py index 8d20c1a708..320c74aecc 100644 --- a/system_tests/epics/eiger/test_eiger_system.py +++ b/system_tests/epics/eiger/test_eiger_system.py @@ -71,7 +71,7 @@ async def test_eiger(RE, ioc_prefixes) -> EigerDetector: async def test_trigger_saves_file(test_eiger: EigerDetector, setup_device: SetupDevice): single_shot = EigerTriggerInfo( frame_timeout=None, - number=1, + number_of_triggers=1, trigger=DetectorTrigger.internal, deadtime=None, livetime=None, diff --git a/tests/conftest.py b/tests/conftest.py index 0dfd2c75c6..fa0a8fb800 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -230,7 +230,7 @@ def static_path_provider( def one_shot_trigger_info() -> TriggerInfo: return TriggerInfo( frame_timeout=None, - number=1, + number_of_triggers=1, trigger=DetectorTrigger.internal, deadtime=None, livetime=None, diff --git a/tests/core/test_flyer.py b/tests/core/test_flyer.py index 5c2b3e83ed..728bb6ca68 100644 --- a/tests/core/test_flyer.py +++ b/tests/core/test_flyer.py @@ -22,6 +22,7 @@ TriggerLogic, observe_value, ) +from ophyd_async.core._signal import assert_emitted from ophyd_async.epics.signal import epics_signal_rw @@ -138,23 +139,23 @@ async def dummy_arm_2(self=None, trigger=None, num=0, exposure=None): return (detector_1, detector_2) +@pytest.mark.parametrize( + "number_of_triggers", [[1, 2, 3, 4], [2, 3, 100, 3], [1, 1, 1, 1]] +) async def test_hardware_triggered_flyable( - RE: RunEngine, detectors: tuple[StandardDetector] + RE: RunEngine, detectors: tuple[StandardDetector], number_of_triggers: list[int] ): - names = [] - docs = [] + docs = {} def append_and_print(name, doc): - names.append(name) - docs.append(doc) + if name not in docs: + docs[name] = [] + docs[name] += [doc] RE.subscribe(append_and_print) trigger_logic = DummyTriggerLogic() flyer = StandardFlyer(trigger_logic, name="flyer") - trigger_info = TriggerInfo( - number=1, trigger=DetectorTrigger.constant_gate, deadtime=2, livetime=2 - ) def flying_plan(): yield from bps.stage_all(*detectors, flyer) @@ -168,7 +169,12 @@ def flying_plan(): for detector in detectors: yield from bps.prepare( detector, - trigger_info, + TriggerInfo( + number_of_triggers=number_of_triggers, + trigger=DetectorTrigger.constant_gate, + deadtime=2, + livetime=2, + ), wait=True, ) @@ -178,40 +184,42 @@ def flying_plan(): yield from bps.open_run() yield from bps.declare_stream(*detectors, name="main_stream", collect=True) + frames_completed: int = 0 + for frames in number_of_triggers: + yield from bps.kickoff(flyer) + for detector in detectors: + yield from bps.kickoff(detector) - yield from bps.kickoff(flyer) - for detector in detectors: - yield from bps.kickoff(detector) - - yield from bps.complete(flyer, wait=False, group="complete") - for detector in detectors: - yield from bps.complete(detector, wait=False, group="complete") + yield from bps.complete(flyer, wait=False, group="complete") + for detector in detectors: + yield from bps.complete(detector, wait=False, group="complete") - assert flyer._trigger_logic.state == TriggerState.null + assert flyer._trigger_logic.state == TriggerState.null - # Manually incremenet the index as if a frame was taken - for detector in detectors: - detector.writer.index += 1 - - done = False - while not done: - try: - yield from bps.wait(group="complete", timeout=0.5) - except TimeoutError: - pass - else: - done = True - yield from bps.collect( - *detectors, - return_payload=False, - name="main_stream", - ) - yield from bps.wait(group="complete") + # Manually increment the index as if a frame was taken + frames_completed += frames + for detector in detectors: + yield from bps.abs_set(detector.writer.dummy_signal, frames_completed) + detector.writer.index = frames_completed + done = False + while not done: + try: + yield from bps.wait(group="complete", timeout=0.5) + except TimeoutError: + pass + else: + done = True + + yield from bps.collect( + *detectors, + return_payload=False, + name="main_stream", + ) + yield from bps.wait(group="complete") for detector in detectors: - # Since we set number of iterations to 1 (default), - # make sure it gets reset on complete - assert detector._iterations_completed == 0 + # ensure _completable_frames are reset after completion + assert detector._completable_frames == 0 yield from bps.close_run() @@ -223,24 +231,59 @@ def flying_plan(): # fly scan RE(flying_plan()) - assert names == [ - "start", - "descriptor", - "stream_resource", - "stream_datum", - "stream_resource", - "stream_datum", - "stop", - ] + assert_emitted( + docs, + start=1, + descriptor=1, + stream_resource=2, + stream_datum=2 * len(number_of_triggers), + stop=1, + ) + # test stream datum + seq_numbers: list = [] + frame_completed: int = 0 + last_frame_collected: int = 0 + for frame in number_of_triggers: + frame_completed += frame + seq_numbers.extend([(last_frame_collected, frame_completed)] * 2) + last_frame_collected = frame_completed + for index, stream_datum in enumerate(docs["stream_datum"]): + assert stream_datum["descriptor"] == docs["descriptor"][0]["uid"] + assert stream_datum["seq_nums"] == { + "start": seq_numbers[index][0] + 1, + "stop": seq_numbers[index][1] + 1, + } + assert stream_datum["indices"] == { + "start": seq_numbers[index][0], + "stop": seq_numbers[index][1], + } + assert stream_datum["stream_resource"] in [ + sd["uid"].split("/")[0] for sd in docs["stream_datum"] + ] +@pytest.mark.parametrize( + "number_of_triggers,invoke_extra_kickoff_before_complete", + [ + (10, True), + ([10], True), + (10, False), + ([10], False), + ], +) async def test_hardware_triggered_flyable_too_many_kickoffs( - RE: RunEngine, detectors: tuple[StandardDetector] + RE: RunEngine, + detectors: tuple[StandardDetector], + number_of_triggers: int | list[int], + invoke_extra_kickoff_before_complete: bool, ): trigger_logic = DummyTriggerLogic() flyer = StandardFlyer(trigger_logic, name="flyer") trigger_info = TriggerInfo( - number=1, trigger=DetectorTrigger.constant_gate, deadtime=2, livetime=2 + number_of_triggers=number_of_triggers, + trigger=DetectorTrigger.constant_gate, + deadtime=2, + livetime=2, ) def flying_plan(): @@ -262,20 +305,24 @@ def flying_plan(): yield from bps.open_run() yield from bps.declare_stream(*detectors, name="main_stream", collect=True) - for _ in range(2): - yield from bps.kickoff(flyer) - for detector in detectors: + yield from bps.kickoff(flyer) + for detector in detectors: + yield from bps.kickoff(detector) + # Perform an additional kickoff + if invoke_extra_kickoff_before_complete: yield from bps.kickoff(detector) - yield from bps.complete(flyer, wait=False, group="complete") for detector in detectors: yield from bps.complete(detector, wait=False, group="complete") assert flyer._trigger_logic.state == TriggerState.null - # Manually incremenet the index as if a frame was taken + # Manually increment the index as if a frame was taken for detector in detectors: - detector.writer.index += 1 + yield from bps.abs_set( + detector.writer.dummy_signal, trigger_info.total_number_of_triggers + ) + detector.writer.index = trigger_info.total_number_of_triggers yield from bps.wait(group="complete") @@ -288,16 +335,25 @@ def flying_plan(): for detector in detectors: # Since we set number of iterations to 1 (default), # make sure it gets reset on complete - assert detector._iterations_completed == 0 - + assert detector._completable_frames == 0 + assert detector._frames_to_complete == 0 + assert detector._number_of_triggers_iter is None + assert detector.controller.wait_for_idle.called # type: ignore + + # This is an additional kickoff + # Ensuring stop iteration is called if kickoff is invoked after complete + if not invoke_extra_kickoff_before_complete: + yield from bps.kickoff(detector) yield from bps.close_run() yield from bps.unstage_all(flyer, *detectors) # fly scan - with pytest.raises( - Exception, match="Kickoff called more than the configured number" - ): + if invoke_extra_kickoff_before_complete: + match_msg = "Kickoff called more than the configured number" + else: + match_msg = "Prepare must be called before kickoff!" + with pytest.raises(Exception, match=match_msg): RE(flying_plan()) @@ -306,7 +362,7 @@ def flying_plan(): [ ( { - "number": 1, + "number_of_triggers": 1, "trigger": DetectorTrigger.constant_gate, "deadtime": 2, "livetime": 2, @@ -317,7 +373,7 @@ def flying_plan(): ), ( { - "number": 1, + "number_of_triggers": 1, "trigger": "constant_gate", "deadtime": 2, "livetime": -1, @@ -327,7 +383,7 @@ def flying_plan(): ), ( { - "number": 1, + "number_of_triggers": 1, "trigger": DetectorTrigger.internal, "deadtime": 2, "livetime": 1, @@ -338,7 +394,7 @@ def flying_plan(): ), ( { - "number": 1, + "number_of_triggers": 1, "trigger": "not_in_enum", "deadtime": 2, "livetime": 1, @@ -347,6 +403,27 @@ def flying_plan(): "Input should be 'internal', 'edge_trigger', 'constant_gate' or " "'variable_gate' [type=enum, input_value='not_in_enum', input_type=str]", ), + ( + { + "number_of_triggers": -100, + "trigger": "constant_gate", + "deadtime": 2, + "livetime": 1, + }, + "number_of_triggers.constrained-int\n Input should be greater than or " + "equal to 0 [type=greater_than_equal, input_value=-100, input_type=int]", + ), + ( + { + "number_of_triggers": [1, 1, 1, 1, -100], + "trigger": "constant_gate", + "deadtime": 2, + "livetime": 1, + }, + "number_of_triggers.list[constrained-int].4\n" + " Input should be greater than or equal to 0 [type=greater_than_equal," + " input_value=-100, input_type=int]\n", + ), ], ) def test_malformed_trigger_info(kwargs, error_msg): diff --git a/tests/epics/adaravis/test_aravis.py b/tests/epics/adaravis/test_aravis.py index ee40fd6fc5..270b661e55 100644 --- a/tests/epics/adaravis/test_aravis.py +++ b/tests/epics/adaravis/test_aravis.py @@ -30,7 +30,7 @@ async def test_trigger_source_set_to_gpio_line(test_adaravis: adaravis.AravisDet async def trigger_and_complete(): await test_adaravis.controller.prepare( TriggerInfo( - number=1, + number_of_triggers=1, trigger=DetectorTrigger.edge_trigger, livetime=None, deadtime=None, @@ -151,7 +151,7 @@ async def test_unsupported_trigger_excepts(test_adaravis: adaravis.AravisDetecto ): await test_adaravis.prepare( TriggerInfo( - number=0, + number_of_triggers=0, trigger=DetectorTrigger.variable_gate, deadtime=1, livetime=1, diff --git a/tests/epics/adcore/test_scans.py b/tests/epics/adcore/test_scans.py index 4757b2f8b3..d6a9e6b306 100644 --- a/tests/epics/adcore/test_scans.py +++ b/tests/epics/adcore/test_scans.py @@ -104,7 +104,10 @@ def test_hdf_writer_fails_on_timeout_with_flyscan( flyer = StandardFlyer(trigger_logic, name="flyer") trigger_info = TriggerInfo( - number=1, trigger=DetectorTrigger.constant_gate, deadtime=2, livetime=2 + number_of_triggers=1, + trigger=DetectorTrigger.constant_gate, + deadtime=2, + livetime=2, ) def flying_plan(): diff --git a/tests/epics/adkinetix/test_kinetix.py b/tests/epics/adkinetix/test_kinetix.py index 5e460f63d1..b58d3acf37 100644 --- a/tests/epics/adkinetix/test_kinetix.py +++ b/tests/epics/adkinetix/test_kinetix.py @@ -27,7 +27,7 @@ async def test_trigger_modes(test_adkinetix: adkinetix.KinetixDetector): async def setup_trigger_mode(trig_mode: DetectorTrigger): await test_adkinetix.controller.prepare( - TriggerInfo(number=1, trigger=trig_mode) + TriggerInfo(number_of_triggers=1, trigger=trig_mode) ) await test_adkinetix.controller.arm() await test_adkinetix.controller.wait_for_idle() diff --git a/tests/epics/adpilatus/test_pilatus.py b/tests/epics/adpilatus/test_pilatus.py index 192c466a13..5657203d9e 100644 --- a/tests/epics/adpilatus/test_pilatus.py +++ b/tests/epics/adpilatus/test_pilatus.py @@ -50,7 +50,7 @@ async def test_trigger_mode_set( async def trigger_and_complete(): set_mock_value(test_adpilatus.drv.armed, True) await test_adpilatus.controller.prepare( - TriggerInfo(number=1, trigger=detector_trigger) + TriggerInfo(number_of_triggers=1, trigger=detector_trigger) ) await test_adpilatus.controller.arm() await test_adpilatus.controller.wait_for_idle() @@ -63,7 +63,7 @@ async def test_trigger_mode_set_without_armed_pv( ): async def trigger_and_complete(): await test_adpilatus.controller.prepare( - TriggerInfo(number=1, trigger=DetectorTrigger.internal) + TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.internal) ) await test_adpilatus.controller.arm() await test_adpilatus.controller.wait_for_idle() @@ -108,7 +108,7 @@ async def test_unsupported_trigger_excepts(test_adpilatus: adpilatus.PilatusDete ): await test_adpilatus.prepare( TriggerInfo( - number=1, + number_of_triggers=1, trigger=DetectorTrigger.edge_trigger, deadtime=1.0, livetime=1.0, @@ -126,7 +126,10 @@ async def dummy_open(multiplier: int = 0): set_mock_value(test_adpilatus.drv.armed, True) await test_adpilatus.prepare( TriggerInfo( - number=1, trigger=DetectorTrigger.internal, deadtime=1.0, livetime=1.0 + number_of_triggers=1, + trigger=DetectorTrigger.internal, + deadtime=1.0, + livetime=1.0, ) ) assert (await test_adpilatus.drv.acquire_time.get_value()) == 1.0 @@ -137,7 +140,9 @@ async def test_pilatus_controller(test_adpilatus: adpilatus.PilatusDetector): pilatus = test_adpilatus._controller pilatus_driver = pilatus._drv set_mock_value(pilatus_driver.armed, True) - await pilatus.prepare(TriggerInfo(number=1, trigger=DetectorTrigger.constant_gate)) + await pilatus.prepare( + TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.constant_gate) + ) await pilatus.arm() await pilatus.wait_for_idle() diff --git a/tests/epics/adsimdetector/test_sim.py b/tests/epics/adsimdetector/test_sim.py index 637c3c6ed7..be625e0143 100644 --- a/tests/epics/adsimdetector/test_sim.py +++ b/tests/epics/adsimdetector/test_sim.py @@ -77,7 +77,7 @@ async def test_two_detectors_fly_different_rate( two_test_adsimdetectors: list[adsimdetector.SimDetector], RE: RunEngine ): trigger_info = TriggerInfo( - number=15, + number_of_triggers=15, trigger=DetectorTrigger.internal, ) docs = defaultdict(list) @@ -359,7 +359,9 @@ def my_plan(): async def test_ad_sim_controller(test_adsimdetector: adsimdetector.SimDetector): ad = test_adsimdetector._controller with patch("ophyd_async.core._signal.wait_for_value", return_value=None): - await ad.prepare(TriggerInfo(number=1, trigger=DetectorTrigger.internal)) + await ad.prepare( + TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.internal) + ) await ad.arm() await ad.wait_for_idle() diff --git a/tests/epics/advimba/test_vimba.py b/tests/epics/advimba/test_vimba.py index a9dad18bc3..25ec7f2328 100644 --- a/tests/epics/advimba/test_vimba.py +++ b/tests/epics/advimba/test_vimba.py @@ -32,7 +32,9 @@ async def test_arming_trig_modes(test_advimba: advimba.VimbaDetector): set_mock_value(test_advimba.drv.exposure_mode, VimbaExposeOutMode.timed) async def setup_trigger_mode(trig_mode: DetectorTrigger): - await test_advimba.controller.prepare(TriggerInfo(number=1, trigger=trig_mode)) + await test_advimba.controller.prepare( + TriggerInfo(number_of_triggers=1, trigger=trig_mode) + ) await test_advimba.controller.arm() await test_advimba.controller.wait_for_idle() # Prevent timeouts diff --git a/tests/epics/eiger/test_eiger_controller.py b/tests/epics/eiger/test_eiger_controller.py index 39204142e5..4d9276030c 100644 --- a/tests/epics/eiger/test_eiger_controller.py +++ b/tests/epics/eiger/test_eiger_controller.py @@ -44,7 +44,7 @@ async def test_when_arm_with_exposure_then_time_and_period_set( ): driver, controller = eiger_driver_and_controller test_exposure = 0.002 - await controller.prepare(TriggerInfo(number=10, livetime=test_exposure)) + await controller.prepare(TriggerInfo(number_of_triggers=10, livetime=test_exposure)) await controller.arm() await controller.wait_for_idle() assert (await driver.acquire_period.get_value()) == test_exposure @@ -55,7 +55,7 @@ async def test_when_arm_with_no_exposure_then_arm_set_correctly( eiger_driver_and_controller: DriverAndController, ): driver, controller = eiger_driver_and_controller - await controller.prepare(TriggerInfo(number=10)) + await controller.prepare(TriggerInfo(number_of_triggers=10)) await controller.arm() await controller.wait_for_idle() get_mock_put(driver.arm).assert_called_once_with(1, wait=ANY, timeout=ANY) @@ -66,7 +66,7 @@ async def test_when_arm_with_number_of_images_then_number_of_images_set_correctl ): driver, controller = eiger_driver_and_controller test_number_of_images = 40 - await controller.prepare(TriggerInfo(number=test_number_of_images)) + await controller.prepare(TriggerInfo(number_of_triggers=test_number_of_images)) await controller.arm() await controller.wait_for_idle() get_mock_put(driver.num_images).assert_called_once_with( @@ -80,7 +80,7 @@ async def test_given_detector_fails_to_go_ready_when_arm_called_then_fails( ): driver, controller = eiger_driver_and_controller_no_arm with raises(TimeoutError): - await controller.prepare(TriggerInfo(number=10)) + await controller.prepare(TriggerInfo(number_of_triggers=10)) await controller.arm() await controller.wait_for_idle() diff --git a/tests/epics/eiger/test_eiger_detector.py b/tests/epics/eiger/test_eiger_detector.py index 33cfef8055..4ad6557ee0 100644 --- a/tests/epics/eiger/test_eiger_detector.py +++ b/tests/epics/eiger/test_eiger_detector.py @@ -25,7 +25,7 @@ async def test_when_prepared_with_energy_then_energy_set_on_detector(detector): await detector.prepare( EigerTriggerInfo( frame_timeout=None, - number=1, + number_of_triggers=1, trigger=DetectorTrigger.internal, deadtime=None, livetime=None, diff --git a/tests/fastcs/panda/test_hdf_panda.py b/tests/fastcs/panda/test_hdf_panda.py index fe5e42e524..7fcf78b7e3 100644 --- a/tests/fastcs/panda/test_hdf_panda.py +++ b/tests/fastcs/panda/test_hdf_panda.py @@ -134,134 +134,8 @@ def flying_plan(): ) yield from bps.wait(group="complete") yield from bps.close_run() - - yield from bps.unstage_all(flyer, mock_hdf_panda) - yield from bps.wait_for([lambda: mock_hdf_panda.controller.disarm()]) - - # fly scan - RE(flying_plan()) - - assert_emitted( - docs, start=1, descriptor=1, stream_resource=2, stream_datum=2, stop=1 - ) - - # test descriptor - data_key_names: dict[str, str] = docs["descriptor"][0]["object_keys"]["panda"] - assert data_key_names == ["x", "y"] - for data_key_name in data_key_names: - assert ( - docs["descriptor"][0]["data_keys"][data_key_name]["source"] - == "mock+soft://panda-data-hdf_directory" - ) - - # test stream resources - for dataset_name, stream_resource, data_key_name in zip( - ("x", "y"), docs["stream_resource"], data_key_names, strict=False - ): - assert stream_resource == { - "run_start": docs["start"][0]["uid"], - "uid": ANY, - "data_key": data_key_name, - "mimetype": "application/x-hdf5", - "uri": "file://localhost" + str(tmp_path / "test-panda.h5"), - "parameters": { - "dataset": f"/{dataset_name}", - "swmr": False, - "multiplier": 1, - "chunk_shape": (1024,), - }, - } - assert "test-panda.h5" in stream_resource["uri"] - - # test stream datum - for stream_datum in docs["stream_datum"]: - assert stream_datum["descriptor"] == docs["descriptor"][0]["uid"] - assert stream_datum["seq_nums"] == { - "start": 1, - "stop": 2, - } - assert stream_datum["indices"] == { - "start": 0, - "stop": 1, - } - assert stream_datum["stream_resource"] in [ - sd["uid"].split("/")[0] for sd in docs["stream_datum"] - ] - - -async def test_hdf_panda_hardware_triggered_flyable_with_iterations( - RE: RunEngine, - mock_hdf_panda, - tmp_path, -): - docs = {} - - def append_and_print(name, doc): - if name not in docs: - docs[name] = [] - docs[name] += [doc] - - RE.subscribe(append_and_print) - - shutter_time = 0.004 - exposure = 1 - - trigger_logic = StaticSeqTableTriggerLogic(mock_hdf_panda.seq[1]) - flyer = StandardFlyer(trigger_logic, name="flyer") - - def flying_plan(): - iteration = 2 - yield from bps.stage_all(mock_hdf_panda, flyer) - yield from bps.open_run() - yield from prepare_static_seq_table_flyer_and_detectors_with_same_trigger( # noqa: E501 - flyer, - [mock_hdf_panda], - number_of_frames=1, - exposure=exposure, - shutter_time=shutter_time, - iteration=iteration, - ) - - yield from bps.declare_stream(mock_hdf_panda, name="main_stream", collect=True) - - for i in range(iteration): - set_mock_value(flyer.trigger_logic.seq.active, 1) - - yield from bps.kickoff(flyer, wait=True) - yield from bps.kickoff(mock_hdf_panda) - assert mock_hdf_panda._iterations_completed == i + 1 - - yield from bps.complete(flyer, wait=False, group="complete") - yield from bps.complete(mock_hdf_panda, wait=False, group="complete") - - # Manually incremenet the index as if a frame was taken - set_mock_value(mock_hdf_panda.data.num_captured, 1) - set_mock_value(flyer.trigger_logic.seq.active, 0) - - done = False - while not done: - try: - yield from bps.wait(group="complete", timeout=0.5) - except TimeoutError: - pass - else: - done = True - yield from bps.collect( - mock_hdf_panda, - return_payload=False, - name="main_stream", - ) - yield from bps.wait(group="complete") - - # Make sure first complete doesn't reset iterations completed - if i == 0: - assert mock_hdf_panda._iterations_completed == 1 - - # Make sure the number of iterations completed is set to 0 after final complete. - assert mock_hdf_panda._iterations_completed == 0 - - yield from bps.close_run() - + # Verify that _completable_frames is reset to 0 after the final complete. + assert mock_hdf_panda._completable_frames == 0 yield from bps.unstage_all(flyer, mock_hdf_panda) yield from bps.wait_for([lambda: mock_hdf_panda.controller.disarm()]) diff --git a/tests/fastcs/panda/test_panda_control.py b/tests/fastcs/panda/test_panda_control.py index 6c0abf298f..0920cd394f 100644 --- a/tests/fastcs/panda/test_panda_control.py +++ b/tests/fastcs/panda/test_panda_control.py @@ -38,7 +38,7 @@ class PcapBlock(Device): with patch("ophyd_async.fastcs.panda._control.wait_for_value", return_value=None): with pytest.raises(AttributeError) as exc: await pandaController.prepare( - TriggerInfo(number=1, trigger=DetectorTrigger.constant_gate) + TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.constant_gate) ) await pandaController.arm() assert ("'PcapBlock' object has no attribute 'arm'") in str(exc.value) @@ -48,7 +48,7 @@ async def test_panda_controller_arm_disarm(mock_panda): pandaController = PandaPcapController(mock_panda.pcap) with patch("ophyd_async.fastcs.panda._control.wait_for_value", return_value=None): await pandaController.prepare( - TriggerInfo(number=1, trigger=DetectorTrigger.constant_gate) + TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.constant_gate) ) await pandaController.arm() await pandaController.wait_for_idle() @@ -59,5 +59,5 @@ async def test_panda_controller_wrong_trigger(): pandaController = PandaPcapController(None) with pytest.raises(AssertionError): await pandaController.prepare( - TriggerInfo(number=1, trigger=DetectorTrigger.internal) + TriggerInfo(number_of_triggers=1, trigger=DetectorTrigger.internal) ) diff --git a/tests/plan_stubs/test_fly.py b/tests/plan_stubs/test_fly.py index 6508ba377d..b42d9d65ce 100644 --- a/tests/plan_stubs/test_fly.py +++ b/tests/plan_stubs/test_fly.py @@ -116,6 +116,7 @@ def __init__( @WatchableAsyncStatus.wrap async def complete(self): assert self._trigger_info + assert self._fly_start self.writer.increment_index() async for index in self.writer.observe_indices_written( self._trigger_info.frame_timeout @@ -129,12 +130,15 @@ async def complete(self): name=self.name, current=index, initial=self._initial_frame, - target=self._trigger_info.number, + target=self._trigger_info.number_of_triggers, unit="", precision=0, time_elapsed=time.monotonic() - self._fly_start, ) - if index >= self._trigger_info.number: + if ( + isinstance(self._trigger_info.number_of_triggers, int) + and index >= self._trigger_info.number_of_triggers + ): break