diff --git a/src/ophyd_async/core/__init__.py b/src/ophyd_async/core/__init__.py index c302f0d133..103638019d 100644 --- a/src/ophyd_async/core/__init__.py +++ b/src/ophyd_async/core/__init__.py @@ -6,7 +6,13 @@ StaticDirectoryProvider, ) from .async_status import AsyncStatus -from .detector import DetectorControl, DetectorTrigger, DetectorWriter, StandardDetector +from .detector import ( + DetectorControl, + DetectorTrigger, + DetectorWriter, + StandardDetector, + TriggerInfo, +) from .device import Device, DeviceCollector, DeviceVector from .device_save_loader import ( get_signal_values, @@ -17,7 +23,7 @@ set_signal_values, walk_rw_signals, ) -from .flyer import HardwareTriggeredFlyable, TriggerInfo, TriggerLogic +from .flyer import HardwareTriggeredFlyable, TriggerLogic from .signal import ( Signal, SignalR, diff --git a/src/ophyd_async/core/flyer.py b/src/ophyd_async/core/flyer.py index f7872d7a52..0490ca10f2 100644 --- a/src/ophyd_async/core/flyer.py +++ b/src/ophyd_async/core/flyer.py @@ -1,10 +1,9 @@ from abc import ABC, abstractmethod -from typing import Dict, Generic, Optional, Sequence, TypeVar +from typing import Dict, Generic, Sequence, TypeVar from bluesky.protocols import Descriptor, Flyable, Preparable, Reading, Stageable from .async_status import AsyncStatus -from .detector import TriggerInfo from .device import Device from .signal import SignalR from .utils import merge_gathered_dicts @@ -13,18 +12,18 @@ class TriggerLogic(ABC, Generic[T]): - @abstractmethod - def trigger_info(self, value: T) -> TriggerInfo: - """Return info about triggers that will be produced for a given value""" - @abstractmethod async def prepare(self, value: T): """Move to the start of the flyscan""" @abstractmethod - async def start(self): + async def kickoff(self): """Start the flyscan""" + @abstractmethod + async def complete(self): + """Block until the flyscan is done""" + @abstractmethod async def stop(self): """Stop flying and wait everything to be stopped""" @@ -45,19 +44,12 @@ def __init__( ): self._trigger_logic = trigger_logic self._configuration_signals = tuple(configuration_signals) - self._describe: Dict[str, Descriptor] = {} - self._fly_status: Optional[AsyncStatus] = None - self._trigger_info: Optional[TriggerInfo] = None super().__init__(name=name) @property def trigger_logic(self) -> TriggerLogic[T]: return self._trigger_logic - @property - def trigger_info(self) -> Optional[TriggerInfo]: - return self._trigger_info - @AsyncStatus.wrap async def stage(self) -> None: await self.unstage() @@ -71,17 +63,16 @@ def prepare(self, value: T) -> AsyncStatus: return AsyncStatus(self._prepare(value)) async def _prepare(self, value: T) -> None: - self._trigger_info = self._trigger_logic.trigger_info(value) # Move to start and setup the flyscan await self._trigger_logic.prepare(value) @AsyncStatus.wrap async def kickoff(self) -> None: - self._fly_status = AsyncStatus(self._trigger_logic.start()) + await self._trigger_logic.kickoff() - def complete(self) -> AsyncStatus: - assert self._fly_status, "Kickoff not run" - return self._fly_status + @AsyncStatus.wrap + async def complete(self) -> None: + await self._trigger_logic.complete() async def describe_configuration(self) -> Dict[str, Descriptor]: return await merge_gathered_dicts( diff --git a/src/ophyd_async/core/utils.py b/src/ophyd_async/core/utils.py index 42a4b5b7d4..6863c1d1f2 100644 --- a/src/ophyd_async/core/utils.py +++ b/src/ophyd_async/core/utils.py @@ -148,3 +148,20 @@ async def merge_gathered_dicts( async def gather_list(coros: Iterable[Awaitable[T]]) -> List[T]: return await asyncio.gather(*coros) + + +def in_micros(t: float) -> int: + """ + Converts between a positive number of seconds and an equivalent + number of microseconds. + + Args: + t (float): A time in seconds + Raises: + ValueError: if t < 0 + Returns: + t (int): A time in microseconds, rounded up to the nearest whole microsecond, + """ + if t < 0: + raise ValueError(f"Expected a positive time in seconds, got {t!r}") + return int(np.ceil(t * 1e6)) diff --git a/src/ophyd_async/panda/__init__.py b/src/ophyd_async/panda/__init__.py index 3a409cb356..de9095f61c 100644 --- a/src/ophyd_async/panda/__init__.py +++ b/src/ophyd_async/panda/__init__.py @@ -1,4 +1,4 @@ -from .panda import CommonPandABlocks, PandA, PcapBlock, PulseBlock, SeqBlock +from .panda import CommonPandABlocks, PandA, PcapBlock, PulseBlock, SeqBlock, TimeUnits from .panda_controller import PandaPcapController from .table import ( SeqTable, @@ -23,4 +23,5 @@ "PandaPcapController", "DataBlock", "CommonPandABlocks", + "TimeUnits", ] diff --git a/src/ophyd_async/panda/panda.py b/src/ophyd_async/panda/panda.py index 2015d51685..d579877228 100644 --- a/src/ophyd_async/panda/panda.py +++ b/src/ophyd_async/panda/panda.py @@ -1,5 +1,7 @@ from __future__ import annotations +from enum import Enum + from ophyd_async.core import DEFAULT_TIMEOUT, Device, DeviceVector, SignalR, SignalRW from ophyd_async.epics.pvi import fill_pvi_entries from ophyd_async.panda.table import SeqTable @@ -19,9 +21,20 @@ class PulseBlock(Device): width: SignalRW[float] +class TimeUnits(str, Enum): + min = "min" + s = "s" + ms = "ms" + us = "us" + + class SeqBlock(Device): table: SignalRW[SeqTable] active: SignalRW[bool] + repeats: SignalRW[int] + prescale: SignalRW[float] + prescale_units: SignalRW[TimeUnits] + enable: SignalRW[str] class PcapBlock(Device): diff --git a/src/ophyd_async/panda/trigger.py b/src/ophyd_async/panda/trigger.py new file mode 100644 index 0000000000..ef9251b7f5 --- /dev/null +++ b/src/ophyd_async/panda/trigger.py @@ -0,0 +1,40 @@ +import asyncio +from dataclasses import dataclass + +from ophyd_async.core import TriggerLogic, wait_for_value +from ophyd_async.panda import SeqBlock, SeqTable, TimeUnits + + +@dataclass +class SeqTableInfo: + sequence_table: SeqTable + repeats: int + prescale_as_us: float = 1 # microseconds + + +class StaticSeqTableTriggerLogic(TriggerLogic[SeqTableInfo]): + + def __init__(self, seq: SeqBlock) -> None: + self.seq = seq + + async def prepare(self, value: SeqTableInfo): + await asyncio.gather( + self.seq.prescale_units.set(TimeUnits.us), + self.seq.enable.set("ZERO"), + ) + await asyncio.gather( + self.seq.prescale.set(value.prescale_as_us), + self.seq.repeats.set(value.repeats), + self.seq.table.set(value.sequence_table), + ) + + async def kickoff(self) -> None: + await self.seq.enable.set("ONE") + await wait_for_value(self.seq.active, True, timeout=1) + + async def complete(self) -> None: + await wait_for_value(self.seq.active, False, timeout=None) + + async def stop(self): + await self.seq.enable.set("ZERO") + await wait_for_value(self.seq.active, False, timeout=1) diff --git a/src/ophyd_async/planstubs/__init__.py b/src/ophyd_async/planstubs/__init__.py new file mode 100644 index 0000000000..cc409ce3a1 --- /dev/null +++ b/src/ophyd_async/planstubs/__init__.py @@ -0,0 +1,5 @@ +from .prepare_trigger_and_dets import ( + prepare_static_seq_table_flyer_and_detectors_with_same_trigger, +) + +__all__ = ["prepare_static_seq_table_flyer_and_detectors_with_same_trigger"] diff --git a/src/ophyd_async/planstubs/prepare_trigger_and_dets.py b/src/ophyd_async/planstubs/prepare_trigger_and_dets.py new file mode 100644 index 0000000000..ad86ef0a92 --- /dev/null +++ b/src/ophyd_async/planstubs/prepare_trigger_and_dets.py @@ -0,0 +1,58 @@ +from typing import List + +import bluesky.plan_stubs as bps + +from ophyd_async.core.detector import DetectorTrigger, StandardDetector, TriggerInfo +from ophyd_async.core.flyer import HardwareTriggeredFlyable +from ophyd_async.core.utils import in_micros +from ophyd_async.panda.table import SeqTable, SeqTableRow, seq_table_from_rows +from ophyd_async.panda.trigger import SeqTableInfo + + +def prepare_static_seq_table_flyer_and_detectors_with_same_trigger( + flyer: HardwareTriggeredFlyable[SeqTableInfo], + detectors: List[StandardDetector], + num: int, + width: float, + deadtime: float, + shutter_time: float, + repeats: int = 1, + period: float = 0.0, +): + + trigger_info = TriggerInfo( + num=num * repeats, + trigger=DetectorTrigger.constant_gate, + deadtime=deadtime, + livetime=width, + ) + + trigger_time = num * (width + deadtime) + pre_delay = max(period - 2 * shutter_time - trigger_time, 0) + + table: SeqTable = seq_table_from_rows( + # Wait for pre-delay then open shutter + SeqTableRow( + time1=in_micros(pre_delay), + time2=in_micros(shutter_time), + outa2=True, + ), + # Keeping shutter open, do N triggers + SeqTableRow( + repeats=num, + time1=in_micros(width), + outa1=True, + outb1=True, + time2=in_micros(deadtime), + outa2=True, + ), + # Add the shutter close + SeqTableRow(time2=in_micros(shutter_time)), + ) + + table_info = SeqTableInfo(table, repeats) + + for det in detectors: + yield from bps.prepare(det, trigger_info, wait=False, group="prep") + yield from bps.prepare(flyer, table_info, wait=False, group="prep") + yield from bps.wait(group="prep") diff --git a/tests/core/test_flyer.py b/tests/core/test_flyer.py index f4d36e6411..747850f846 100644 --- a/tests/core/test_flyer.py +++ b/tests/core/test_flyer.py @@ -35,18 +35,16 @@ class DummyTriggerLogic(TriggerLogic[int]): def __init__(self): self.state = TriggerState.null - def trigger_info(self, value: int) -> TriggerInfo: - return TriggerInfo( - num=value, trigger=DetectorTrigger.constant_gate, deadtime=2, livetime=2 - ) - async def prepare(self, value: int): self.state = TriggerState.preparing return value - async def start(self): + async def kickoff(self): self.state = TriggerState.starting + async def complete(self): + self.state = TriggerState.null + async def stop(self): self.state = TriggerState.stopping @@ -153,6 +151,9 @@ def append_and_print(name, doc): trigger_logic = DummyTriggerLogic() flyer = HardwareTriggeredFlyable(trigger_logic, [], name="flyer") + trigger_info = TriggerInfo( + num=1, trigger=DetectorTrigger.constant_gate, deadtime=2, livetime=2 + ) def flying_plan(): yield from bps.stage_all(*detector_list, flyer) @@ -166,11 +167,11 @@ def flying_plan(): for detector in detector_list: yield from bps.prepare( detector, - flyer.trigger_info, + trigger_info, wait=True, ) - assert trigger_logic.state == TriggerState.preparing + assert flyer._trigger_logic.state == TriggerState.preparing for detector in detector_list: detector.controller.disarm.assert_called_once # type: ignore @@ -184,7 +185,7 @@ def flying_plan(): yield from bps.complete(flyer, wait=False, group="complete") for detector in detector_list: yield from bps.complete(detector, wait=False, group="complete") - assert trigger_logic.state == TriggerState.starting + assert flyer._trigger_logic.state == TriggerState.null # Manually incremenet the index as if a frame was taken for detector in detector_list: @@ -201,9 +202,8 @@ def flying_plan(): yield from bps.collect( *detector_list, return_payload=False, - # name="main_stream", + name="main_stream", ) - yield from bps.sleep(0.01) yield from bps.wait(group="complete") yield from bps.close_run() @@ -226,16 +226,6 @@ def flying_plan(): ] -def test_flyer_has_trigger_logic_property(): - flyer = HardwareTriggeredFlyable(DummyTriggerLogic(), [], name="flyer") - trigger_info = flyer.trigger_logic.trigger_info(1) - assert type(trigger_info) is TriggerInfo - assert trigger_info.num == 1 - assert trigger_info.trigger == "constant_gate" - assert trigger_info.deadtime == 2 - assert trigger_info.livetime == 2 - - # To do: Populate configuration signals async def test_describe_configuration(): flyer = HardwareTriggeredFlyable(DummyTriggerLogic(), [], name="flyer") diff --git a/tests/epics/areadetector/test_scans.py b/tests/epics/areadetector/test_scans.py index 06c0c396ce..3e13186d29 100644 --- a/tests/epics/areadetector/test_scans.py +++ b/tests/epics/areadetector/test_scans.py @@ -28,15 +28,12 @@ class DummyTriggerLogic(TriggerLogic[int]): def __init__(self): ... - def trigger_info(self, value: int) -> TriggerInfo: - return TriggerInfo( - num=value, trigger=DetectorTrigger.constant_gate, deadtime=2, livetime=2 - ) - async def prepare(self, value: int): return value - async def start(self): ... + async def kickoff(self): ... + + async def complete(self): ... async def stop(self): ... @@ -105,6 +102,9 @@ def test_hdf_writer_fails_on_timeout_with_flyscan(RE: RunEngine, writer: HDFWrit trigger_logic = DummyTriggerLogic() flyer = HardwareTriggeredFlyable(trigger_logic, [], name="flyer") + trigger_info = TriggerInfo( + num=1, trigger=DetectorTrigger.constant_gate, deadtime=2, livetime=2 + ) def flying_plan(): """NOTE: the following is a workaround to ensure tests always pass. @@ -115,7 +115,7 @@ def flying_plan(): # Prepare the flyer first to get the trigger info for the detectors yield from bps.prepare(flyer, 1, wait=True) # prepare detector second. - yield from bps.prepare(detector, flyer.trigger_info, wait=True) + yield from bps.prepare(detector, trigger_info, wait=True) yield from bps.open_run() yield from bps.kickoff(flyer) yield from bps.kickoff(detector) diff --git a/tests/panda/db/panda.db b/tests/panda/db/panda.db index 59a920ca27..305c9abae6 100644 --- a/tests/panda/db/panda.db +++ b/tests/panda/db/panda.db @@ -43,6 +43,66 @@ record(bi, "$(IOC_NAME=PANDAQSRV):SEQ1:ACTIVE") }) } +record(longin, "$(IOC_NAME=PANDAQSRV):SEQ1:REPEATS") +{ + field(PINI, "YES") + info(Q:group, { + "$(IOC_NAME=PANDAQSRV):SEQ1:PVI": { + "pvi.repeats.rw": { + "+channel": "NAME", + "+type": "plain" + } + } + }) +} + +record(ai, "$(IOC_NAME=PANDAQSRV):SEQ1:PRESCALE") +{ + field(PINI, "YES") + info(Q:group, { + "$(IOC_NAME=PANDAQSRV):SEQ1:PVI": { + "pvi.prescale.rw": { + "+channel": "NAME", + "+type": "plain" + } + } + }) +} + +record(mbbi, "$(IOC_NAME=PANDAQSRV):SEQ1:PRESCALE:UNITS") +{ + field(ZRST, "min") + field(ZRVL, "0") + field(ONST, "s") + field(ONVL, "1") + field(TWST, "ms") + field(TWVL, "2") + field(THST, "us") + field(THVL, "3") + field(PINI, "YES") + info(Q:group, { + "$(IOC_NAME=PANDAQSRV):SEQ1:PVI": { + "pvi.prescale_units.rw": { + "+channel": "NAME", + "+type": "plain" + } + } + }) +} + +record(stringout, "$(IOC_NAME=PANDAQSRV):SEQ1:ENABLE") +{ + field(PINI, "YES") + info(Q:group, { + "$(IOC_NAME=PANDAQSRV):SEQ1:PVI": { + "pvi.enable.rw": { + "+channel": "NAME", + "+type": "plain" + } + } + }) +} + record(bi, "$(IOC_NAME=PANDAQSRV):PCAP:ACTIVE") { field(ZNAM, "0") diff --git a/tests/panda/test_panda_utils.py b/tests/panda/test_panda_utils.py index 043de879ba..c636b62e01 100644 --- a/tests/panda/test_panda_utils.py +++ b/tests/panda/test_panda_utils.py @@ -25,7 +25,11 @@ async def test_save_panda(mock_save_to_yaml, sim_panda, RE: RunEngine): mock_save_to_yaml.assert_called_once() assert mock_save_to_yaml.call_args[0] == ( [ - {"phase_1_signal_units": 0}, + { + "phase_1_signal_units": 0, + "seq.1.prescale_units": "min", + "seq.2.prescale_units": "min", + }, { "data.capture": False, "data.flush_period": 0.0, @@ -40,8 +44,14 @@ async def test_save_panda(mock_save_to_yaml, sim_panda, RE: RunEngine): "pulse.2.width": 0.0, "seq.1.table": {}, "seq.1.active": False, + "seq.1.repeats": 0, + "seq.1.prescale": 0.0, + "seq.1.enable": "", "seq.2.table": {}, "seq.2.active": False, + "seq.2.repeats": 0, + "seq.2.prescale": 0.0, + "seq.2.enable": "", }, ], "path", diff --git a/tests/test_flyer_with_panda.py b/tests/test_flyer_with_panda.py new file mode 100644 index 0000000000..573177e701 --- /dev/null +++ b/tests/test_flyer_with_panda.py @@ -0,0 +1,220 @@ +import time +from typing import AsyncGenerator, AsyncIterator, Dict, Optional, Sequence +from unittest.mock import Mock + +import bluesky.plan_stubs as bps +import pytest +from bluesky.protocols import Descriptor, StreamAsset +from bluesky.run_engine import RunEngine +from event_model import ComposeStreamResourceBundle, compose_stream_resource + +from ophyd_async.core import ( + DEFAULT_TIMEOUT, + DetectorControl, + DetectorWriter, + HardwareTriggeredFlyable, + SignalRW, + SimSignalBackend, +) +from ophyd_async.core.detector import StandardDetector +from ophyd_async.core.device import DeviceCollector +from ophyd_async.core.signal import observe_value, set_sim_value +from ophyd_async.panda import PandA +from ophyd_async.panda.trigger import StaticSeqTableTriggerLogic +from ophyd_async.planstubs import ( + prepare_static_seq_table_flyer_and_detectors_with_same_trigger, +) + + +class DummyWriter(DetectorWriter): + def __init__(self, name: str, shape: Sequence[int]): + self.dummy_signal = SignalRW(backend=SimSignalBackend(int, source="test")) + self._shape = shape + self._name = name + self._file: Optional[ComposeStreamResourceBundle] = None + self._last_emitted = 0 + self.index = 0 + + async def open(self, multiplier: int = 1) -> Dict[str, Descriptor]: + return { + self._name: Descriptor( + source="sim://some-source", + shape=self._shape, + dtype="number", + external="STREAM:", + ) + } + + async def observe_indices_written( + self, timeout=DEFAULT_TIMEOUT + ) -> AsyncGenerator[int, None]: + num_captured: int + async for num_captured in observe_value(self.dummy_signal, timeout): + yield num_captured + + async def get_indices_written(self) -> int: + return self.index + + async def collect_stream_docs( + self, indices_written: int + ) -> AsyncIterator[StreamAsset]: + if indices_written: + if not self._file: + self._file = compose_stream_resource( + spec="AD_HDF5_SWMR_SLICE", + root="/", + data_key=self._name, + resource_path="", + resource_kwargs={ + "path": "", + "multiplier": 1, + "timestamps": "/entry/instrument/NDAttributes/NDArrayTimeStamp", + }, + ) + yield "stream_resource", self._file.stream_resource_doc + + if indices_written >= self._last_emitted: + indices = dict( + start=self._last_emitted, + stop=indices_written, + ) + self._last_emitted = indices_written + self._last_flush = time.monotonic() + yield "stream_datum", self._file.compose_stream_datum(indices) + + async def close(self) -> None: + self._file = None + + +@pytest.fixture +async def detector_list(RE: RunEngine) -> tuple[StandardDetector, StandardDetector]: + writers = [DummyWriter("testa", (1, 1)), DummyWriter("testb", (1, 1))] + await writers[0].dummy_signal.connect(sim=True) + await writers[1].dummy_signal.connect(sim=True) + + async def dummy_arm_1(self=None, trigger=None, num=0, exposure=None): + return writers[0].dummy_signal.set(1) + + async def dummy_arm_2(self=None, trigger=None, num=0, exposure=None): + return writers[1].dummy_signal.set(1) + + detector_1: StandardDetector = StandardDetector( + Mock(spec=DetectorControl, get_deadtime=lambda num: num, arm=dummy_arm_1), + writers[0], + name="detector_1", + writer_timeout=3, + ) + detector_2: StandardDetector = StandardDetector( + Mock(spec=DetectorControl, get_deadtime=lambda num: num, arm=dummy_arm_2), + writers[1], + name="detector_2", + writer_timeout=3, + ) + return (detector_1, detector_2) + + +@pytest.fixture +async def panda(): + async with DeviceCollector(sim=True): + sim_panda = PandA("PANDAQSRV:", "sim_panda") + + assert sim_panda.name == "sim_panda" + yield sim_panda + + +async def test_hardware_triggered_flyable_with_static_seq_table_logic( + RE: RunEngine, + detector_list: tuple[StandardDetector], + panda, +): + """Run a dummy scan using a flyer with a prepare plan stub. + + This runs a dummy plan with two detectors and a flyer that uses + StaticSeqTableTriggerLogic. The flyer and detectors are prepared with the + prepare_static_seq_table_flyer_and_detectors_with_same_trigger plan stub. + This stub creates trigger_info and a sequence table from given parameters + and prepares the fly and both detectors with the same trigger info. + + """ + names = [] + docs = [] + + def append_and_print(name, doc): + names.append(name) + docs.append(doc) + + RE.subscribe(append_and_print) + + shutter_time = 0.004 + exposure = 1 + deadtime = max(det.controller.get_deadtime(1) for det in detector_list) + + trigger_logic = StaticSeqTableTriggerLogic(panda.seq[1]) + flyer = HardwareTriggeredFlyable(trigger_logic, [], name="flyer") + + def flying_plan(): + yield from bps.stage_all(*detector_list, flyer) + + yield from prepare_static_seq_table_flyer_and_detectors_with_same_trigger( + flyer, + detector_list, + num=1, + width=exposure, + deadtime=deadtime, + shutter_time=shutter_time, + ) + + for detector in detector_list: + detector.controller.disarm.assert_called_once # type: ignore + + yield from bps.open_run() + yield from bps.declare_stream(*detector_list, name="main_stream", collect=True) + + set_sim_value(flyer.trigger_logic.seq.active, 1) + + yield from bps.kickoff(flyer, wait=True) + for detector in detector_list: + yield from bps.kickoff(detector) + + yield from bps.complete(flyer, wait=False, group="complete") + for detector in detector_list: + yield from bps.complete(detector, wait=False, group="complete") + + # Manually incremenet the index as if a frame was taken + for detector in detector_list: + detector.writer.index += 1 + + set_sim_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( + *detector_list, + return_payload=False, + name="main_stream", + ) + yield from bps.wait(group="complete") + yield from bps.close_run() + + yield from bps.unstage_all(flyer, *detector_list) + for detector in detector_list: + assert detector.controller.disarm.called # type: ignore + + # fly scan + RE(flying_plan()) + + assert names == [ + "start", + "descriptor", + "stream_resource", + "stream_datum", + "stream_resource", + "stream_datum", + "stop", + ] diff --git a/tests/triggers/test_static_seq_table_trigger.py b/tests/triggers/test_static_seq_table_trigger.py new file mode 100644 index 0000000000..a4c3dc8a78 --- /dev/null +++ b/tests/triggers/test_static_seq_table_trigger.py @@ -0,0 +1,22 @@ +import pytest + +from ophyd_async.core.device import DeviceCollector +from ophyd_async.panda import PandA +from ophyd_async.panda.trigger import StaticSeqTableTriggerLogic + + +@pytest.fixture +async def panda(): + async with DeviceCollector(sim=True): + sim_panda = PandA("PANDAQSRV:", "sim_panda") + + assert sim_panda.name == "sim_panda" + yield sim_panda + + +def test_trigger_logic_has_given_methods(panda: PandA): + trigger_logic = StaticSeqTableTriggerLogic(panda.seq[1]) + assert hasattr(trigger_logic, "prepare") + assert hasattr(trigger_logic, "kickoff") + assert hasattr(trigger_logic, "complete") + assert hasattr(trigger_logic, "stop")