From 293e56fba8c08a68bbea19472ea213f71f6d5ce8 Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 3 Jul 2025 20:13:12 +0200 Subject: [PATCH 1/7] feat(ddg): rewrite of the delay generator integration for cSAXS --- csaxs_bec/device_configs/ddg_test_config.yaml | 34 + .../devices/epics/delay_generator_csaxs.py | 274 -------- .../epics/delay_generator_csaxs/__init__.py | 0 .../epics/delay_generator_csaxs/ddg_master.py | 106 +++ .../delay_generator_csaxs.py | 617 ++++++++++++++++++ .../test_delay_generator_csaxs.py | 360 +++------- 6 files changed, 863 insertions(+), 528 deletions(-) create mode 100644 csaxs_bec/device_configs/ddg_test_config.yaml delete mode 100644 csaxs_bec/devices/epics/delay_generator_csaxs.py create mode 100644 csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py create mode 100644 csaxs_bec/devices/epics/delay_generator_csaxs/ddg_master.py create mode 100644 csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py diff --git a/csaxs_bec/device_configs/ddg_test_config.yaml b/csaxs_bec/device_configs/ddg_test_config.yaml new file mode 100644 index 0000000..8e9ad23 --- /dev/null +++ b/csaxs_bec/device_configs/ddg_test_config.yaml @@ -0,0 +1,34 @@ +ddg: + description: 'CSAXS master delay generator' + deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.ddg_master.DDGMaster + deviceConfig: + prefix: "X12SA-CPCL-DDG1:" + enabled: true + readOnly: false + onFailure: raise + readoutPriority: baseline + softwareTrigger: true + +samx: + readoutPriority: baseline + deviceClass: ophyd_devices.SimPositioner + deviceConfig: + delay: 1 + limits: + - -50 + - 50 + tolerance: 0.01 + update_frequency: 400 + deviceTags: + - user motors + enabled: true + readOnly: false + +bpm4i: + readoutPriority: monitored + deviceClass: ophyd_devices.SimMonitor + deviceConfig: + deviceTags: + - beamline + enabled: true + readOnly: false \ No newline at end of file diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs.py b/csaxs_bec/devices/epics/delay_generator_csaxs.py deleted file mode 100644 index 1094857..0000000 --- a/csaxs_bec/devices/epics/delay_generator_csaxs.py +++ /dev/null @@ -1,274 +0,0 @@ -from bec_lib import bec_logger -from ophyd import Component, DeviceStatus, Kind -from ophyd_devices.devices.delay_generator_645 import DelayGenerator, TriggerSource -from ophyd_devices.interfaces.base_classes.bec_device_base import BECDeviceBase, CustomPrepare -from ophyd_devices.sim.sim_signals import SetableSignal -from ophyd_devices.utils import bec_utils - -logger = bec_logger.logger - - -class DelayGeneratorcSAXSError(Exception): - """Exception raised for errors.""" - - -class DDGSetup(CustomPrepare["DelayGeneratorcSAXS"]): - """ - Custom Prepare class with hooks for beamline specific logic for the DG645 at CSAXS - """ - - def on_wait_for_connection(self) -> None: - """Init default parameter after the all signals are connected""" - for ii, channel in enumerate(self.parent.all_channels): - self.parent.set_channels("polarity", self.parent.polarity.get()[ii], [channel]) - - self.parent.set_channels("amplitude", self.parent.amplitude.get()) - self.parent.set_channels("offset", self.parent.offset.get()) - # Setup reference - self.parent.set_channels( - "reference", 0, [f"channel{pair}.ch1" for pair in self.parent.all_delay_pairs] - ) - self.parent.set_channels( - "reference", 0, [f"channel{pair}.ch2" for pair in self.parent.all_delay_pairs] - ) - self.parent.set_trigger(getattr(TriggerSource, self.parent.set_trigger_source.get())) - # Set threshold level for ext. pulses - self.parent.level.put(self.parent.thres_trig_level.get()) - - def on_stage(self) -> None: - "Hook execute before the scan starts" - if self.parent.scaninfo.scan_type == "step": - exp_time = self.parent.scaninfo.exp_time - delay = 0 - self.parent.burst_disable() - self.parent.set_trigger(TriggerSource.SINGLE_SHOT) - self.parent.set_channels(signal="width", value=exp_time) - self.parent.set_channels(signal="delay", value=delay) - return - scan_name = self.parent.scaninfo.scan_msg.content["info"].get("scan_name", "") - if scan_name == "jjf_test": - # TODO implement the logic for JJF triggering - exp_time = 480e-6 # self.parent.scaninfo.exp_time - readout = 20e-6 # self.parent.scaninfo.readout_time - total_exposure = exp_time + readout - num_burst_cycle = self.parent.scaninfo.scan_msg.content["info"]["kwargs"]["num_points"] - num_burst_cycle = int(num_burst_cycle * self.parent.scaninfo.exp_time / total_exposure) - delay = 0 - delay_burst = self.parent.delay_burst.get() - - self.parent.set_trigger(trigger_source=TriggerSource.SINGLE_SHOT) - - self.parent.set_channels(signal="width", value=exp_time) - self.parent.set_channels(signal="delay", value=delay) - self.parent.burst_enable( - count=num_burst_cycle, delay=delay_burst, period=total_exposure, config="first" - ) - logger.info( - f"{self.parent.name}: On stage with n_burst: {num_burst_cycle} and total_exp {total_exposure}" - ) - - def on_trigger(self) -> DeviceStatus: - """Method to be executed upon trigger""" - if self.parent.scaninfo.scan_type == "step": - self.parent.trigger_shot.put(1) - return - scan_name = self.parent.scaninfo.scan_msg.content["info"].get("scan_name", "") - if scan_name == "jjf_test": - exp_time = 480e-6 # self.parent.scaninfo.exp_time - readout = 20e-6 # self.parent.scaninfo.readout_time - total_exposure = exp_time + readout - num_burst_cycle = self.parent.scaninfo.scan_msg.content["info"]["kwargs"]["num_points"] - num_burst_cycle = int(num_burst_cycle * self.parent.scaninfo.exp_time / total_exposure) - - # Start trigger cycle - self.parent.trigger_burst_readout.put(1) - - # Create status object that will wait for the end of the burst cycle - status = self.wait_with_status( - signal_conditions=[(self.parent.burst_cycle_finished, 1)], - timeout=num_burst_cycle * total_exposure + 1, # add 1s to be sure - check_stopped=True, - exception_on_timeout=DelayGeneratorcSAXSError( - f"{self.parent.name} run into timeout in complete call." - ), - ) - logger.info(f"Return status {self.parent.name}") - return status - - def on_complete(self) -> DeviceStatus: - pass - - def on_pre_scan(self) -> None: - """ - Method called by pre_scan hook in parent class. - - Executes trigger if premove_trigger is Trus. - """ - if self.parent.premove_trigger.get() is True: - self.parent.trigger_shot.put(1) - - -class DelayGeneratorcSAXS(BECDeviceBase, DelayGenerator): - """ - DG645 delay generator at cSAXS (multiple can be in use depending on the setup) - - Default values for setting up DDG. - Note: checks of set calues are not (only partially) included, check manual for details on possible settings. - https://www.thinksrs.com/downloads/pdfs/manuals/DG645m.pdf - - - delay_burst : (float >=0) Delay between trigger and first pulse in burst mode - - delta_width : (float >= 0) Add width to fast shutter signal to make sure its open during acquisition - - additional_triggers : (int) add additional triggers to burst mode (mcs card needs +1 triggers per line) - - polarity : (list of 0/1) polarity for different channels - - amplitude : (float) amplitude voltage of TTLs - - offset : (float) offset for ampltitude - - thres_trig_level : (float) threshold of trigger amplitude - - Custom signals for logic in different DDGs during scans (for custom_prepare.prepare_ddg): - - - set_high_on_exposure : (bool): if True, then TTL signal should go high during the full acquisition time of a scan. - # TODO trigger_width and fixed_ttl could be combined into single list. - - fixed_ttl_width : (list of either 1 or 0), one for each channel. - - trigger_width : (float) if fixed_ttl_width is True, then the width of the TTL pulse is set to this value. - - set_trigger_source : (TriggerSource) specifies the default trigger source for the DDG. - - premove_trigger : (bool) if True, then a trigger should be executed before the scan starts (to be implemented in on_pre_scan). - - set_high_on_stage : (bool) if True, then TTL signal should go high already on stage. - """ - - custom_prepare_cls = DDGSetup - - # Custom signals passed on during the init procedure via BEC - # TODO review whether those should remain here like that - - delay_burst = Component( - bec_utils.ConfigSignal, name="delay_burst", kind="config", config_storage_name="ddg_config" - ) - - delta_width = Component( - bec_utils.ConfigSignal, name="delta_width", kind="config", config_storage_name="ddg_config" - ) - - additional_triggers = Component( - bec_utils.ConfigSignal, - name="additional_triggers", - kind="config", - config_storage_name="ddg_config", - ) - - polarity = Component( - bec_utils.ConfigSignal, name="polarity", kind="config", config_storage_name="ddg_config" - ) - - fixed_ttl_width = Component( - bec_utils.ConfigSignal, - name="fixed_ttl_width", - kind="config", - config_storage_name="ddg_config", - ) - - amplitude = Component( - bec_utils.ConfigSignal, name="amplitude", kind="config", config_storage_name="ddg_config" - ) - - offset = Component( - bec_utils.ConfigSignal, name="offset", kind="config", config_storage_name="ddg_config" - ) - - thres_trig_level = Component( - bec_utils.ConfigSignal, - name="thres_trig_level", - kind="config", - config_storage_name="ddg_config", - ) - - set_high_on_exposure = Component( - bec_utils.ConfigSignal, - name="set_high_on_exposure", - kind="config", - config_storage_name="ddg_config", - ) - - set_high_on_stage = Component( - bec_utils.ConfigSignal, - name="set_high_on_stage", - kind="config", - config_storage_name="ddg_config", - ) - - set_trigger_source = Component( - bec_utils.ConfigSignal, - name="set_trigger_source", - kind="config", - config_storage_name="ddg_config", - ) - - trigger_width = Component( - bec_utils.ConfigSignal, - name="trigger_width", - kind="config", - config_storage_name="ddg_config", - ) - premove_trigger = Component( - bec_utils.ConfigSignal, - name="premove_trigger", - kind="config", - config_storage_name="ddg_config", - ) - - def __init__( - self, - name: str, - prefix: str = "", - kind: Kind = None, - ddg_config: dict = None, - parent=None, - device_manager=None, - **kwargs, - ): - """ - Args: - prefix (str, optional): Prefix of the device. Defaults to "". - name (str): Name of the device. - kind (str, optional): Kind of the device. Defaults to None. - read_attrs (list, optional): List of attributes to read. Defaults to None. - configuration_attrs (list, optional): List of attributes to configure. Defaults to None. - parent (Device, optional): Parent device. Defaults to None. - device_manager (DeviceManagerBase, optional): DeviceManagerBase object. Defaults to None. - sim_mode (bool, optional): Simulation mode flag. Defaults to False. - ddg_config (dict, optional): Dictionary of ddg_config signals. Defaults to None. - - """ - - # Default values for ddg_config signals - self.ddg_config = { - # Setup default values - f"{name}_delay_burst": 0, - f"{name}_delta_width": 0, - f"{name}_additional_triggers": 0, - f"{name}_polarity": [1, 1, 1, 1, 1], - f"{name}_amplitude": 4.5, - f"{name}_offset": 0, - f"{name}_thres_trig_level": 2.5, - # Values for different behaviour during scans - f"{name}_fixed_ttl_width": [0, 0, 0, 0, 0], - f"{name}_trigger_width": None, - f"{name}_set_high_on_exposure": False, - f"{name}_set_high_on_stage": False, - f"{name}_set_trigger_source": "SINGLE_SHOT", - f"{name}_premove_trigger": False, - } - if ddg_config is not None: - # pylint: disable=expression-not-assigned - [self.ddg_config.update({f"{name}_{key}": value}) for key, value in ddg_config.items()] - super().__init__( - prefix=prefix, - name=name, - kind=kind, - parent=parent, - device_manager=device_manager, - **kwargs, - ) - - -# if __name__ == "__main__": -# dgen = DelayGeneratorcSAXS("X12SA-CPCL-DDG3:", name="ddg3") diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py b/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_master.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_master.py new file mode 100644 index 0000000..b7dd6dd --- /dev/null +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_master.py @@ -0,0 +1,106 @@ +from threading import Event, Thread +from time import sleep + +from ophyd import DeviceStatus, StatusBase +from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import DelayGeneratorCSAXS, ChannelConfig, AllChannelNames, OUTPUTPOLARITY, TRIGGERSOURCE, StatusBitsCompareStatus, STATUSBITS, CHANNELREFERENCE +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase +from bec_lib.logger import bec_logger +import atexit + +logger = bec_logger.logger + +_DEFAULT_CHANNEL_CONFIG: ChannelConfig = { + "amplitude":5.0, + "offset":0.0, + "polarity": OUTPUTPOLARITY.POSITIVE, + "mode":"ttl", + } + +DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = { + "t0":_DEFAULT_CHANNEL_CONFIG, + "ab":_DEFAULT_CHANNEL_CONFIG, + "cd":_DEFAULT_CHANNEL_CONFIG, + "ef":_DEFAULT_CHANNEL_CONFIG, + "gh":_DEFAULT_CHANNEL_CONFIG, +} +DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.SINGLE_SHOT +DEFAULT_DEAD_TIMES_S = { + "ab":1e-3, + "cd":1e-3, + "ef":1e-3, + "gh":1e-3, +} + + +class DDGMaster(PSIDeviceBase, DelayGeneratorCSAXS): + """ + Implementation of DelayGeneratorCSAXS for the CSAXS master trigger delay generator at X12SA-CPCL-DDG1 + """ + + def on_connected(self) -> None: + """Set the default values on the device - intended to overwrite everything to a usable default state. + Sets DEFAULT_IO_CONFIG into each channel, + sets the trigger source to DEFAULT_TRIGGER_SOURCE, + and turns off burst mode""" + self.burst_disable() # it is possible to miss setting settings if burst is enabled + for channel, config in DEFAULT_IO_CONFIG.items(): + self.set_io_values(channel, **config) + self.set_trigger(DEFAULT_TRIGGER_SOURCE) + self.set_references_for_channels( + [("A", CHANNELREFERENCE.T0), + ("B", CHANNELREFERENCE.A), + ("C", CHANNELREFERENCE.T0), + ("D", CHANNELREFERENCE.C), + ("E", CHANNELREFERENCE.T0), + ("F", CHANNELREFERENCE.E), + ("G", CHANNELREFERENCE.T0), + ("H", CHANNELREFERENCE.G)] + ) + # Start background thread to poll status register + self._status_polling_stop_event = Event() + self._status_polling_thread = Thread(target=self._poll_status) # TODO if exit is called, ca_context from pyepics seems unset.. Hook to kill thread? + self._status_polling_thread.start() + atexit.register(self.on_destroy) + + def _poll_status(self): + """The status register has to be actively pulled, triggering the proc_status results in + event_status being updated, which in turn allows the StatusBitsCompareStatus from on_trigger + to be updated and eventually resolve.""" + dispatcher = self.state.proc_status.cl.get_dispatcher() + event = dispatcher.stop_event + while not (self._status_polling_stop_event.is_set() or event.is_set()): + self.state.proc_status.put(1) + sleep(1/5) # poll the status at 5 Hz + + + def on_stage(self) -> DeviceStatus | StatusBase | None: + exp_time = self.scan_info.msg.scan_parameters["exp_time"] + frames_per_trigger = self.scan_info.msg.scan_parameters["frames_per_trigger"] + readout_time = self.scan_info.msg.scan_parameters["readout_time"] + + if readout_time is not None and readout_time != 0: + # If we are given a single readout time from BEC, use it for all 4 channels + pulse_widths = [exp_time - readout_time]*len(DEFAULT_DEAD_TIMES_S) + else: + # Otherwise, derive the pulse widths from the default dead times defined above + pulse_widths = [exp_time - DEFAULT_DEAD_TIMES_S[ch] for ch in DEFAULT_DEAD_TIMES_S.keys()] + logger.info(f"setting pulse widths to {pulse_widths}") + self.set_delay_pairs(["ab", "cd", "ef", "gh"], delay=0, width=pulse_widths) + self.burst_enable(count=frames_per_trigger, delay=0, period=exp_time) + + def on_trigger(self) -> DeviceStatus | StatusBase | None: + st = StatusBitsCompareStatus(self.state.event_status, STATUSBITS.END_OF_BURST, run=False) + self.cancel_on_stop(st) + self.trigger_shot.put(1) + return st + + def on_destroy(self) -> None: + if getattr(self, "_status_polling_stop_event", None) is not None: + self._status_polling_stop_event.set() + if getattr(self, "_status_polling_thread", None) is not None: + self._status_polling_thread.join(timeout=3) + +if __name__ == "__main__": + ddg = DDGMaster(name="ddg", prefix="X12SA-CPCL-DDG1:") + ddg.wait_for_connection(all_signals=True, timeout=30) + ddg.summary() diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py new file mode 100644 index 0000000..1910c4c --- /dev/null +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py @@ -0,0 +1,617 @@ +""" +Delay generator implementation for CSAXS. + +Detailed information can be found in the manual: +https://www.thinksrs.com/downloads/pdfs/manuals/DG645m.pdf +""" + +import enum +from typing import Literal, TypedDict + +from ophyd import Component as Cpt +from ophyd import Device, EpicsSignal, EpicsSignalRO, Kind, Signal +from ophyd_devices import StatusBase, SubscriptionStatus +from typeguard import typechecked +from bec_lib.logger import bec_logger + +logger = bec_logger.logger +DelayChannelNames = Literal["ab", "cd", "ef", "gh"] +AllChannelNames = Literal["t0", "ab", "cd", "ef", "gh"] +LiteralChannels = Literal["A","B","C","D","E","F","G","H"] + +class CHANNELREFERENCE(enum.Enum): + T0 = 0 + A = 1 + B = 2 + C = 3 + D = 4 + E = 5 + F = 6 + G = 7 + H = 8 + +class BURSTCONFIG(enum.Enum): + """Enum option for burst_config signal of the delay generator. + + ALL_CYCLES: T0 triggere for all cycles. + FIRST_CYCLE: T0 only triggered for the first cycle. + """ + + ALL_CYCLES = 0 + FIRST_CYCLE = 1 + + +class TRIGGERSOURCE(enum.Enum): + """Enum options for the trigger_source signal of the delay generator.""" + + INTERNAL = 0 + EXT_RISING_EDGE = 1 + EXT_FALLING_EDGE = 2 + SS_EXT_RISING_EDGE = 3 + SS_EXT_FALLING_EDGE = 4 + SINGLE_SHOT = 5 + LINE = 6 + + +class TRIGGERINHIBIT(enum.Enum): + """Enum options for the trigger_inhibit signal of the delay generator.""" + + OFF = 0 + TRIGGERS = 1 + AB = 2 + AB_CD = 3 + AB_CD_EF = 4 + AB_CD_EF_GH = 5 + + +class OUTPUTPOLARITY(enum.Enum): + """Enum options for the polarity signal of the static pair.""" + + NEGATIVE = 0 + POSITIVE = 1 + + +class STATUSBITS(enum.IntFlag): + """Bit flags for the status signal of the delay generator.""" + + TRIG = 1 << 0 # Got a trigger. + RATE = 1 << 1 # Got a trigger while a delay or burst was in progress. + END_OF_DELAY = 1 << 2 # A delay cycle has completed. + END_OF_BURST = 1 << 3 # A burst cycle has completed. + INHIBIT = 1 << 4 # A trigger or output delay cycle was inhibited. + ABORT_DELAY = 1 << 5 # A delay cycle was aborted early. + PLL_UNLOCK = 1 << 6 # The 100 MHz PLL came unlocked. + RB_UNLOCK = 1 << 7 # The installed Rb oscillator is unlocked. + + def describe(self) -> dict: + """Return a description of the status bits.""" + descriptions = { + STATUSBITS.TRIG: "Got a trigger.", + STATUSBITS.RATE: "Got a trigger while a delay or burst was in progress.", + STATUSBITS.END_OF_DELAY: "A delay cycle has completed.", + STATUSBITS.END_OF_BURST: "A burst cycle has completed.", + STATUSBITS.INHIBIT: "A trigger or output delay cycle was inhibited.", + STATUSBITS.ABORT_DELAY: "A delay cycle was aborted early.", + STATUSBITS.PLL_UNLOCK: "The 100 MHz PLL came unlocked.", + STATUSBITS.RB_UNLOCK: "The installed Rb oscillator is unlocked.", + } + return {flag.name: descriptions[flag] for flag in STATUSBITS if flag in self} + + +class StatusBitsCompareStatus(SubscriptionStatus): + """Compare status for STATUSBITS comparison.""" + + def __init__( + self, + signal: EpicsSignalRO, + value: STATUSBITS, + *args, + event_type=None, + timeout: float|None = None, + settle_time: float = 0, + run: bool = True, + **kwargs, + ): + """Initialize the compare status with a signal.""" + self._signal = signal + self._value = value + super().__init__( + device=signal, + callback=self._compare_callback, + timeout=timeout, + settle_time=settle_time, + event_type=event_type, + run=run, + ) + + def _compare_callback(self, value, **kwargs) -> bool: + """Callback for subscription status""" + return (STATUSBITS(value) & self._value) == self._value + + + +class ChannelConfig(TypedDict): + amplitude: float | None + offset: float | None + polarity: OUTPUTPOLARITY | Literal[0, 1] | None + mode: Literal["ttl", "nim"] | None + +class StaticPair(Device): + """ + Class to represent a static pair. + It allows setting the logic levels, but the timing is fixed. + The signal is high after receiving the trigger until the end of the holdoff period. + """ + + ttl_mode = Cpt( + EpicsSignal, "OutputModeTtlSS.PROC", kind=Kind.omitted, auto_monitor=True + ) + nim_mode = Cpt( + EpicsSignal, "OutputModeNimSS.PROC", kind=Kind.omitted, auto_monitor=True + ) + polarity = Cpt( + EpicsSignal, + "OutputPolarityBI", + write_pv="OutputPolarityBO", + name="polarity", + kind=Kind.omitted, + auto_monitor=True, + ) + + amplitude = Cpt( + EpicsSignal, + "OutputAmpAI", + write_pv="OutputAmpAO", + name="amplitude", + kind=Kind.omitted, + auto_monitor=True, + ) + + offset = Cpt( + EpicsSignal, + "OutputOffsetAI", + write_pv="OutputOffsetAO", + name="offset", + kind=Kind.omitted, + auto_monitor=True, + ) + + +class Channel(Device): + """ + Represents a single channel A, B, etc. of the delay generator. + """ + + setpoint = Cpt( + EpicsSignal, + write_pv="DelayAO", + read_pv="DelayAI", + put_complete=True, + auto_monitor=True, + kind=Kind.omitted, + doc="Value for the channel", + ) + reference = Cpt( + EpicsSignal, + "ReferenceMO", + put_complete=True, + kind=Kind.omitted, + auto_monitor=True, + doc="Reference channel for the channel", # Check defaults, possible should be T0,A,B,... + ) + + def __init__(self, *args, **kwargs): + """ + Initialize the channel with a setpoint and reference signal. + """ + # The read PV in EpicsSignal does not receive the prefix.. so we need to add it manually. + self.__class__.__dict__["setpoint"].kwargs["read_pv"] = args[0] + "DelayAI" + super().__init__(*args, **kwargs) + + +class WidthSignal(Signal): + """A signal that represents the width of a channel.""" + + def get(self, **kwargs) -> float: + """ + Get the width of the channel. + + Returns: + float: The width of the channel in seconds. + """ + parent: _DelayPairBase = self._parent # type: ignore + return parent.ch2.setpoint.get() - parent.ch1.setpoint.get() # type: ignore + + def check_value(self, value: float) -> float: + """Check if the value is larger equal to 0""" + if value >= 0: + return value + else: + raise ValueError(f"Width must be larger ot equal 0, got {value} seconds.") + + def put(self, value: float, **kwargs): + """ + Set the width of the channel. + + Args: + value (float): The width to set in seconds. + """ + self.check_value(value) + parent: _DelayPairBase = self._parent # type: ignore + ch1_setpoint: float = parent.ch1.setpoint.get()# type: ignore + parent.ch2.setpoint.put(ch1_setpoint + value, **kwargs) + + def set(self, value: float, **kwargs): + """ + Set the width of the channel. + + Args: + value (float): The width to set in seconds. + """ + status = StatusBase() + self.put(value, **kwargs) + status.set_finished() + return status + + +class DelaySignal(Signal): + """A signal that represents the delay of a channel.""" + + def get(self, **kwargs): + """ + Get the delay of the channel. + + Returns: + float: The delay of the channel in seconds. + """ + parent: _DelayPairBase = self._parent # type: ignore + return parent.ch1.setpoint.get() + + def put(self, value: float, **kwargs): + """ + Set the delay of the channel. + + Args: + value (float): The delay to set in seconds. + """ + parent: _DelayPairBase = self._parent # type: ignore + parent.ch1.setpoint.put(value, **kwargs) + parent.ch2.setpoint.put(value + parent.width.get(), **kwargs) + + def set(self, value: float, **kwargs): + """ + Set the width of the channel. + + Args: + value (float): The width to set in seconds. + """ + status = StatusBase() + self.put(value, **kwargs) + status.set_finished() + return status + +class _DelayPairBase(Device): + """ Base class for delay pairs. Children have to implement ch1,ch2 for + the respective delay channels. The class attributes have to be called + ch1, ch2 for width and delay signals to work.""" + + ch1: Cpt[Channel] + ch2: Cpt[Channel] + io: Cpt[StaticPair] + width = Cpt(WidthSignal, name="width", kind=Kind.config, doc="Width of TTL pulse") + delay = Cpt(DelaySignal, name="delay", kind=Kind.config, doc="Delay of TTL pulse") + +class DelayPairAB(_DelayPairBase): + + ch1 = Cpt(Channel, "A", name="A", kind=Kind.omitted) + ch2 = Cpt(Channel, "B", name="B", kind=Kind.omitted) + io = Cpt(StaticPair, "AB", name="io", kind=Kind.omitted) + +class DelayPairCD(_DelayPairBase): + + ch1 = Cpt(Channel, "C", name="C", kind=Kind.omitted) + ch2 = Cpt(Channel, "D", name="D", kind=Kind.omitted) + io = Cpt(StaticPair, "CD", name="io", kind=Kind.omitted) + +class DelayPairEF(_DelayPairBase): + + ch1 = Cpt(Channel, "E", name="E", kind=Kind.omitted) + ch2 = Cpt(Channel, "F", name="F", kind=Kind.omitted) + io = Cpt(StaticPair, "EF", name="io", kind=Kind.omitted) + +class DelayPairGH(_DelayPairBase): + + ch1 = Cpt(Channel, "G", name="G", kind=Kind.omitted) + ch2 = Cpt(Channel, "H", name="H", kind=Kind.omitted) + io = Cpt(StaticPair, "GH", name="io", kind=Kind.omitted) + + +class DelayGeneratorEventStatus(Device): + """Subdevice to represent the event state of the delay generator.""" + + event_status = Cpt( + EpicsSignalRO, "EventStatusLI", name="event_status", kind=Kind.omitted, auto_monitor=True + ) + proc_status = Cpt( + EpicsSignal, "EventStatusLI.PROC", name="proc_status", kind=Kind.omitted + ) + + +class DelayGeneratorCSAXS(Device): + """ + Delay Generator Stanford Research DG645. This implements an interface for the DG645 delay generator. + + Detailed information can be found in the manual: + https://www.thinksrs.com/downloads/pdfs/manuals/DG645m.pdf + + The DG645 has 8 channels, each with a delay and pulse width. The channels are implemented as DelayPair objects (AB etc.). + + Each pair has a TTL pulse width, delay and a reference signal to which they are being triggered. + In addition, the io layer allows setting amplitude, offset and polarity for each pair. + """ + + _pv_timeout: float = 1.5 # Default timeout for PV operations in seconds + + # Front Panel + t0 = Cpt(StaticPair, "T0", name="t0") + ab = Cpt(DelayPairAB, "", name="ab") + cd = Cpt(DelayPairCD, "", name="cd") + ef = Cpt(DelayPairEF, "", name="ef") + gh = Cpt(DelayPairGH, "", name="gh") + state = Cpt(DelayGeneratorEventStatus, "", name="state") + status_msg = Cpt( + EpicsSignalRO, "StatusSI", name="status_msg", kind=Kind.omitted, auto_monitor=True + ) + status_msg_clear = Cpt( + EpicsSignal, "StatusClearBO", name="status_msg_clear", kind=Kind.omitted + ) + + trigger_holdoff = Cpt( + EpicsSignal, + "TriggerHoldoffAI", + write_pv="TriggerHoldoffAO", + name="trigger_holdoff", + kind=Kind.config, + ) + trigger_inhibit = Cpt( + EpicsSignal, + "TriggerInhibitMI", + write_pv="TriggerInhibitMO", + name="trigger_inhibit", + kind=Kind.omitted, + ) + trigger_source = Cpt( + EpicsSignal, + "TriggerSourceMI", + write_pv="TriggerSourceMO", + name="trigger_source", + kind=Kind.omitted, + doc="Trigger Source for the DDG, options in TRIGGERSOURCE" + ) + trigger_level = Cpt( + EpicsSignal, + "TriggerLevelAI", + write_pv="TriggerLevelAO", + name="trigger_level", + kind=Kind.omitted, + ) + trigger_rate = Cpt( + EpicsSignal, + "TriggerRateAI", + write_pv="TriggerRateAO", + name="trigger_rate", + kind=Kind.omitted, + ) + trigger_shot = Cpt( + EpicsSignal, "TriggerDelayBO", name="trigger_shot", kind=Kind.omitted + ) + burst_mode = Cpt( + EpicsSignal, "BurstModeBI", write_pv="BurstModeBO", name="burst_mode", kind=Kind.omitted + ) + burst_config = Cpt( + EpicsSignal, + "BurstConfigBI", + write_pv="BurstConfigBO", + name="burst_config", + kind=Kind.omitted, + ) + burst_count = Cpt( + EpicsSignal, "BurstCountLI", write_pv="BurstCountLO", name="burst_count", kind=Kind.omitted + ) + burst_delay = Cpt( + EpicsSignal, "BurstDelayAI", write_pv="BurstDelayAO", name="burst_delay", kind=Kind.omitted + ) + burst_period = Cpt( + EpicsSignal, + "BurstPeriodAI", + write_pv="BurstPeriodAO", + name="burst_period", + kind=Kind.omitted, + ) + + def proc_event_status(self) -> None: + """The reading must be manually triggered to update the event status.""" + self.state.proc_status.put(1) + + def wait_for_event_status( + self, value: STATUSBITS, timeout: float | None = None + ) -> StatusBitsCompareStatus: + """ + Wait for a specific event status. + + Args: + value (STATUSBITS): The status bits to wait for. + timeout (float): The maximum time to wait in seconds. + """ + return StatusBitsCompareStatus( + signal=self.state.event_status, value=value, timeout=timeout, run=True + ) + + def set_trigger(self, source: TRIGGERSOURCE | int) -> None: + """ + Set the trigger source. + + Args: + source (TriggerSource | int): The trigger source + INTERNAL = 0 + EXT_RISING_EDGE = 1 + EXT_FALLING_EDGE = 2 + SS_EXT_RISING_EDGE = 3 + SS_EXT_FALLING_EDGE = 4 + SINGLE_SHOT = 5 + LINE = 6 + """ + if isinstance(source, TRIGGERSOURCE): + self.trigger_source.set(source.value).wait(self._pv_timeout) + else: + self.trigger_source.set(int(source)).wait(self._pv_timeout) + + @typechecked + def burst_enable( + self, + count: int, + delay: float, + period: float, + config: Literal["all", "first"] | BURSTCONFIG = "first", + ) -> None: + """Enable burst mode with valid parameters. + + Args: + count (int): Number of bursts >0 + delay (float): Delay before bursts start in seconds >=0 + period (float): Period of the bursts in seconds >0 + config (str): Configuration of T0 duiring burst. + In addition, to simplify triggering of other instruments synchronously with the burst, + the T0 output may be configured to fire on the first delay cycle of the burst, + rather than for all delay cycles as is normally the case. BURSTCONFIG + """ + + # Check inputs first + if count <= 0: + raise ValueError(f"Count must be >0, provided: {count}") + if delay < 0: + raise ValueError(f"Delay must be >=0, provided: {delay}") + if period <= 0: + raise ValueError(f"Period must be >0, provided: {period}") + + self.burst_mode.set(1).wait(timeout=self._pv_timeout) + self.burst_count.set(count).wait(timeout=self._pv_timeout) + self.burst_delay.set(delay).wait(timeout=self._pv_timeout) + self.burst_period.set(period).wait(timeout=self._pv_timeout) + + if config == "all": + self.burst_config.set(BURSTCONFIG.ALL_CYCLES.value).wait(timeout=self._pv_timeout) + elif config == "first": + self.burst_config.set(BURSTCONFIG.FIRST_CYCLE.value).wait(timeout=self._pv_timeout) + + def burst_disable(self) -> None: + """Disable burst mode""" + self.burst_mode.set(0).wait(timeout=self._pv_timeout) + + @typechecked + def set_io_values( + self, + channel: ( + AllChannelNames | list[AllChannelNames] + ), + amplitude: float | None = None, + offset: float | None = None, + polarity: OUTPUTPOLARITY | Literal[0, 1] | None = None, + mode: Literal["ttl", "nim"] | None = None, + ) -> None: + """Set the IO values for the static pair. + + Args: + channel (str | list[str]): Channel(s) to set the IO values for. + Can be "t0", "ab", "cd", "ef", "gh" or a list of these. + If a list is provided, the same values will be set for all channels. + amplitude (float): Amplitude of the output signal in volts. + offset (float): Offset of the output signal in volts. + polarity (OUTPUTPOLARITY | int): Polarity of the output signal. + ttl_mode (bool): If True, set the output to TTL mode. + nim_mode (bool): If True, set the output to NIM mode. + If both ttl_mode and nim_mode are set to True, + a ValueError is raised. + """ + if isinstance(channel, str): + channel = [channel] + for ch in channel: + if ch == "t0": + io_channel = self.t0 + else: + io_channel = getattr(getattr(self, ch), "io") + if amplitude is not None: + io_channel.amplitude.set(amplitude).wait(timeout=self._pv_timeout) + if offset is not None: + io_channel.offset.set(offset).wait(timeout=self._pv_timeout) + if polarity is not None: + if isinstance(polarity, OUTPUTPOLARITY): + io_channel.polarity.set(polarity.value).wait(timeout=self._pv_timeout) + else: + io_channel.polarity.set(int(polarity)).wait(timeout=self._pv_timeout) + if mode == "ttl": + io_channel.ttl_mode.set(1).wait(timeout=self._pv_timeout) + if mode == "nim": + io_channel.nim_mode.set(1).wait(timeout=self._pv_timeout) + + def set_delay_pairs( + self, + channel: DelayChannelNames | list[DelayChannelNames], + delay: float | list[float] | None = None, + width: float | list[float] | None = None, + ) -> None: + """Set the delay and width for a specific channel pair. + + Args: + channel (str): Channel pair to set the delay and width for. + Can be "ab", "cd", "ef", "gh". + delay (float): Delay in seconds to set for the channel pair. + width (float): Width in seconds to set for the channel pair. + """ + if isinstance(channel, str): + channel = [channel] + if isinstance(delay, (float, int)): + delay = [float(delay)] * len(channel) + if isinstance(width, (float,int)): + width = [float(width)] * len(channel) + if delay is not None: + if len(delay) != len(channel): + raise ValueError( + f"Length of delay {len(delay)} must match length of channel {len(channel)}." + ) + for ii, ch in enumerate(channel): + delay_channel=getattr(self, ch) + delay_channel.delay.put(delay[ii]) + if width is not None: + if len(width) != len(channel): + raise ValueError( + f"Length of width {len(width)} must match length of channel {len(channel)}." + ) + logger.info(f"setting widths of channels {channel} to {width}") + for ii, ch in enumerate(channel): + delay_channel= getattr(self, ch) + delay_channel.width.put(width[ii]) + + def _get_literal_channel(self, channel: LiteralChannels) -> Channel: + return { + "A": self.ab.ch1, + "B": self.ab.ch2, + "C": self.cd.ch1, + "D": self.cd.ch2, + "E": self.ef.ch1, + "F": self.ef.ch2, + "G": self.gh.ch1, + "H": self.gh.ch2, + }[channel] + + def set_channel_reference(self, channel: LiteralChannels, reference_channel: CHANNELREFERENCE): + self._get_literal_channel(channel).reference.put(reference_channel.value) + + def set_references_for_channels(self, channels_and_refs: list[tuple[LiteralChannels, CHANNELREFERENCE]]): + for ch, ref in channels_and_refs: + self.set_channel_reference(ch, ref) + +if __name__ == "__main__": + ddg = DelayGeneratorCSAXS(name="ddg", prefix="X12SA-CPCL-DDG1:") + ddg.wait_for_connection(all_signals=True, timeout=30) + ddg.summary() diff --git a/tests/tests_devices/test_delay_generator_csaxs.py b/tests/tests_devices/test_delay_generator_csaxs.py index 10005f1..b36e998 100644 --- a/tests/tests_devices/test_delay_generator_csaxs.py +++ b/tests/tests_devices/test_delay_generator_csaxs.py @@ -1,276 +1,128 @@ # pylint: skip-file +import threading +from typing import Generator from unittest import mock +import numpy as np +import ophyd import pytest -from ophyd_devices.devices.delay_generator_645 import TriggerSource +from ophyd_devices.tests.utils import MockPV, patch_dual_pvs -from csaxs_bec.devices.epics.delay_generator_csaxs import DDGSetup +from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import ( + BURSTCONFIG, + STATUSBITS, + TRIGGERSOURCE, + DelayGeneratorCSAXS, +) @pytest.fixture(scope="function") -def mock_DDGSetup(): - mock_ddg = mock.MagicMock() - yield DDGSetup(parent=mock_ddg) +def mock_ddg() -> Generator[DelayGeneratorCSAXS, DelayGeneratorCSAXS, DelayGeneratorCSAXS]: + """Fixture to mock the camera device.""" + name = "ddg" + prefix = "test:" + with mock.patch.object(ophyd, "cl") as mock_cl: + mock_cl.get_pv = MockPV + mock_cl.thread_class = threading.Thread + dev = DelayGeneratorCSAXS(name=name, prefix=prefix) + patch_dual_pvs(dev) + yield dev -# Fixture for scaninfo -@pytest.fixture( - params=[ - { - "scan_id": "1234", - "scan_type": "step", - "num_points": 500, - "frames_per_trigger": 1, - "exp_time": 0.1, - "readout_time": 0.1, - }, - { - "scan_id": "1234", - "scan_type": "step", - "num_points": 500, - "frames_per_trigger": 5, - "exp_time": 0.01, - "readout_time": 0, - }, - { - "scan_id": "1234", - "scan_type": "fly", - "num_points": 500, - "frames_per_trigger": 1, - "exp_time": 1, - "readout_time": 0.2, - }, - { - "scan_id": "1234", - "scan_type": "fly", - "num_points": 500, - "frames_per_trigger": 5, - "exp_time": 0.1, - "readout_time": 0.4, - }, - ] -) -def scaninfo(request): - return request.param +def test_ddg_init(mock_ddg): + """Test the proc event status method.""" + assert mock_ddg.name == "ddg" + assert mock_ddg.prefix == "test:" -# Fixture for DDG config default values -@pytest.fixture( - params=[ - { - "delay_burst": 0.0, - "delta_width": 0.0, - "additional_triggers": 0, - "polarity": [0, 0, 0, 0, 0], - "amplitude": 0.0, - "offset": 0.0, - "thres_trig_level": 0.0, - }, - { - "delay_burst": 0.1, - "delta_width": 0.1, - "additional_triggers": 1, - "polarity": [0, 0, 1, 0, 0], - "amplitude": 5, - "offset": 0.0, - "thres_trig_level": 2.5, - }, - ] -) -def ddg_config_defaults(request): - return request.param +def test_ddg_proc_event_status(mock_ddg): + """Test the proc event status method.""" + mock_ddg.state.proc_status.put(0) + mock_ddg.proc_event_status() + assert mock_ddg.state.proc_status.get() == 1 -# Fixture for DDG config scan values -@pytest.fixture( - params=[ - { - "fixed_ttl_width": [0, 0, 0, 0, 0], - "trigger_width": None, - "set_high_on_exposure": False, - "set_high_on_stage": False, - "set_trigger_source": "SINGLE_SHOT", - "premove_trigger": False, - }, - { - "fixed_ttl_width": [0, 0, 0, 0, 0], - "trigger_width": 0.1, - "set_high_on_exposure": True, - "set_high_on_stage": False, - "set_trigger_source": "SINGLE_SHOT", - "premove_trigger": True, - }, - { - "fixed_ttl_width": [0, 0, 0, 0, 0], - "trigger_width": 0.1, - "set_high_on_exposure": False, - "set_high_on_stage": False, - "set_trigger_source": "EXT_RISING_EDGE", - "premove_trigger": False, - }, - ] -) -def ddg_config_scan(request): - return request.param +def test_ddg_set_trigger(mock_ddg): + """Test setting the trigger.""" + for trigger in TRIGGERSOURCE: + mock_ddg.set_trigger(trigger) + assert mock_ddg.trigger_source.get() == trigger.value -# Fixture for delay pairs -@pytest.fixture( - params=[ - {"all_channels": ["channelAB", "channelCD"], "all_delay_pairs": ["AB", "CD"]}, - {"all_channels": [], "all_delay_pairs": []}, - {"all_channels": ["channelT0", "channelAB", "channelCD"], "all_delay_pairs": ["AB", "CD"]}, - ] -) -def channel_pairs(request): - return request.param +def test_ddg_burst_enable(mock_ddg): + """Test enabling burst mode.""" + mock_ddg.burst_enable(count=100, delay=0.1, period=0.02, config=BURSTCONFIG.ALL_CYCLES) + mock_ddg.burst_mode.get() == 1 + assert mock_ddg.burst_count.get() == 100 + assert mock_ddg.burst_delay.get() == 0.1 + assert mock_ddg.burst_period.get() == 0.02 + assert mock_ddg.burst_config.get() == BURSTCONFIG.ALL_CYCLES.value + assert mock_ddg.burst_mode.get() == 1 + # Count is 0 + with pytest.raises(ValueError): + mock_ddg.burst_enable(count=0, delay=0.1, period=0.02, config=BURSTCONFIG.ALL_CYCLES) + # delay is negative + with pytest.raises(ValueError): + mock_ddg.burst_enable(count=100, delay=-0.1, period=0.02, config=BURSTCONFIG.ALL_CYCLES) + # period is zero + with pytest.raises(ValueError): + mock_ddg.burst_enable(count=100, delay=0.1, period=0, config=BURSTCONFIG.ALL_CYCLES) + + # Works with default config + mock_ddg.burst_enable(count=100, delay=0.1, period=0.02) + mock_ddg.burst_mode.get() == BURSTCONFIG.FIRST_CYCLE.value -def test_on_pre_scan(mock_DDGSetup, scaninfo, ddg_config_defaults, ddg_config_scan): - """Test the check_scan_id method.""" - # Set first attributes of parent class - for k, v in scaninfo.items(): - setattr(mock_DDGSetup.parent.scaninfo, k, v) - for k, v in ddg_config_defaults.items(): - getattr(mock_DDGSetup.parent, k).get.return_value = v - for k, v in ddg_config_scan.items(): - getattr(mock_DDGSetup.parent, k).get.return_value = v - # Call the function you want to test - mock_DDGSetup.on_pre_scan() - if ddg_config_scan["premove_trigger"]: - mock_DDGSetup.parent.trigger_shot.put.assert_called_once_with(1) +def test_ddg_wait_for_event_status(mock_ddg): + """Test setting wait for event status.""" + mock_ddg: DelayGeneratorCSAXS + mock_ddg.state.event_status._read_pv.mock_data = 0 + status = mock_ddg.wait_for_event_status(value=STATUSBITS.END_OF_BURST) # 8 + assert status.done is False + mock_ddg.state.event_status._read_pv.mock_data = 1 + assert status.done is False + mock_ddg.state.event_status._read_pv.mock_data = 4 + assert status.done is False + # TODO enable once callback for MockPV is implemented + # mock_ddg.state.event_status._read_pv.mock_data = 13 # 8 + 4 + 1 + # status.wait(timeout=1) # Wait for the status to be done + # assert status.done is True -# TODO put back once the logic is implemented -# @pytest.mark.parametrize("source", ["SINGLE_SHOT", "EXT_RISING_EDGE"]) -# def test_on_trigger(mock_DDGSetup, scaninfo, ddg_config_defaults, ddg_config_scan, source): -# """Test the on_trigger method.""" -# # Set first attributes of parent class -# for k, v in scaninfo.items(): -# setattr(mock_DDGSetup.parent.scaninfo, k, v) -# for k, v in ddg_config_defaults.items(): -# getattr(mock_DDGSetup.parent, k).get.return_value = v -# for k, v in ddg_config_scan.items(): -# getattr(mock_DDGSetup.parent, k).get.return_value = v -# # Call the function you want to test -# mock_DDGSetup.parent.source.name = "source" -# mock_DDGSetup.parent.source.read.return_value = { -# mock_DDGSetup.parent.source.name: {"value": getattr(TriggerSource, source)} -# } -# mock_DDGSetup.on_trigger() -# if source == "SINGLE_SHOT": -# mock_DDGSetup.parent.trigger_shot.put.assert_called_once_with(1) +def test_ddg_set_io_values(mock_ddg): + """Test setting IO values.""" + mock_ddg.set_io_values(channel="AB", amplitude=3, offset=2, polarity=1, mode="ttl") + assert mock_ddg.AB.io.amplitude.get() == 3 + assert mock_ddg.AB.io.offset.get() == 2 + assert mock_ddg.AB.io.polarity.get() == 1 + assert mock_ddg.AB.io.ttl_mode.get() == 1 + # List of channels + channels = ["AB", "CD", "T0"] + mock_ddg.set_io_values(channel=channels, amplitude=3, offset=2, polarity=1, mode="nim") + for channel in channels: + if channel == "T0": + attr = getattr(mock_ddg, channel) + else: + attr = getattr(mock_ddg, channel).io + assert attr.amplitude.get() == 3 + assert attr.offset.get() == 2 + assert attr.polarity.get() == 1 + assert attr.nim_mode.get() == 1 -def test_on_wait_for_connection( - mock_DDGSetup, scaninfo, ddg_config_defaults, ddg_config_scan, channel_pairs -): - """Test the initialize_default_parameter method.""" - # Set first attributes of parent class - for k, v in scaninfo.items(): - setattr(mock_DDGSetup.parent.scaninfo, k, v) - for k, v in ddg_config_defaults.items(): - getattr(mock_DDGSetup.parent, k).get.return_value = v - for k, v in ddg_config_scan.items(): - getattr(mock_DDGSetup.parent, k).get.return_value = v - # Call the function you want to test - mock_DDGSetup.parent.all_channels = channel_pairs["all_channels"] - mock_DDGSetup.parent.all_delay_pairs = channel_pairs["all_delay_pairs"] - calls = [] - calls.extend( - [ - mock.call("polarity", ddg_config_defaults["polarity"][ii], [channel]) - for ii, channel in enumerate(channel_pairs["all_channels"]) - ] - ) - calls.extend([mock.call("amplitude", ddg_config_defaults["amplitude"])]) - calls.extend([mock.call("offset", ddg_config_defaults["offset"])]) - calls.extend( - [ - mock.call( - "reference", 0, [f"channel{pair}.ch1" for pair in channel_pairs["all_delay_pairs"]] - ) - ] - ) - calls.extend( - [ - mock.call( - "reference", 0, [f"channel{pair}.ch2" for pair in channel_pairs["all_delay_pairs"]] - ) - ] - ) - mock_DDGSetup.on_wait_for_connection() - mock_DDGSetup.parent.set_channels.assert_has_calls(calls) - - -# TODO put back once the logic is implemented -# def test_on_stage(mock_DDGSetup, scaninfo, ddg_config_defaults, ddg_config_scan, channel_pairs): -# """Test the prepare_ddg method.""" -# # Set first attributes of parent class -# for k, v in scaninfo.items(): -# setattr(mock_DDGSetup.parent.scaninfo, k, v) -# for k, v in ddg_config_defaults.items(): -# getattr(mock_DDGSetup.parent, k).get.return_value = v -# for k, v in ddg_config_scan.items(): -# getattr(mock_DDGSetup.parent, k).get.return_value = v -# # Call the function you want to test -# mock_DDGSetup.parent.all_channels = channel_pairs["all_channels"] -# mock_DDGSetup.parent.all_delay_pairs = channel_pairs["all_delay_pairs"] - -# mock_DDGSetup.prepare_ddg() -# mock_DDGSetup.parent.set_trigger.assert_called_once_with( -# getattr(TriggerSource, ddg_config_scan["set_trigger_source"]) -# ) -# if scaninfo["scan_type"] == "step": -# if ddg_config_scan["set_high_on_exposure"]: -# num_burst_cycle = 1 + ddg_config_defaults["additional_triggers"] -# exp_time = ddg_config_defaults["delta_width"] + scaninfo["frames_per_trigger"] * ( -# scaninfo["exp_time"] + scaninfo["readout_time"] -# ) -# total_exposure = exp_time -# delay_burst = ddg_config_defaults["delay_burst"] -# else: -# exp_time = ddg_config_defaults["delta_width"] + scaninfo["exp_time"] -# total_exposure = exp_time + scaninfo["readout_time"] -# delay_burst = ddg_config_defaults["delay_burst"] -# num_burst_cycle = ( -# scaninfo["frames_per_trigger"] + ddg_config_defaults["additional_triggers"] -# ) -# elif scaninfo["scan_type"] == "fly": -# if ddg_config_scan["set_high_on_exposure"]: -# num_burst_cycle = 1 + ddg_config_defaults["additional_triggers"] -# exp_time = ( -# ddg_config_defaults["delta_width"] -# + scaninfo["num_points"] * scaninfo["exp_time"] -# + (scaninfo["num_points"] - 1) * scaninfo["readout_time"] -# ) -# total_exposure = exp_time -# delay_burst = ddg_config_defaults["delay_burst"] -# else: -# exp_time = ddg_config_defaults["delta_width"] + scaninfo["exp_time"] -# total_exposure = exp_time + scaninfo["readout_time"] -# delay_burst = ddg_config_defaults["delay_burst"] -# num_burst_cycle = scaninfo["num_points"] + ddg_config_defaults["additional_triggers"] - -# # mock_DDGSetup.parent.burst_enable.assert_called_once_with( -# # mock.call(num_burst_cycle, delay_burst, total_exposure, config="first") -# # ) -# mock_DDGSetup.parent.burst_enable.assert_called_once_with( -# num_burst_cycle, delay_burst, total_exposure, config="first" -# ) -# if not ddg_config_scan["trigger_width"]: -# call = mock.call("width", exp_time) -# assert call in mock_DDGSetup.parent.set_channels.mock_calls -# else: -# call = mock.call("width", ddg_config_scan["trigger_width"]) -# assert call in mock_DDGSetup.parent.set_channels.mock_calls -# if ddg_config_scan["set_high_on_exposure"]: -# calls = [ -# mock.call("width", value, channels=[channel]) -# for value, channel in zip( -# ddg_config_scan["fixed_ttl_width"], channel_pairs["all_channels"] -# ) -# if value != 0 -# ] -# if calls: -# assert all(calls in mock_DDGSetup.parent.set_channels.mock_calls) +def test_ddg_set_delay_pairs(mock_ddg): + """Test setting delay pairs.""" + mock_ddg.set_delay_pairs(channel="AB", delay=0.1, width=0.2) + assert np.isclose(mock_ddg.AB.delay.get(), 0.1) + assert np.isclose(mock_ddg.AB.width.get(), 0.2) + assert np.isclose(mock_ddg.AB.ch1.setpoint.get(), 0.1) + assert np.isclose(mock_ddg.AB.ch2.setpoint.get(), 0.3) + # List of channels + channels = ["AB", "CD", "EF", "GH"] + delays = [0.1, 0.2, 0.4, 0.5] + mock_ddg.set_delay_pairs(channel=channels, delay=delays, width=0.2) + for delay, channel in zip(delays, channels): + assert np.isclose(getattr(mock_ddg, channel).delay.get(), delay) + assert np.isclose(getattr(mock_ddg, channel).width.get(), 0.2) + assert np.isclose(getattr(mock_ddg, channel).ch1.setpoint.get(), delay) + assert np.isclose(getattr(mock_ddg, channel).ch2.setpoint.get(), delay + 0.2) -- 2.49.1 From 1c539a1e9c5d23a1c6b6414199b67bf31514b299 Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 10 Jul 2025 15:48:41 +0200 Subject: [PATCH 2/7] refactor(ddg): final refactoring and cleanup --- csaxs_bec/device_configs/ddg_test_config.yaml | 34 --- .../epics/delay_generator_csaxs/__init__.py | 1 + .../epics/delay_generator_csaxs/ddg_master.py | 96 ++++---- .../delay_generator_csaxs.py | 206 +++++++++++++----- .../delay_generator_csaxs/error_registry.py | 73 +++++++ .../test_delay_generator_csaxs.py | 26 +-- 6 files changed, 293 insertions(+), 143 deletions(-) delete mode 100644 csaxs_bec/device_configs/ddg_test_config.yaml create mode 100644 csaxs_bec/devices/epics/delay_generator_csaxs/error_registry.py diff --git a/csaxs_bec/device_configs/ddg_test_config.yaml b/csaxs_bec/device_configs/ddg_test_config.yaml deleted file mode 100644 index 8e9ad23..0000000 --- a/csaxs_bec/device_configs/ddg_test_config.yaml +++ /dev/null @@ -1,34 +0,0 @@ -ddg: - description: 'CSAXS master delay generator' - deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.ddg_master.DDGMaster - deviceConfig: - prefix: "X12SA-CPCL-DDG1:" - enabled: true - readOnly: false - onFailure: raise - readoutPriority: baseline - softwareTrigger: true - -samx: - readoutPriority: baseline - deviceClass: ophyd_devices.SimPositioner - deviceConfig: - delay: 1 - limits: - - -50 - - 50 - tolerance: 0.01 - update_frequency: 400 - deviceTags: - - user motors - enabled: true - readOnly: false - -bpm4i: - readoutPriority: monitored - deviceClass: ophyd_devices.SimMonitor - deviceConfig: - deviceTags: - - beamline - enabled: true - readOnly: false \ No newline at end of file diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py b/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py index e69de29..8de5d58 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py @@ -0,0 +1 @@ +from .ddg_master import DDGMaster diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_master.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_master.py index b7dd6dd..305af73 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_master.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_master.py @@ -1,77 +1,92 @@ -from threading import Event, Thread -from time import sleep - -from ophyd import DeviceStatus, StatusBase -from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import DelayGeneratorCSAXS, ChannelConfig, AllChannelNames, OUTPUTPOLARITY, TRIGGERSOURCE, StatusBitsCompareStatus, STATUSBITS, CHANNELREFERENCE -from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase -from bec_lib.logger import bec_logger import atexit +import time +from threading import Event, Thread + +from bec_lib.logger import bec_logger +from ophyd import DeviceStatus, StatusBase +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase + +from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import ( + CHANNELREFERENCE, + OUTPUTPOLARITY, + STATUSBITS, + TRIGGERSOURCE, + AllChannelNames, + ChannelConfig, + DelayGeneratorCSAXS, + StatusBitsCompareStatus, +) +from csaxs_bec.devices.epics.delay_generator_csaxs.error_registry import ERROR_CODES logger = bec_logger.logger _DEFAULT_CHANNEL_CONFIG: ChannelConfig = { - "amplitude":5.0, - "offset":0.0, - "polarity": OUTPUTPOLARITY.POSITIVE, - "mode":"ttl", - } + "amplitude": 5.0, + "offset": 0.0, + "polarity": OUTPUTPOLARITY.POSITIVE, + "mode": "ttl", +} DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = { - "t0":_DEFAULT_CHANNEL_CONFIG, - "ab":_DEFAULT_CHANNEL_CONFIG, - "cd":_DEFAULT_CHANNEL_CONFIG, - "ef":_DEFAULT_CHANNEL_CONFIG, - "gh":_DEFAULT_CHANNEL_CONFIG, + "t0": _DEFAULT_CHANNEL_CONFIG, + "ab": _DEFAULT_CHANNEL_CONFIG, + "cd": _DEFAULT_CHANNEL_CONFIG, + "ef": _DEFAULT_CHANNEL_CONFIG, + "gh": _DEFAULT_CHANNEL_CONFIG, } DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.SINGLE_SHOT -DEFAULT_DEAD_TIMES_S = { - "ab":1e-3, - "cd":1e-3, - "ef":1e-3, - "gh":1e-3, -} +DEFAULT_DEAD_TIMES_S = {"ab": 1e-3, "cd": 1e-3, "ef": 1e-3, "gh": 1e-3} class DDGMaster(PSIDeviceBase, DelayGeneratorCSAXS): """ Implementation of DelayGeneratorCSAXS for the CSAXS master trigger delay generator at X12SA-CPCL-DDG1 """ - + + # pylint: disable=attribute-defined-outside-init def on_connected(self) -> None: """Set the default values on the device - intended to overwrite everything to a usable default state. Sets DEFAULT_IO_CONFIG into each channel, - sets the trigger source to DEFAULT_TRIGGER_SOURCE, + sets the trigger source to DEFAULT_TRIGGER_SOURCE, and turns off burst mode""" - self.burst_disable() # it is possible to miss setting settings if burst is enabled + self.burst_disable() # it is possible to miss setting settings if burst is enabled for channel, config in DEFAULT_IO_CONFIG.items(): - self.set_io_values(channel, **config) + self.set_io_values(channel, **config) self.set_trigger(DEFAULT_TRIGGER_SOURCE) self.set_references_for_channels( - [("A", CHANNELREFERENCE.T0), + [ + ("A", CHANNELREFERENCE.T0), ("B", CHANNELREFERENCE.A), ("C", CHANNELREFERENCE.T0), ("D", CHANNELREFERENCE.C), ("E", CHANNELREFERENCE.T0), ("F", CHANNELREFERENCE.E), ("G", CHANNELREFERENCE.T0), - ("H", CHANNELREFERENCE.G)] + ("H", CHANNELREFERENCE.G), + ] ) # Start background thread to poll status register self._status_polling_stop_event = Event() - self._status_polling_thread = Thread(target=self._poll_status) # TODO if exit is called, ca_context from pyepics seems unset.. Hook to kill thread? + self._status_polling_thread = Thread(target=self._poll_status) self._status_polling_thread.start() atexit.register(self.on_destroy) - + def _poll_status(self): - """The status register has to be actively pulled, triggering the proc_status results in + """The status register has to be actively pulled, triggering the proc_status results in event_status being updated, which in turn allows the StatusBitsCompareStatus from on_trigger to be updated and eventually resolve.""" dispatcher = self.state.proc_status.cl.get_dispatcher() event = dispatcher.stop_event while not (self._status_polling_stop_event.is_set() or event.is_set()): - self.state.proc_status.put(1) - sleep(1/5) # poll the status at 5 Hz - + try: + # Call with timeout to avoid blocking in shutdown + self.state.proc_status.put(1, timeout=1) + except TimeoutError: + # If any of the stop events are set, stop polling + if self._status_polling_stop_event.is_set() or event.is_set(): + logger.info("Exiting _poll_status thread loop for DDG.") + break + time.sleep(1 / 5) # poll the status at 5 Hz def on_stage(self) -> DeviceStatus | StatusBase | None: exp_time = self.scan_info.msg.scan_parameters["exp_time"] @@ -80,10 +95,10 @@ class DDGMaster(PSIDeviceBase, DelayGeneratorCSAXS): if readout_time is not None and readout_time != 0: # If we are given a single readout time from BEC, use it for all 4 channels - pulse_widths = [exp_time - readout_time]*len(DEFAULT_DEAD_TIMES_S) + pulse_widths = [exp_time - readout_time] * len(DEFAULT_DEAD_TIMES_S) else: # Otherwise, derive the pulse widths from the default dead times defined above - pulse_widths = [exp_time - DEFAULT_DEAD_TIMES_S[ch] for ch in DEFAULT_DEAD_TIMES_S.keys()] + pulse_widths = [exp_time - DEFAULT_DEAD_TIMES_S[ch] for ch in DEFAULT_DEAD_TIMES_S] logger.info(f"setting pulse widths to {pulse_widths}") self.set_delay_pairs(["ab", "cd", "ef", "gh"], delay=0, width=pulse_widths) self.burst_enable(count=frames_per_trigger, delay=0, period=exp_time) @@ -93,13 +108,18 @@ class DDGMaster(PSIDeviceBase, DelayGeneratorCSAXS): self.cancel_on_stop(st) self.trigger_shot.put(1) return st - + def on_destroy(self) -> None: if getattr(self, "_status_polling_stop_event", None) is not None: self._status_polling_stop_event.set() if getattr(self, "_status_polling_thread", None) is not None: self._status_polling_thread.join(timeout=3) + def on_stop(self) -> None: + """Stop the delay generator by setting the burst mode to 0""" + self.stop_ddg() + + if __name__ == "__main__": ddg = DDGMaster(name="ddg", prefix="X12SA-CPCL-DDG1:") ddg.wait_for_connection(all_signals=True, timeout=30) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py index 1910c4c..68a939b 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py @@ -8,16 +8,19 @@ https://www.thinksrs.com/downloads/pdfs/manuals/DG645m.pdf import enum from typing import Literal, TypedDict +from bec_lib.logger import bec_logger from ophyd import Component as Cpt from ophyd import Device, EpicsSignal, EpicsSignalRO, Kind, Signal from ophyd_devices import StatusBase, SubscriptionStatus from typeguard import typechecked -from bec_lib.logger import bec_logger + +from csaxs_bec.devices.epics.delay_generator_csaxs.error_registry import ERROR_CODES logger = bec_logger.logger DelayChannelNames = Literal["ab", "cd", "ef", "gh"] AllChannelNames = Literal["t0", "ab", "cd", "ef", "gh"] -LiteralChannels = Literal["A","B","C","D","E","F","G","H"] +LiteralChannels = Literal["A", "B", "C", "D", "E", "F", "G", "H"] + class CHANNELREFERENCE(enum.Enum): T0 = 0 @@ -30,6 +33,7 @@ class CHANNELREFERENCE(enum.Enum): G = 7 H = 8 + class BURSTCONFIG(enum.Enum): """Enum option for burst_config signal of the delay generator. @@ -105,9 +109,10 @@ class StatusBitsCompareStatus(SubscriptionStatus): self, signal: EpicsSignalRO, value: STATUSBITS, + raise_states: list[STATUSBITS] | None = None, *args, event_type=None, - timeout: float|None = None, + timeout: float | None = None, settle_time: float = 0, run: bool = True, **kwargs, @@ -115,6 +120,7 @@ class StatusBitsCompareStatus(SubscriptionStatus): """Initialize the compare status with a signal.""" self._signal = signal self._value = value + self._raise_states = raise_states or [] super().__init__( device=signal, callback=self._compare_callback, @@ -126,28 +132,43 @@ class StatusBitsCompareStatus(SubscriptionStatus): def _compare_callback(self, value, **kwargs) -> bool: """Callback for subscription status""" + if any((STATUSBITS(value) & state) == state for state in self._raise_states): + self.set_exception( + ValueError( + f"Status bits {STATUSBITS(value).describe()} raised an exception: {self._raise_states}" + ) + ) + return False return (STATUSBITS(value) & self._value) == self._value - class ChannelConfig(TypedDict): - amplitude: float | None - offset: float | None - polarity: OUTPUTPOLARITY | Literal[0, 1] | None - mode: Literal["ttl", "nim"] | None + amplitude: float | None + offset: float | None + polarity: OUTPUTPOLARITY | Literal[0, 1] | None + mode: Literal["ttl", "nim"] | None + class StaticPair(Device): """ - Class to represent a static pair. + Class to represent a static pair (T0, aswell as all AB, CB, EF, GH channels). It allows setting the logic levels, but the timing is fixed. The signal is high after receiving the trigger until the end of the holdoff period. """ ttl_mode = Cpt( - EpicsSignal, "OutputModeTtlSS.PROC", kind=Kind.omitted, auto_monitor=True + EpicsSignal, + "OutputModeTtlSS.PROC", + kind=Kind.omitted, + auto_monitor=True, + doc="Set the output mode to TTL", ) nim_mode = Cpt( - EpicsSignal, "OutputModeNimSS.PROC", kind=Kind.omitted, auto_monitor=True + EpicsSignal, + "OutputModeNimSS.PROC", + kind=Kind.omitted, + auto_monitor=True, + doc="Set the output mode to NIM", ) polarity = Cpt( EpicsSignal, @@ -156,6 +177,7 @@ class StaticPair(Device): name="polarity", kind=Kind.omitted, auto_monitor=True, + doc="Control the polarity of the output signal. POS 1 or NEG 0", ) amplitude = Cpt( @@ -165,6 +187,7 @@ class StaticPair(Device): name="amplitude", kind=Kind.omitted, auto_monitor=True, + doc="Amplitude of the output signal in volts.", ) offset = Cpt( @@ -174,12 +197,13 @@ class StaticPair(Device): name="offset", kind=Kind.omitted, auto_monitor=True, + doc="Offset of the output signal in volts.", ) class Channel(Device): """ - Represents a single channel A, B, etc. of the delay generator. + Represents a single channel A, B, C, ... of the delay generator. """ setpoint = Cpt( @@ -189,7 +213,7 @@ class Channel(Device): put_complete=True, auto_monitor=True, kind=Kind.omitted, - doc="Value for the channel", + doc="Setpoint value for the delay of the channel", ) reference = Cpt( EpicsSignal, @@ -197,7 +221,7 @@ class Channel(Device): put_complete=True, kind=Kind.omitted, auto_monitor=True, - doc="Reference channel for the channel", # Check defaults, possible should be T0,A,B,... + doc="Reference channel T0,A,B,.. for the delay of the setpoint", ) def __init__(self, *args, **kwargs): @@ -219,8 +243,8 @@ class WidthSignal(Signal): Returns: float: The width of the channel in seconds. """ - parent: _DelayPairBase = self._parent # type: ignore - return parent.ch2.setpoint.get() - parent.ch1.setpoint.get() # type: ignore + parent: _DelayPairBase = self._parent # type: ignore + return parent.ch2.setpoint.get() - parent.ch1.setpoint.get() # type: ignore def check_value(self, value: float) -> float: """Check if the value is larger equal to 0""" @@ -237,8 +261,8 @@ class WidthSignal(Signal): value (float): The width to set in seconds. """ self.check_value(value) - parent: _DelayPairBase = self._parent # type: ignore - ch1_setpoint: float = parent.ch1.setpoint.get()# type: ignore + parent: _DelayPairBase = self._parent # type: ignore + ch1_setpoint: float = parent.ch1.setpoint.get() # type: ignore parent.ch2.setpoint.put(ch1_setpoint + value, **kwargs) def set(self, value: float, **kwargs): @@ -264,7 +288,7 @@ class DelaySignal(Signal): Returns: float: The delay of the channel in seconds. """ - parent: _DelayPairBase = self._parent # type: ignore + parent: _DelayPairBase = self._parent # type: ignore return parent.ch1.setpoint.get() def put(self, value: float, **kwargs): @@ -274,7 +298,7 @@ class DelaySignal(Signal): Args: value (float): The delay to set in seconds. """ - parent: _DelayPairBase = self._parent # type: ignore + parent: _DelayPairBase = self._parent # type: ignore parent.ch1.setpoint.put(value, **kwargs) parent.ch2.setpoint.put(value + parent.width.get(), **kwargs) @@ -289,51 +313,69 @@ class DelaySignal(Signal): self.put(value, **kwargs) status.set_finished() return status - + + class _DelayPairBase(Device): - """ Base class for delay pairs. Children have to implement ch1,ch2 for + """Base class for delay pairs. Children have to implement ch1,ch2 for the respective delay channels. The class attributes have to be called ch1, ch2 for width and delay signals to work.""" ch1: Cpt[Channel] ch2: Cpt[Channel] io: Cpt[StaticPair] - width = Cpt(WidthSignal, name="width", kind=Kind.config, doc="Width of TTL pulse") - delay = Cpt(DelaySignal, name="delay", kind=Kind.config, doc="Delay of TTL pulse") + width = Cpt( + WidthSignal, name="width", kind=Kind.config, doc="Width of TTL pulse for delay pair" + ) + delay = Cpt( + DelaySignal, name="delay", kind=Kind.config, doc="Delay of TTL pulse for delay pair" + ) + class DelayPairAB(_DelayPairBase): - ch1 = Cpt(Channel, "A", name="A", kind=Kind.omitted) - ch2 = Cpt(Channel, "B", name="B", kind=Kind.omitted) - io = Cpt(StaticPair, "AB", name="io", kind=Kind.omitted) + ch1 = Cpt(Channel, "A", name="A", kind=Kind.omitted, doc="Channel A") + ch2 = Cpt(Channel, "B", name="B", kind=Kind.omitted, doc="Channel B") + io = Cpt(StaticPair, "AB", name="io", kind=Kind.omitted, doc="IO for delay pair AB") + class DelayPairCD(_DelayPairBase): - ch1 = Cpt(Channel, "C", name="C", kind=Kind.omitted) - ch2 = Cpt(Channel, "D", name="D", kind=Kind.omitted) - io = Cpt(StaticPair, "CD", name="io", kind=Kind.omitted) + ch1 = Cpt(Channel, "C", name="C", kind=Kind.omitted, doc="Channel C") + ch2 = Cpt(Channel, "D", name="D", kind=Kind.omitted, doc="Channel D") + io = Cpt(StaticPair, "CD", name="io", kind=Kind.omitted, doc="IO for delay pair CD") + class DelayPairEF(_DelayPairBase): - ch1 = Cpt(Channel, "E", name="E", kind=Kind.omitted) - ch2 = Cpt(Channel, "F", name="F", kind=Kind.omitted) - io = Cpt(StaticPair, "EF", name="io", kind=Kind.omitted) + ch1 = Cpt(Channel, "E", name="E", kind=Kind.omitted, doc="Channel E") + ch2 = Cpt(Channel, "F", name="F", kind=Kind.omitted, doc="Channel F") + io = Cpt(StaticPair, "EF", name="io", kind=Kind.omitted, doc="IO for delay pair EF") + class DelayPairGH(_DelayPairBase): - ch1 = Cpt(Channel, "G", name="G", kind=Kind.omitted) - ch2 = Cpt(Channel, "H", name="H", kind=Kind.omitted) - io = Cpt(StaticPair, "GH", name="io", kind=Kind.omitted) + ch1 = Cpt(Channel, "G", name="G", kind=Kind.omitted, doc="Channel G") + ch2 = Cpt(Channel, "H", name="H", kind=Kind.omitted, doc="Channel H") + io = Cpt(StaticPair, "GH", name="io", kind=Kind.omitted, doc="IO for delay pair GH") class DelayGeneratorEventStatus(Device): """Subdevice to represent the event state of the delay generator.""" event_status = Cpt( - EpicsSignalRO, "EventStatusLI", name="event_status", kind=Kind.omitted, auto_monitor=True + EpicsSignalRO, + "EventStatusLI", + name="event_status", + kind=Kind.omitted, + auto_monitor=True, + doc="Event status register for the delay generator", ) proc_status = Cpt( - EpicsSignal, "EventStatusLI.PROC", name="proc_status", kind=Kind.omitted + EpicsSignal, + "EventStatusLI.PROC", + name="proc_status", + kind=Kind.omitted, + doc="Poll and flush the latest event status register entry from the HW to the event_status signal", ) @@ -353,17 +395,26 @@ class DelayGeneratorCSAXS(Device): _pv_timeout: float = 1.5 # Default timeout for PV operations in seconds # Front Panel - t0 = Cpt(StaticPair, "T0", name="t0") - ab = Cpt(DelayPairAB, "", name="ab") - cd = Cpt(DelayPairCD, "", name="cd") - ef = Cpt(DelayPairEF, "", name="ef") - gh = Cpt(DelayPairGH, "", name="gh") - state = Cpt(DelayGeneratorEventStatus, "", name="state") + t0 = Cpt(StaticPair, "T0", name="t0", doc="T0 static pair") + ab = Cpt(DelayPairAB, "", name="ab", doc="Delay pair AB") + cd = Cpt(DelayPairCD, "", name="cd", doc="Delay pair CD") + ef = Cpt(DelayPairEF, "", name="ef", doc="Delay pair EF") + gh = Cpt(DelayPairGH, "", name="gh", doc="Delay pair GH") + state = Cpt(DelayGeneratorEventStatus, "", name="state", doc="Subdevice for event status") status_msg = Cpt( - EpicsSignalRO, "StatusSI", name="status_msg", kind=Kind.omitted, auto_monitor=True + EpicsSignalRO, + "StatusSI", + name="status_msg", + kind=Kind.omitted, + auto_monitor=True, + doc="Status message from the delay generator", ) status_msg_clear = Cpt( - EpicsSignal, "StatusClearBO", name="status_msg_clear", kind=Kind.omitted + EpicsSignal, + "StatusClearBO", + name="status_msg_clear", + kind=Kind.omitted, + doc="Clear the status message", ) trigger_holdoff = Cpt( @@ -386,7 +437,7 @@ class DelayGeneratorCSAXS(Device): write_pv="TriggerSourceMO", name="trigger_source", kind=Kind.omitted, - doc="Trigger Source for the DDG, options in TRIGGERSOURCE" + doc="Trigger Source for the DDG, options in TRIGGERSOURCE", ) trigger_level = Cpt( EpicsSignal, @@ -403,10 +454,20 @@ class DelayGeneratorCSAXS(Device): kind=Kind.omitted, ) trigger_shot = Cpt( - EpicsSignal, "TriggerDelayBO", name="trigger_shot", kind=Kind.omitted + EpicsSignal, + "TriggerDelayBO", + name="trigger_shot", + kind=Kind.omitted, + doc="Software trigger, needs to be in correct mode to work", ) burst_mode = Cpt( - EpicsSignal, "BurstModeBI", write_pv="BurstModeBO", name="burst_mode", kind=Kind.omitted + EpicsSignal, + "BurstModeBI", + write_pv="BurstModeBO", + name="burst_mode", + kind=Kind.omitted, + auto_monitor=True, + doc="Enable or disable burst mode. 1 = enabled, 0 = disabled.", ) burst_config = Cpt( EpicsSignal, @@ -414,12 +475,23 @@ class DelayGeneratorCSAXS(Device): write_pv="BurstConfigBO", name="burst_config", kind=Kind.omitted, + doc="Configuration of T0 during burst. Can be ALL_CYCLES (0) or FIRST_CYCLE (1) .", ) burst_count = Cpt( - EpicsSignal, "BurstCountLI", write_pv="BurstCountLO", name="burst_count", kind=Kind.omitted + EpicsSignal, + "BurstCountLI", + write_pv="BurstCountLO", + name="burst_count", + kind=Kind.omitted, + doc="Number of bursts to trigger in burst mode. Must be >0.", ) burst_delay = Cpt( - EpicsSignal, "BurstDelayAI", write_pv="BurstDelayAO", name="burst_delay", kind=Kind.omitted + EpicsSignal, + "BurstDelayAI", + write_pv="BurstDelayAO", + name="burst_delay", + kind=Kind.omitted, + doc="Delay before bursts start in seconds. Must be >=0.", ) burst_period = Cpt( EpicsSignal, @@ -427,6 +499,7 @@ class DelayGeneratorCSAXS(Device): write_pv="BurstPeriodAO", name="burst_period", kind=Kind.omitted, + doc="Period of the bursts in seconds. Must be >0.", ) def proc_event_status(self) -> None: @@ -511,9 +584,7 @@ class DelayGeneratorCSAXS(Device): @typechecked def set_io_values( self, - channel: ( - AllChannelNames | list[AllChannelNames] - ), + channel: AllChannelNames | list[AllChannelNames], amplitude: float | None = None, offset: float | None = None, polarity: OUTPUTPOLARITY | Literal[0, 1] | None = None, @@ -572,7 +643,7 @@ class DelayGeneratorCSAXS(Device): channel = [channel] if isinstance(delay, (float, int)): delay = [float(delay)] * len(channel) - if isinstance(width, (float,int)): + if isinstance(width, (float, int)): width = [float(width)] * len(channel) if delay is not None: if len(delay) != len(channel): @@ -580,7 +651,7 @@ class DelayGeneratorCSAXS(Device): f"Length of delay {len(delay)} must match length of channel {len(channel)}." ) for ii, ch in enumerate(channel): - delay_channel=getattr(self, ch) + delay_channel = getattr(self, ch) delay_channel.delay.put(delay[ii]) if width is not None: if len(width) != len(channel): @@ -589,7 +660,7 @@ class DelayGeneratorCSAXS(Device): ) logger.info(f"setting widths of channels {channel} to {width}") for ii, ch in enumerate(channel): - delay_channel= getattr(self, ch) + delay_channel = getattr(self, ch) delay_channel.width.put(width[ii]) def _get_literal_channel(self, channel: LiteralChannels) -> Channel: @@ -607,10 +678,29 @@ class DelayGeneratorCSAXS(Device): def set_channel_reference(self, channel: LiteralChannels, reference_channel: CHANNELREFERENCE): self._get_literal_channel(channel).reference.put(reference_channel.value) - def set_references_for_channels(self, channels_and_refs: list[tuple[LiteralChannels, CHANNELREFERENCE]]): + def set_references_for_channels( + self, channels_and_refs: list[tuple[LiteralChannels, CHANNELREFERENCE]] + ): for ch, ref in channels_and_refs: self.set_channel_reference(ch, ref) + def stop_ddg(self) -> None: + """Stop the delay generator by setting the burst mode to 0""" + self.burst_mode.put(0) + + def reset_error(self) -> None: + """Reset the error status message of the delay generator.""" + self.status_msg_clear.put(1) + + def get_error_msg(self) -> str: + """Get the error message from the delay generator.""" + msg = self.status_msg.get() + if msg in ERROR_CODES: + return ERROR_CODES[msg] + else: + return f"Unknown error code: {msg}" + + if __name__ == "__main__": ddg = DelayGeneratorCSAXS(name="ddg", prefix="X12SA-CPCL-DDG1:") ddg.wait_for_connection(all_signals=True, timeout=30) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/error_registry.py b/csaxs_bec/devices/epics/delay_generator_csaxs/error_registry.py new file mode 100644 index 0000000..1ea0ac1 --- /dev/null +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/error_registry.py @@ -0,0 +1,73 @@ +ERROR_CODES: dict[str, str] = { + "STATUS OK": "No more errors left in the queue.", # renamed apparently from the IOC for No Error to STATUS OK + "Illegal Value": "A parameter was out of range.", + "Illegal Mode": "The action is illegal in the current mode.", + "Illegal Delay": "The requested delay is out of range.", + "Illegal Link": "The requested delay linkage is illegal.", + "Recall Failed": "Recall of instrument settings failed; settings were invalid.", + "Not Allowed": "Action not allowed: instrument is locked by another interface.", + "Failed Self Test": "The DG645 self test failed.", + "Failed Auto Calibration": "The DG645 auto calibration failed.", + "Lost Data": "Output buffer overflow or data lost due to communication error.", + "No Listener": "No GPIB listeners; pending output discarded.", + "Failed ROM Check": "ROM checksum failed; firmware likely corrupted.", + "Failed Offset T0 Test": "Self test of offset functionality for T0 failed.", + "Failed Offset AB Test": "Self test of offset functionality for AB failed.", + "Failed Offset CD Test": "Self test of offset functionality for CD failed.", + "Failed Offset EF Test": "Self test of offset functionality for EF failed.", + "Failed Offset GH Test": "Self test of offset functionality for GH failed.", + "Failed Amplitude T0 Test": "Self test of amplitude functionality for T0 failed.", + "Failed Amplitude AB Test": "Self test of amplitude functionality for AB failed.", + "Failed Amplitude CD Test": "Self test of amplitude functionality for CD failed.", + "Failed Amplitude EF Test": "Self test of amplitude functionality for EF failed.", + "Failed Amplitude GH Test": "Self test of amplitude functionality for GH failed.", + "Failed FPGA Communications Test": "Self test of FPGA communications failed.", + "Failed GPIB Communications Test": "Self test of GPIB communications failed.", + "Failed DDS Communications Test": "Self test of DDS communications failed.", + "Failed Serial EEPROM Communications Test": "Self test of serial EEPROM failed.", + "Failed Temperature Sensor Communications Test": "Temp sensor communication failed.", + "Failed PLL Communications Test": "PLL communication self test failed.", + "Failed DAC 0 Communications Test": "Self test of DAC 0 failed.", + "Failed DAC 1 Communications Test": "Self test of DAC 1 failed.", + "Failed DAC 2 Communications Test": "Self test of DAC 2 failed.", + "Failed Sample and Hold Operations Test": "Sample and hold self test failed.", + "Failed Vjitter Operations Test": "Vjitter operation self test failed.", + "Failed Channel T0 Analog Delay Test": "Analog delay test for T0 failed.", + "Failed Channel T1 Analog Delay Test": "Analog delay test for T1 failed.", + "Failed Channel A Analog Delay Test": "Analog delay test for A failed.", + "Failed Channel B Analog Delay Test": "Analog delay test for B failed.", + "Failed Channel C Analog Delay Test": "Analog delay test for C failed.", + "Failed Channel D Analog Delay Test": "Analog delay test for D failed.", + "Failed Channel E Analog Delay Test": "Analog delay test for E failed.", + "Failed Channel F Analog Delay Test": "Analog delay test for F failed.", + "Failed Channel G Analog Delay Test": "Analog delay test for G failed.", + "Failed Channel H Analog Delay Test": "Analog delay test for H failed.", + "Failed Sample and Hold Calibration": "Auto calibration of sample and hold failed.", + "Failed T0 Calibration": "Auto calibration of channel T0 failed.", + "Failed T1 Calibration": "Auto calibration of channel T1 failed.", + "Failed A Calibration": "Auto calibration of channel A failed.", + "Failed B Calibration": "Auto calibration of channel B failed.", + "Failed C Calibration": "Auto calibration of channel C failed.", + "Failed D Calibration": "Auto calibration of channel D failed.", + "Failed E Calibration": "Auto calibration of channel E failed.", + "Failed F Calibration": "Auto calibration of channel F failed.", + "Failed G Calibration": "Auto calibration of channel G failed.", + "Failed H Calibration": "Auto calibration of channel H failed.", + "Failed Vjitter Calibration": "Auto calibration of Vjitter failed.", + "Illegal Command": "The command syntax used was illegal.", + "Undefined Command": "The specified command does not exist.", + "Illegal Query": "The specified command does not permit queries.", + "Illegal Set": "The specified command can only be queried.", + "Null Parameter": "The parser detected an empty parameter.", + "Extra Parameters": "Too many parameters were provided.", + "Missing Parameters": "Some required parameters are missing.", + "Parameter Overflow": "Buffer overflow while parsing parameters.", + "Invalid Floating Point Number": "Expected a float but couldn't parse it.", + "Invalid Integer": "Expected an integer but couldn't parse it.", + "Integer Overflow": "Parsed integer is too large.", + "Invalid Hexadecimal": "Failed to parse expected hexadecimal input.", + "Syntax Error": "The parser detected a syntax error.", + "Communication Error": "Framing or parity error detected.", + "Over run": "Input buffer overflowed.", + "Too Many Errors": "Error buffer is full; some errors dropped.", +} diff --git a/tests/tests_devices/test_delay_generator_csaxs.py b/tests/tests_devices/test_delay_generator_csaxs.py index b36e998..2274c5d 100644 --- a/tests/tests_devices/test_delay_generator_csaxs.py +++ b/tests/tests_devices/test_delay_generator_csaxs.py @@ -91,16 +91,16 @@ def test_ddg_wait_for_event_status(mock_ddg): def test_ddg_set_io_values(mock_ddg): """Test setting IO values.""" - mock_ddg.set_io_values(channel="AB", amplitude=3, offset=2, polarity=1, mode="ttl") - assert mock_ddg.AB.io.amplitude.get() == 3 - assert mock_ddg.AB.io.offset.get() == 2 - assert mock_ddg.AB.io.polarity.get() == 1 - assert mock_ddg.AB.io.ttl_mode.get() == 1 + mock_ddg.set_io_values(channel="ab", amplitude=3, offset=2, polarity=1, mode="ttl") + assert mock_ddg.ab.io.amplitude.get() == 3 + assert mock_ddg.ab.io.offset.get() == 2 + assert mock_ddg.ab.io.polarity.get() == 1 + assert mock_ddg.ab.io.ttl_mode.get() == 1 # List of channels - channels = ["AB", "CD", "T0"] + channels = ["ab", "cd", "t0"] mock_ddg.set_io_values(channel=channels, amplitude=3, offset=2, polarity=1, mode="nim") for channel in channels: - if channel == "T0": + if channel == "t0": attr = getattr(mock_ddg, channel) else: attr = getattr(mock_ddg, channel).io @@ -112,13 +112,13 @@ def test_ddg_set_io_values(mock_ddg): def test_ddg_set_delay_pairs(mock_ddg): """Test setting delay pairs.""" - mock_ddg.set_delay_pairs(channel="AB", delay=0.1, width=0.2) - assert np.isclose(mock_ddg.AB.delay.get(), 0.1) - assert np.isclose(mock_ddg.AB.width.get(), 0.2) - assert np.isclose(mock_ddg.AB.ch1.setpoint.get(), 0.1) - assert np.isclose(mock_ddg.AB.ch2.setpoint.get(), 0.3) + mock_ddg.set_delay_pairs(channel="ab", delay=0.1, width=0.2) + assert np.isclose(mock_ddg.ab.delay.get(), 0.1) + assert np.isclose(mock_ddg.ab.width.get(), 0.2) + assert np.isclose(mock_ddg.ab.ch1.setpoint.get(), 0.1) + assert np.isclose(mock_ddg.ab.ch2.setpoint.get(), 0.3) # List of channels - channels = ["AB", "CD", "EF", "GH"] + channels = ["ab", "cd", "ef", "gh"] delays = [0.1, 0.2, 0.4, 0.5] mock_ddg.set_delay_pairs(channel=channels, delay=delays, width=0.2) for delay, channel in zip(delays, channels): -- 2.49.1 From 173893ec334ccbea40d6aef9edc50f177bc89682 Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 10 Jul 2025 18:24:57 +0200 Subject: [PATCH 3/7] refacto: remove old configs --- .../bec_device_config_sastt.yaml | 166 +++++++++--------- csaxs_bec/device_configs/ddg_test.yaml | 34 ++++ .../device_configs/epics_devices_config.yaml | 54 +++--- .../epics/delay_generator_csaxs/__init__.py | 2 +- .../epics/delay_generator_csaxs/ddg_1.py | 145 +++++++++++++++ .../{ddg_master.py => ddg_2.py} | 51 +++--- .../delay_generator_csaxs.py | 13 ++ 7 files changed, 327 insertions(+), 138 deletions(-) create mode 100644 csaxs_bec/device_configs/ddg_test.yaml create mode 100644 csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py rename csaxs_bec/devices/epics/delay_generator_csaxs/{ddg_master.py => ddg_2.py} (71%) diff --git a/csaxs_bec/device_configs/bec_device_config_sastt.yaml b/csaxs_bec/device_configs/bec_device_config_sastt.yaml index 142794f..35dc259 100644 --- a/csaxs_bec/device_configs/bec_device_config_sastt.yaml +++ b/csaxs_bec/device_configs/bec_device_config_sastt.yaml @@ -53,89 +53,89 @@ eiger9m: enabled: true readoutPriority: async softwareTrigger: false -ddg_detectors: - description: DelayGenerator for detector triggering - deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS - deviceConfig: - prefix: 'delaygen:DG1:' - ddg_config: - delay_burst: 40.e-3 - delta_width: 0 - additional_triggers: 0 - polarity: - - 1 # T0 -> DDG MCS - - 0 # eiger - - 1 # falcon - - 1 - - 1 - amplitude: 4.5 - offset: 0 - thres_trig_level: 2.5 - set_high_on_exposure: False - set_high_on_stage: False - deviceTags: - - cSAXS - - ddg_detectors - onFailure: buffer - enabled: true - readoutPriority: async - softwareTrigger: false -ddg_mcs: - description: DelayGenerator for mcs triggering - deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS - deviceConfig: - prefix: 'delaygen:DG2:' - ddg_config: - delay_burst: 0 - delta_width: 0 - additional_triggers: 1 - polarity: - - 1 - - 0 - - 1 - - 1 - - 1 - amplitude: 4.5 - offset: 0 - thres_trig_level: 2.5 - set_high_on_exposure: False - set_high_on_stage: False - set_trigger_source: EXT_RISING_EDGE - trigger_width: 3.e-3 - deviceTags: - - cSAXS - - ddg_mcs - onFailure: buffer - enabled: true - readoutPriority: async - softwareTrigger: false -ddg_fsh: - description: DelayGenerator for fast shutter control - deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS - deviceConfig: - prefix: 'delaygen:DG3:' - ddg_config: - delay_burst: 0 - delta_width: 80.e-3 - additional_triggers: 0 - polarity: - - 1 - - 1 - - 1 - - 1 - - 1 - amplitude: 4.5 - offset: 0 - thres_trig_level: 2.5 - set_high_on_exposure: True - set_high_on_stage: False - deviceTags: - - cSAXS - - ddg_fsh - onFailure: buffer - enabled: true - readoutPriority: async - softwareTrigger: false +# ddg_detectors: +# description: DelayGenerator for detector triggering +# deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS +# deviceConfig: +# prefix: 'delaygen:DG1:' +# ddg_config: +# delay_burst: 40.e-3 +# delta_width: 0 +# additional_triggers: 0 +# polarity: +# - 1 # T0 -> DDG MCS +# - 0 # eiger +# - 1 # falcon +# - 1 +# - 1 +# amplitude: 4.5 +# offset: 0 +# thres_trig_level: 2.5 +# set_high_on_exposure: False +# set_high_on_stage: False +# deviceTags: +# - cSAXS +# - ddg_detectors +# onFailure: buffer +# enabled: true +# readoutPriority: async +# softwareTrigger: false +# ddg_mcs: +# description: DelayGenerator for mcs triggering +# deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS +# deviceConfig: +# prefix: 'delaygen:DG2:' +# ddg_config: +# delay_burst: 0 +# delta_width: 0 +# additional_triggers: 1 +# polarity: +# - 1 +# - 0 +# - 1 +# - 1 +# - 1 +# amplitude: 4.5 +# offset: 0 +# thres_trig_level: 2.5 +# set_high_on_exposure: False +# set_high_on_stage: False +# set_trigger_source: EXT_RISING_EDGE +# trigger_width: 3.e-3 +# deviceTags: +# - cSAXS +# - ddg_mcs +# onFailure: buffer +# enabled: true +# readoutPriority: async +# softwareTrigger: false +# ddg_fsh: +# description: DelayGenerator for fast shutter control +# deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS +# deviceConfig: +# prefix: 'delaygen:DG3:' +# ddg_config: +# delay_burst: 0 +# delta_width: 80.e-3 +# additional_triggers: 0 +# polarity: +# - 1 +# - 1 +# - 1 +# - 1 +# - 1 +# amplitude: 4.5 +# offset: 0 +# thres_trig_level: 2.5 +# set_high_on_exposure: True +# set_high_on_stage: False +# deviceTags: +# - cSAXS +# - ddg_fsh +# onFailure: buffer +# enabled: true +# readoutPriority: async +# softwareTrigger: false falcon: description: Falcon detector x-ray fluoresence deviceClass: csaxs_bec.devices.epics.falcon_csaxs.FalconcSAXS diff --git a/csaxs_bec/device_configs/ddg_test.yaml b/csaxs_bec/device_configs/ddg_test.yaml new file mode 100644 index 0000000..e79b771 --- /dev/null +++ b/csaxs_bec/device_configs/ddg_test.yaml @@ -0,0 +1,34 @@ +ddg_master: + description: Main delay Generator for triggering + deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DDGMaster + enabled: true + deviceConfig: + prefix: 'X12SA-CPCL-DDG1:' + onFailure: raise + readOnly: false + readoutPriority: baseline + softwareTrigger: true + +samx: + readoutPriority: baseline + deviceClass: ophyd_devices.SimPositioner + deviceConfig: + delay: 1 + limits: + - -50 + - 50 + tolerance: 0.01 + update_frequency: 400 + deviceTags: + - user motors + enabled: true + readOnly: false + +bpm4i: + readoutPriority: monitored + deviceClass: ophyd_devices.SimMonitor + deviceConfig: + deviceTags: + - beamline + enabled: true + readOnly: false \ No newline at end of file diff --git a/csaxs_bec/device_configs/epics_devices_config.yaml b/csaxs_bec/device_configs/epics_devices_config.yaml index 546f085..1f69623 100644 --- a/csaxs_bec/device_configs/epics_devices_config.yaml +++ b/csaxs_bec/device_configs/epics_devices_config.yaml @@ -1,30 +1,30 @@ -ddg_detectors: - description: DelayGenerator for detector triggering - deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS - deviceConfig: - prefix: 'X12SA-CPCL-DDG3:' - ddg_config: - delay_burst: 40.e-3 - delta_width: 0 - additional_triggers: 0 - polarity: - - 1 # T0 -> DDG MCS - - 0 # eiger - - 1 # falcon - - 1 - - 1 - amplitude: 4.5 - offset: 0 - thres_trig_level: 2.5 - set_high_on_exposure: False - set_high_on_stage: False - deviceTags: - - cSAXS - - ddg_detectors - onFailure: buffer - enabled: true - readoutPriority: async - softwareTrigger: True +# ddg_detectors: +# description: DelayGenerator for detector triggering +# deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS +# deviceConfig: +# prefix: 'X12SA-CPCL-DDG3:' +# ddg_config: +# delay_burst: 40.e-3 +# delta_width: 0 +# additional_triggers: 0 +# polarity: +# - 1 # T0 -> DDG MCS +# - 0 # eiger +# - 1 # falcon +# - 1 +# - 1 +# amplitude: 4.5 +# offset: 0 +# thres_trig_level: 2.5 +# set_high_on_exposure: False +# set_high_on_stage: False +# deviceTags: +# - cSAXS +# - ddg_detectors +# onFailure: buffer +# enabled: true +# readoutPriority: async +# softwareTrigger: True bpm4i: readoutPriority: monitored deviceClass: ophyd_devices.SimMonitor diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py b/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py index 8de5d58..0b883c8 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py @@ -1 +1 @@ -from .ddg_master import DDGMaster +from .ddg_1 import DDG1 diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py new file mode 100644 index 0000000..eea5bcf --- /dev/null +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -0,0 +1,145 @@ +import atexit +import time +from threading import Event, Thread + +from bec_lib.logger import bec_logger +from ophyd import DeviceStatus, StatusBase +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase + +from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import ( + CHANNELREFERENCE, + OUTPUTPOLARITY, + STATUSBITS, + TRIGGERSOURCE, + AllChannelNames, + ChannelConfig, + DelayGeneratorCSAXS, + StatusBitsCompareStatus, +) +from csaxs_bec.devices.epics.delay_generator_csaxs.error_registry import ERROR_CODES + +logger = bec_logger.logger + +_DEFAULT_CHANNEL_CONFIG: ChannelConfig = { + "amplitude": 5.0, + "offset": 0.0, + "polarity": OUTPUTPOLARITY.POSITIVE, + "mode": "ttl", +} + +DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = { + "t0": _DEFAULT_CHANNEL_CONFIG, + "ab": _DEFAULT_CHANNEL_CONFIG, + "cd": _DEFAULT_CHANNEL_CONFIG, + "ef": _DEFAULT_CHANNEL_CONFIG, + "gh": _DEFAULT_CHANNEL_CONFIG, +} +DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.SINGLE_SHOT +DEFAULT_DEAD_TIMES_S = {"ab": 1e-3, "cd": 1e-3, "ef": 1e-3, "gh": 1e-3} + + +class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): + """ + Implementation of DelayGeneratorCSAXS for the CSAXS master trigger delay generator at X12SA-CPCL-DDG1 + """ + + # pylint: disable=attribute-defined-outside-init + def on_connected(self) -> None: + """Set the default values on the device - intended to overwrite everything to a usable default state. + Sets DEFAULT_IO_CONFIG into each channel, + sets the trigger source to DEFAULT_TRIGGER_SOURCE, + and turns off burst mode""" + self.burst_disable() # it is possible to miss setting settings if burst is enabled + for channel, config in DEFAULT_IO_CONFIG.items(): + self.set_io_values(channel, **config) + self.set_trigger(DEFAULT_TRIGGER_SOURCE) + self.set_references_for_channels( + [ + ("A", CHANNELREFERENCE.T0), + ("B", CHANNELREFERENCE.A), + ("C", CHANNELREFERENCE.T0), + ("D", CHANNELREFERENCE.C), + ("E", CHANNELREFERENCE.D), # One extra pulse once shutter closes for MCS + ("F", CHANNELREFERENCE.E), + ("G", CHANNELREFERENCE.T0), + ("H", CHANNELREFERENCE.G), + ] + ) + # Start background thread to poll status register + # self._status_polling_stop_event = Event() + # self._status_polling_thread = Thread(target=self._poll_status) + # self._status_polling_thread.start() + # atexit.register(self.on_destroy) + + # def _poll_status(self): + # """The status register has to be actively pulled, triggering the proc_status results in + # event_status being updated, which in turn allows the StatusBitsCompareStatus from on_trigger + # to be updated and eventually resolve.""" + # dispatcher = self.state.proc_status.cl.get_dispatcher() + # event = dispatcher.stop_event + # while not (self._status_polling_stop_event.is_set() or event.is_set()): + # try: + # # Call with timeout to avoid blocking in shutdown + # self.state.proc_status.put(1, timeout=1) + # except TimeoutError: + # # If any of the stop events are set, stop polling + # if self._status_polling_stop_event.is_set() or event.is_set(): + # logger.info("Exiting _poll_status thread loop for DDG.") + # break + # time.sleep(1 / 5) # poll the status at 5 Hz + + def on_stage(self) -> DeviceStatus | StatusBase | None: + exp_time = self.scan_info.msg.scan_parameters["exp_time"] + frames_per_trigger = self.scan_info.msg.scan_parameters["frames_per_trigger"] + readout_time = self.scan_info.msg.scan_parameters["readout_time"] + + if readout_time is not None and readout_time != 0: + # If we are given a single readout time from BEC, use it for all 4 channels + pulse_widths = [exp_time - readout_time] * len(DEFAULT_DEAD_TIMES_S) + else: + # Otherwise, derive the pulse widths from the default dead times defined above + pulse_widths = [exp_time - DEFAULT_DEAD_TIMES_S[ch] for ch in DEFAULT_DEAD_TIMES_S] + logger.info(f"setting pulse widths to {pulse_widths}") + self.set_delay_pairs(["ab", "cd", "ef", "gh"], delay=0, width=pulse_widths) + self.burst_enable(count=frames_per_trigger, delay=0, period=exp_time) + + def on_trigger(self) -> DeviceStatus | StatusBase | None: + """ Note, we need to add a delay to the StatusBits callback on the event_status. + If we don't then subsequent triggers may reach the DDG too early, and will be ignored. To + avoid this, we've added the option to specify a delay via add_delay, default here is 50ms.""" + st = StatusBase()#StatusBitsCompareStatus(self.state.event_status, STATUSBITS.END_OF_BURST, run=False) + self.cancel_on_stop(st) + self.trigger_shot.put(1, use_complete=True) + time.sleep(self.scan_info.msg.scan_parameters["exp_time"]) + timer = 0 + # TODO make asynchronous and nicer! + while st.done is False: + self.state.proc_status.put(1,use_complete=True) + # Do I need to give this time to update? ask Xiaoqiang!! + event_status = self.state.event_status.get() + if (STATUSBITS(event_status) & STATUSBITS.END_OF_BURST) == STATUSBITS.END_OF_BURST: + st.set_finished() + timer += 0.1 + time.sleep(0.1) + if timer > 1: + st.set_exception(TimeoutError(f"Device {self.name} failed to finish trigger")) + break + time.sleep(0.05) + return st + + def on_destroy(self) -> None: + return + if getattr(self, "_status_polling_stop_event", None) is not None: + self._status_polling_stop_event.set() + if getattr(self, "_status_polling_thread", None) is not None: + self._status_polling_thread.join(timeout=3) + + def on_stop(self) -> None: + """Stop the delay generator by setting the burst mode to 0""" + self.stop_ddg() + + +if __name__ == "__main__": + ddg = DDG1(name="ddg", prefix="X12SA-CPCL-DDG1:") + ddg.wait_for_connection(all_signals=True, timeout=30) + ddg.summary() diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_master.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py similarity index 71% rename from csaxs_bec/devices/epics/delay_generator_csaxs/ddg_master.py rename to csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py index 305af73..95b27b1 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_master.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py @@ -38,7 +38,7 @@ DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.SINGLE_SHOT DEFAULT_DEAD_TIMES_S = {"ab": 1e-3, "cd": 1e-3, "ef": 1e-3, "gh": 1e-3} -class DDGMaster(PSIDeviceBase, DelayGeneratorCSAXS): +class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): """ Implementation of DelayGeneratorCSAXS for the CSAXS master trigger delay generator at X12SA-CPCL-DDG1 """ @@ -59,34 +59,12 @@ class DDGMaster(PSIDeviceBase, DelayGeneratorCSAXS): ("B", CHANNELREFERENCE.A), ("C", CHANNELREFERENCE.T0), ("D", CHANNELREFERENCE.C), - ("E", CHANNELREFERENCE.T0), + ("E", CHANNELREFERENCE.T0),# One extra pulse once shutter closes for MCS ("F", CHANNELREFERENCE.E), ("G", CHANNELREFERENCE.T0), ("H", CHANNELREFERENCE.G), ] ) - # Start background thread to poll status register - self._status_polling_stop_event = Event() - self._status_polling_thread = Thread(target=self._poll_status) - self._status_polling_thread.start() - atexit.register(self.on_destroy) - - def _poll_status(self): - """The status register has to be actively pulled, triggering the proc_status results in - event_status being updated, which in turn allows the StatusBitsCompareStatus from on_trigger - to be updated and eventually resolve.""" - dispatcher = self.state.proc_status.cl.get_dispatcher() - event = dispatcher.stop_event - while not (self._status_polling_stop_event.is_set() or event.is_set()): - try: - # Call with timeout to avoid blocking in shutdown - self.state.proc_status.put(1, timeout=1) - except TimeoutError: - # If any of the stop events are set, stop polling - if self._status_polling_stop_event.is_set() or event.is_set(): - logger.info("Exiting _poll_status thread loop for DDG.") - break - time.sleep(1 / 5) # poll the status at 5 Hz def on_stage(self) -> DeviceStatus | StatusBase | None: exp_time = self.scan_info.msg.scan_parameters["exp_time"] @@ -104,12 +82,31 @@ class DDGMaster(PSIDeviceBase, DelayGeneratorCSAXS): self.burst_enable(count=frames_per_trigger, delay=0, period=exp_time) def on_trigger(self) -> DeviceStatus | StatusBase | None: - st = StatusBitsCompareStatus(self.state.event_status, STATUSBITS.END_OF_BURST, run=False) + """ Note, we need to add a delay to the StatusBits callback on the event_status. + If we don't then subsequent triggers may reach the DDG too early, and will be ignored. To + avoid this, we've added the option to specify a delay via add_delay, default here is 50ms.""" + st = StatusBase()#StatusBitsCompareStatus(self.state.event_status, STATUSBITS.END_OF_BURST, run=False) self.cancel_on_stop(st) - self.trigger_shot.put(1) + self.trigger_shot.put(1, use_complete=True) + time.sleep(self.scan_info.msg.scan_parameters["exp_time"]) + timer = 0 + # TODO make asynchronous and nicer! + while st.done is False: + self.state.proc_status.put(1,use_complete=True) + # Do I need to give this time to update? ask Xiaoqiang!! + event_status = self.state.event_status.get() + if (STATUSBITS(event_status) & STATUSBITS.END_OF_BURST) == STATUSBITS.END_OF_BURST: + st.set_finished() + timer += 0.1 + time.sleep(0.1) + if timer > 1: + st.set_exception(TimeoutError(f"Device {self.name} failed to finish trigger")) + break + time.sleep(0.05) return st def on_destroy(self) -> None: + return if getattr(self, "_status_polling_stop_event", None) is not None: self._status_polling_stop_event.set() if getattr(self, "_status_polling_thread", None) is not None: @@ -121,6 +118,6 @@ class DDGMaster(PSIDeviceBase, DelayGeneratorCSAXS): if __name__ == "__main__": - ddg = DDGMaster(name="ddg", prefix="X12SA-CPCL-DDG1:") + ddg = DDG1(name="ddg", prefix="X12SA-CPCL-DDG1:") ddg.wait_for_connection(all_signals=True, timeout=30) ddg.summary() diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py index 68a939b..a04b9c9 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py @@ -7,6 +7,7 @@ https://www.thinksrs.com/downloads/pdfs/manuals/DG645m.pdf import enum from typing import Literal, TypedDict +import time from bec_lib.logger import bec_logger from ophyd import Component as Cpt @@ -113,6 +114,7 @@ class StatusBitsCompareStatus(SubscriptionStatus): *args, event_type=None, timeout: float | None = None, + add_delay:float|None = None, settle_time: float = 0, run: bool = True, **kwargs, @@ -120,6 +122,7 @@ class StatusBitsCompareStatus(SubscriptionStatus): """Initialize the compare status with a signal.""" self._signal = signal self._value = value + self._add_delay = add_delay or 0 self._raise_states = raise_states or [] super().__init__( device=signal, @@ -132,6 +135,12 @@ class StatusBitsCompareStatus(SubscriptionStatus): def _compare_callback(self, value, **kwargs) -> bool: """Callback for subscription status""" + obj = kwargs.get("obj", None) + if obj is None: + name = 'no object received' + else: + name=obj.name + logger.info(f"Receive update for {name} with value {value}") if any((STATUSBITS(value) & state) == state for state in self._raise_states): self.set_exception( ValueError( @@ -139,6 +148,10 @@ class StatusBitsCompareStatus(SubscriptionStatus): ) ) return False + if self._add_delay !=0: + logger.info(f"Sleeping for {self._add_delay} for {name}") + time.sleep(self._add_delay) + return (STATUSBITS(value) & self._value) == self._value -- 2.49.1 From 98e4364176155a71ee98234bd5102de3b1ff01d0 Mon Sep 17 00:00:00 2001 From: appel_c Date: Mon, 14 Jul 2025 18:14:30 +0200 Subject: [PATCH 4/7] feat: adding ddg1 and ddg2 logic for csaxs --- csaxs_bec/device_configs/ddg_test.yaml | 15 +- .../epics/delay_generator_csaxs/__init__.py | 1 + .../epics/delay_generator_csaxs/ddg_1.py | 184 ++++++++++-------- .../epics/delay_generator_csaxs/ddg_2.py | 144 ++++++++------ .../delay_generator_csaxs/error_registry.py | 2 +- .../trigger_scheme_ddg1_ddg2.pdf | Bin 0 -> 61625 bytes 6 files changed, 201 insertions(+), 145 deletions(-) create mode 100644 csaxs_bec/devices/epics/delay_generator_csaxs/trigger_scheme_ddg1_ddg2.pdf diff --git a/csaxs_bec/device_configs/ddg_test.yaml b/csaxs_bec/device_configs/ddg_test.yaml index e79b771..d4748b1 100644 --- a/csaxs_bec/device_configs/ddg_test.yaml +++ b/csaxs_bec/device_configs/ddg_test.yaml @@ -1,6 +1,6 @@ -ddg_master: +ddg1: description: Main delay Generator for triggering - deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DDGMaster + deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DDG1 enabled: true deviceConfig: prefix: 'X12SA-CPCL-DDG1:' @@ -9,6 +9,17 @@ ddg_master: readoutPriority: baseline softwareTrigger: true +ddg2: + description: Detector delay Generator for trigger burst + deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DDG2 + enabled: true + deviceConfig: + prefix: 'X12SA-CPCL-DDG2:' + onFailure: raise + readOnly: false + readoutPriority: baseline + softwareTrigger: false + samx: readoutPriority: baseline deviceClass: ophyd_devices.SimPositioner diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py b/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py index 0b883c8..04a1eea 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py @@ -1 +1,2 @@ from .ddg_1 import DDG1 +from .ddg_2 import DDG2 diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index eea5bcf..b0bcadd 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -1,6 +1,35 @@ -import atexit +""" +DDG1 delay generator + +This module implements the DDG1 delay generator logic for the CSAXS beamline. +The attached PDF trigger_scheme_ddg1_ddg2.pdf provides a more detailed overview of +the trigger scheme. If the logic changes in the future, it is highly recommended to +update the PDF accordingly. + +The DDG1 is the main trigger delay generator for the CSAXS beamline. It will +receive either a soft trigger from BEC (depending on the scan type) or a hardware trigger +from a beamline device (e.g. the Galil stages). It is responsible for opening the shutter +and sending a trigger to the Delay Generator CSAXS (DDG2), which in turn will +send the trigger to the detectors. DDG1 will not be witout burst mode, but rather in standard +mode creating delays for the channels ab, cd, ef, gh. + +A brief summary of the DDG1 logic: +DELAY PAIRS: +- DelayPair ab is connected to the EXT/EN of DDG2. +- DelayPair cd is connected to the SHUTTER. +- DelayPair ef is connected to an OR gate together with the detector + PULSE train for the MCS card. The MCS card needs one extra pulse to forward points. + +DELAY CHANNELS: +- a = t0 + 2ms (2ms delay to allow the shutter to open) +- b = a + 1us (short pulse) +- c = t0 +- d = a + exp_time * burst_count + 1ms (to allow the shutter to close) +- e = d +- f = e + 1us (short pulse to OR gate for MCS triggering) +""" + import time -from threading import Event, Thread from bec_lib.logger import bec_logger from ophyd import DeviceStatus, StatusBase @@ -14,9 +43,7 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import AllChannelNames, ChannelConfig, DelayGeneratorCSAXS, - StatusBitsCompareStatus, ) -from csaxs_bec.devices.epics.delay_generator_csaxs.error_registry import ERROR_CODES logger = bec_logger.logger @@ -35,104 +62,101 @@ DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = { "gh": _DEFAULT_CHANNEL_CONFIG, } DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.SINGLE_SHOT -DEFAULT_DEAD_TIMES_S = {"ab": 1e-3, "cd": 1e-3, "ef": 1e-3, "gh": 1e-3} +DEFAULT_READOUT_TIMES = {"ab": 2e-4, "cd": 2e-4, "ef": 2e-4, "gh": 2e-4} # 0.2 ms 5kHz + +DEFAULT_REFERENCES: list[tuple[AllChannelNames, CHANNELREFERENCE]] = [ + ("A", CHANNELREFERENCE.T0), # T0 + 2ms delay + ("B", CHANNELREFERENCE.A), + ("C", CHANNELREFERENCE.T0), # T0 + ("D", CHANNELREFERENCE.C), + ("E", CHANNELREFERENCE.D), # D One extra pulse once shutter closes for MCS + ("F", CHANNELREFERENCE.E), # E + 1mu s + ("G", CHANNELREFERENCE.T0), + ("H", CHANNELREFERENCE.G), +] class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): """ - Implementation of DelayGeneratorCSAXS for the CSAXS master trigger delay generator at X12SA-CPCL-DDG1 + Implementation of DelayGeneratorCSAXS for the CSAXS master trigger delay generator at X12SA-CPCL-DDG1. + It will be triggered by a soft trigger from BEC or a hardware trigger from a beamline device (e.g. the Galil stages). + It is operated in standard mode, not burst mode and will trigger the EXT/EN of DDG2 (channel ab). + It is responsible for opening the shutter (channel cd) and sending an extra trigger to an or gate for the MCS card (channel ef). """ # pylint: disable=attribute-defined-outside-init def on_connected(self) -> None: - """Set the default values on the device - intended to overwrite everything to a usable default state. - Sets DEFAULT_IO_CONFIG into each channel, - sets the trigger source to DEFAULT_TRIGGER_SOURCE, - and turns off burst mode""" + """ + Set the default values on the device - intended to overwrite everything to a usable default state. + Sets DEFAULT_IO_CONFIG into each channel, sets the trigger source to DEFAULT_TRIGGER_SOURCE, + and turns off burst mode. + """ self.burst_disable() # it is possible to miss setting settings if burst is enabled for channel, config in DEFAULT_IO_CONFIG.items(): self.set_io_values(channel, **config) self.set_trigger(DEFAULT_TRIGGER_SOURCE) - self.set_references_for_channels( - [ - ("A", CHANNELREFERENCE.T0), - ("B", CHANNELREFERENCE.A), - ("C", CHANNELREFERENCE.T0), - ("D", CHANNELREFERENCE.C), - ("E", CHANNELREFERENCE.D), # One extra pulse once shutter closes for MCS - ("F", CHANNELREFERENCE.E), - ("G", CHANNELREFERENCE.T0), - ("H", CHANNELREFERENCE.G), - ] - ) - # Start background thread to poll status register - # self._status_polling_stop_event = Event() - # self._status_polling_thread = Thread(target=self._poll_status) - # self._status_polling_thread.start() - # atexit.register(self.on_destroy) - - # def _poll_status(self): - # """The status register has to be actively pulled, triggering the proc_status results in - # event_status being updated, which in turn allows the StatusBitsCompareStatus from on_trigger - # to be updated and eventually resolve.""" - # dispatcher = self.state.proc_status.cl.get_dispatcher() - # event = dispatcher.stop_event - # while not (self._status_polling_stop_event.is_set() or event.is_set()): - # try: - # # Call with timeout to avoid blocking in shutdown - # self.state.proc_status.put(1, timeout=1) - # except TimeoutError: - # # If any of the stop events are set, stop polling - # if self._status_polling_stop_event.is_set() or event.is_set(): - # logger.info("Exiting _poll_status thread loop for DDG.") - # break - # time.sleep(1 / 5) # poll the status at 5 Hz + self.set_references_for_channels(DEFAULT_REFERENCES) def on_stage(self) -> DeviceStatus | StatusBase | None: + """ + Stage logic for the DDG1 device, being th main trigger delay generator for CSAXS. + For standard scans, it will be triggered by a soft trigger from BEC. + It also has a hardware trigger feeded into the EXT/EN for fly-scanning, i.e. Galil stages. + + This DDG is always not in burst mode. + """ + self.burst_disable() exp_time = self.scan_info.msg.scan_parameters["exp_time"] frames_per_trigger = self.scan_info.msg.scan_parameters["frames_per_trigger"] - readout_time = self.scan_info.msg.scan_parameters["readout_time"] - - if readout_time is not None and readout_time != 0: - # If we are given a single readout time from BEC, use it for all 4 channels - pulse_widths = [exp_time - readout_time] * len(DEFAULT_DEAD_TIMES_S) - else: - # Otherwise, derive the pulse widths from the default dead times defined above - pulse_widths = [exp_time - DEFAULT_DEAD_TIMES_S[ch] for ch in DEFAULT_DEAD_TIMES_S] - logger.info(f"setting pulse widths to {pulse_widths}") - self.set_delay_pairs(["ab", "cd", "ef", "gh"], delay=0, width=pulse_widths) - self.burst_enable(count=frames_per_trigger, delay=0, period=exp_time) + # Trigger DDG2 + # a = t0 + 2ms, b = a + 1us + # a has reference to t0, b has reference to a + self.set_delay_pairs(channel="ab", delay=2e-3, width=1e-6) + # Trigger shutter + shutter_width = 2e-3 + exp_time * frames_per_trigger + 1e-3 + # d = c/t0 + 2ms + exp_time * burst_count + 1ms + # c has reference to t0, d has reference to c + self.set_delay_pairs(channel="cd", delay=0, width=shutter_width) + # Trigger extra pulse for MCS OR gate + # f = e + 1us + # e has refernce to d, f has reference to e + self.set_delay_pairs(channel="ef", delay=0, width=1e-6) def on_trigger(self) -> DeviceStatus | StatusBase | None: - """ Note, we need to add a delay to the StatusBits callback on the event_status. + """Note, we need to add a delay to the StatusBits callback on the event_status. If we don't then subsequent triggers may reach the DDG too early, and will be ignored. To - avoid this, we've added the option to specify a delay via add_delay, default here is 50ms.""" - st = StatusBase()#StatusBitsCompareStatus(self.state.event_status, STATUSBITS.END_OF_BURST, run=False) + avoid this, we've added the option to specify a delay via add_delay, default here is 50ms. + """ + st = StatusBase() self.cancel_on_stop(st) self.trigger_shot.put(1, use_complete=True) time.sleep(self.scan_info.msg.scan_parameters["exp_time"]) - timer = 0 - # TODO make asynchronous and nicer! - while st.done is False: - self.state.proc_status.put(1,use_complete=True) - # Do I need to give this time to update? ask Xiaoqiang!! - event_status = self.state.event_status.get() - if (STATUSBITS(event_status) & STATUSBITS.END_OF_BURST) == STATUSBITS.END_OF_BURST: - st.set_finished() - timer += 0.1 - time.sleep(0.1) - if timer > 1: - st.set_exception(TimeoutError(f"Device {self.name} failed to finish trigger")) - break - time.sleep(0.05) - return st + self.cancel_on_stop(st) + status = self.wait_for_status(status=st, bit_event=STATUSBITS.END_OF_DELAY, timeout=2) + return status - def on_destroy(self) -> None: - return - if getattr(self, "_status_polling_stop_event", None) is not None: - self._status_polling_stop_event.set() - if getattr(self, "_status_polling_thread", None) is not None: - self._status_polling_thread.join(timeout=3) + def wait_for_status( + self, status: StatusBase, bit_event: STATUSBITS, timeout: float = 2 + ) -> None: + """Wait for a event status bit to be set. + + Args: + status (StatusBase): The status object to update. + bit_event (STATUSBITS): The event status bit to wait for. + timeout (float): Maximum time to wait for the event status bit to be set. + """ + current_time = time.time() + while not status.done: + self.state.proc_status.put(1, use_complete=True) + event_status = self.state.event_status.get() + if (STATUSBITS(event_status) & bit_event) == bit_event: + status.set_finished() + if time.time() - current_time > timeout: + status.set_exception(TimeoutError(f"Timeout waiting for status {status}")) + break + time.sleep(0.1) + time.sleep(0.05) # Give time for the IOC to be ready again + return status def on_stop(self) -> None: """Stop the delay generator by setting the burst mode to 0""" @@ -140,6 +164,6 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): if __name__ == "__main__": - ddg = DDG1(name="ddg", prefix="X12SA-CPCL-DDG1:") + ddg = DDG1(name="ddg1", prefix="X12SA-CPCL-DDG1:") ddg.wait_for_connection(all_signals=True, timeout=30) ddg.summary() diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py index 95b27b1..d9169bc 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py @@ -1,6 +1,28 @@ -import atexit +""" +DDG2 delay generator + +This module implements the DDG2 delay generator logic for the CSAXS beamline. +Please check also the code for DDG1, aswell as the attached PDF trigger_scheme_ddg1_ddg2.pdf + +The DDG2 is responsible for creating a burst of triggers for all relevant detectors. +It will receive a be triggered from the DDG1 through the EXT/EN channel. + +A brief summary of the DDG2 logic: +DELAY PAIRS: +- EXT/EN is connected to the DDG1 delay pair ab. +- DelayPair ab is connected to a multiplexer, multiplexing the trigger to the detectors. + +DELAY CHANNELS: +- a = t0 +- b = a + (exp_time - READOUT_TIMES) + +Burst mode is enabled: +- Burst count is set to the number of frames per trigger. +- Burst delay is set to 0. +- Burst period is set to the exposure time. +""" + import time -from threading import Event, Thread from bec_lib.logger import bec_logger from ophyd import DeviceStatus, StatusBase @@ -14,9 +36,7 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import AllChannelNames, ChannelConfig, DelayGeneratorCSAXS, - StatusBitsCompareStatus, ) -from csaxs_bec.devices.epics.delay_generator_csaxs.error_registry import ERROR_CODES logger = bec_logger.logger @@ -34,83 +54,83 @@ DEFAULT_IO_CONFIG: dict[AllChannelNames, ChannelConfig] = { "ef": _DEFAULT_CHANNEL_CONFIG, "gh": _DEFAULT_CHANNEL_CONFIG, } -DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.SINGLE_SHOT -DEFAULT_DEAD_TIMES_S = {"ab": 1e-3, "cd": 1e-3, "ef": 1e-3, "gh": 1e-3} +DEFAULT_TRIGGER_SOURCE: TRIGGERSOURCE = TRIGGERSOURCE.EXT_RISING_EDGE +DEFAULT_READOUT_TIMES = {"ab": 2e-4, "cd": 2e-4, "ef": 2e-4, "gh": 2e-4} # 0.2 ms 5kHz + +DEFAULT_REFERENCES: list[tuple[AllChannelNames, CHANNELREFERENCE]] = [ + ("A", CHANNELREFERENCE.T0), + ("B", CHANNELREFERENCE.A), + ("C", CHANNELREFERENCE.T0), + ("D", CHANNELREFERENCE.C), + ("E", CHANNELREFERENCE.T0), + ("F", CHANNELREFERENCE.E), + ("G", CHANNELREFERENCE.T0), + ("H", CHANNELREFERENCE.G), +] class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): """ - Implementation of DelayGeneratorCSAXS for the CSAXS master trigger delay generator at X12SA-CPCL-DDG1 + Implementation of DelayGeneratorCSAXS for the CSAXS master trigger delay generator at X12SA-CPCL-DDG2. + This device is responsible for creating triggers in burst mode and is connected to a multiplexer that + distributes the trigger to the detectors. The DDG2 is triggered by the DDG1 through the EXT/EN channel. """ # pylint: disable=attribute-defined-outside-init def on_connected(self) -> None: - """Set the default values on the device - intended to overwrite everything to a usable default state. - Sets DEFAULT_IO_CONFIG into each channel, - sets the trigger source to DEFAULT_TRIGGER_SOURCE, - and turns off burst mode""" + """ + Set the default values on the device - intended to overwrite everything to a usable default state. + Sets DEFAULT_IO_CONFIG into each channel, sets the trigger source to DEFAULT_TRIGGER_SOURCE. + """ self.burst_disable() # it is possible to miss setting settings if burst is enabled for channel, config in DEFAULT_IO_CONFIG.items(): self.set_io_values(channel, **config) self.set_trigger(DEFAULT_TRIGGER_SOURCE) - self.set_references_for_channels( - [ - ("A", CHANNELREFERENCE.T0), - ("B", CHANNELREFERENCE.A), - ("C", CHANNELREFERENCE.T0), - ("D", CHANNELREFERENCE.C), - ("E", CHANNELREFERENCE.T0),# One extra pulse once shutter closes for MCS - ("F", CHANNELREFERENCE.E), - ("G", CHANNELREFERENCE.T0), - ("H", CHANNELREFERENCE.G), - ] - ) + self.set_references_for_channels(DEFAULT_REFERENCES) def on_stage(self) -> DeviceStatus | StatusBase | None: + """ + Stage logic for the DDG1 device, being th main trigger delay generator for CSAXS. + For standard scans, it will be triggered by a soft trigger from BEC. + It also has a hardware trigger feeded into the EXT/EN for fly-scanning, i.e. Galil stages. + + This DDG is always not in burst mode. + """ exp_time = self.scan_info.msg.scan_parameters["exp_time"] frames_per_trigger = self.scan_info.msg.scan_parameters["frames_per_trigger"] - readout_time = self.scan_info.msg.scan_parameters["readout_time"] - - if readout_time is not None and readout_time != 0: - # If we are given a single readout time from BEC, use it for all 4 channels - pulse_widths = [exp_time - readout_time] * len(DEFAULT_DEAD_TIMES_S) - else: - # Otherwise, derive the pulse widths from the default dead times defined above - pulse_widths = [exp_time - DEFAULT_DEAD_TIMES_S[ch] for ch in DEFAULT_DEAD_TIMES_S] - logger.info(f"setting pulse widths to {pulse_widths}") - self.set_delay_pairs(["ab", "cd", "ef", "gh"], delay=0, width=pulse_widths) + # a = t0 + # a has reference to t0, b has reference to a + burst_pulse_width = exp_time - DEFAULT_READOUT_TIMES["ab"] + self.set_delay_pairs(channel="ab", delay=0, width=burst_pulse_width) self.burst_enable(count=frames_per_trigger, delay=0, period=exp_time) def on_trigger(self) -> DeviceStatus | StatusBase | None: - """ Note, we need to add a delay to the StatusBits callback on the event_status. - If we don't then subsequent triggers may reach the DDG too early, and will be ignored. To - avoid this, we've added the option to specify a delay via add_delay, default here is 50ms.""" - st = StatusBase()#StatusBitsCompareStatus(self.state.event_status, STATUSBITS.END_OF_BURST, run=False) - self.cancel_on_stop(st) - self.trigger_shot.put(1, use_complete=True) - time.sleep(self.scan_info.msg.scan_parameters["exp_time"]) - timer = 0 - # TODO make asynchronous and nicer! - while st.done is False: - self.state.proc_status.put(1,use_complete=True) - # Do I need to give this time to update? ask Xiaoqiang!! - event_status = self.state.event_status.get() - if (STATUSBITS(event_status) & STATUSBITS.END_OF_BURST) == STATUSBITS.END_OF_BURST: - st.set_finished() - timer += 0.1 - time.sleep(0.1) - if timer > 1: - st.set_exception(TimeoutError(f"Device {self.name} failed to finish trigger")) - break - time.sleep(0.05) - return st + """ + DDG2 will not receive a trigger from BEC, but will be triggered by the DDG1 through the EXT/EN channel. + """ - def on_destroy(self) -> None: - return - if getattr(self, "_status_polling_stop_event", None) is not None: - self._status_polling_stop_event.set() - if getattr(self, "_status_polling_thread", None) is not None: - self._status_polling_thread.join(timeout=3) + def wait_for_status( + self, status: StatusBase, bit_event: STATUSBITS, timeout: float = 2 + ) -> None: + """Wait for a event status bit to be set. + + Args: + status (StatusBase): The status object to update. + bit_event (STATUSBITS): The event status bit to wait for. + timeout (float): Maximum time to wait for the event status bit to be set. + """ + current_time = time.time() + while not status.done: + self.state.proc_status.put(1, use_complete=True) + event_status = self.state.event_status.get() + if (STATUSBITS(event_status) & bit_event) == bit_event: + status.set_finished() + if time.time() - current_time > timeout: + status.set_exception(TimeoutError(f"Timeout waiting for status {status}")) + break + time.sleep(0.1) + time.sleep(0.05) # Give time for the IOC to be ready again + return status def on_stop(self) -> None: """Stop the delay generator by setting the burst mode to 0""" @@ -118,6 +138,6 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): if __name__ == "__main__": - ddg = DDG1(name="ddg", prefix="X12SA-CPCL-DDG1:") + ddg = DDG2(name="ddg2", prefix="X12SA-CPCL-DDG2:") ddg.wait_for_connection(all_signals=True, timeout=30) ddg.summary() diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/error_registry.py b/csaxs_bec/devices/epics/delay_generator_csaxs/error_registry.py index 1ea0ac1..93d3874 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/error_registry.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/error_registry.py @@ -1,5 +1,5 @@ ERROR_CODES: dict[str, str] = { - "STATUS OK": "No more errors left in the queue.", # renamed apparently from the IOC for No Error to STATUS OK + "STATUS OK": "No more errors left in the queue.", # renamed apparently from the IOC for 'No Error' to 'STATUS OK' "Illegal Value": "A parameter was out of range.", "Illegal Mode": "The action is illegal in the current mode.", "Illegal Delay": "The requested delay is out of range.", diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/trigger_scheme_ddg1_ddg2.pdf b/csaxs_bec/devices/epics/delay_generator_csaxs/trigger_scheme_ddg1_ddg2.pdf new file mode 100644 index 0000000000000000000000000000000000000000..02c2784e11411db99d9b756d06378864bad94f9c GIT binary patch literal 61625 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

t>StSHksJ+ z-BNW?hZu)%>iYE#D0A3=alGC403_hH$sZ%eq)CbjO&z3M2)n1Q>Vs zna^@ePehEH)mFh@0K3GklMu#1q2@QS4=he8Q%NcX3g!Fyk)Yc|Rl7PWcI8Jd7>Vc6 zVyig^acH?|N^yzUpC2S=ujl6_Gh3+t#E7;%(ho*@T(=CznieWbDdQ;x6GT+9XuZwq z^d@E^J!DY&g^^^O&)lOduPv&GpfePK*-5jU>B23U+xdK(pZJKAcR74jZeUuK}#*G z1D4ATVmE3sK=G6v)@?TT+kMoBq_Ns)uGp5vqEw}0iYPn>Pp@6Cj9o-VHf-mHUC2v* zEgBll4KyJ~FlA4JI#q75N2XL2(rH!RvO{*I6|jA$_~c6)#c%14EMhUjH%>vye4-k< zmUKdud!s4x3n5jd&5cHMv#4=2#doVQ2l-zVQ57cm=k(Cf*C> zD1L~#@pPz6U)?`0v^jxeSN>K&&#@HJj%!GXkPaqNaz}!D{ z+bf$JXyjdPN+l!^nSryon!@;Te60_Y3)&n(+;5@PahSGHwDwg?7c+X;L?TgU!b2fR z`Cvw)qX7oquAClw8>!~|&58q4XUPOduJ?ont6L)hh(U za_B7ufFcPRN(FNSB6yqBL*MWKM3KyEbFX;yP3om=**uyYm{yr$+Kf*iKSBda5g$ zwZNvFIh=Ta8 z^=qV`498?F>ex;Lb$%h#*`DJ#hxKtcY_LMz*qDB|U0<$YOIE_QY9;7cv$3mQdD2NP zg5>C-$*UjBMH8Nt5y)=Mts<8-z3=rA*6Ka0I#F9oex9uq=cvfrDB!3FevTyRoK~+< zxR`?#hr`b*STteo{-S31tW^w?^eaxCc8X`_zKI29?L^*ivmC-#nPXr_+FI2BGDNVE zwcg+rCY3i>D4y#~mz~l=q!}{@%Ryv9^`YpiQYRx-ArY_C}2j_<2 zmT*oiewl#g%)N){V8R_^mFk9B9GsbK&=+uKPi|5&z$i3fP|z=4k(8vqtV8LC1yfQV zLd()Ti{_Qux|{yYupp-~A)kocSfa3dwooa1G{5M$gpR{r!*vpvMYXgs+YN#|Qk2QL zdWh|Gj=sPxYi&plLAf#ChGN$hr0vPP_{tM+4*BPqyVRL0L2am z_Uc=*o&!UL>kUKA-He51q1BVHu@v9+>^q2#)RoC4e_6>lcWv3~ezo64IT*U;fM1!3 zS?gwF?4#y8=&kf4Y|hNMY9Zpa$kn0R!O&yqep-J{eJhWJ>O2t|(ytTb6xQ=o2&lZ3 z2Vl(Q5!li`S~)niym8kQ_eySRJ8A7)g?`qBapghnCo}42aIw)Wqq}UHFuvw@i;u%;+NB z&58lF?Nab|Qb!&nF~oIwz=#HNsp#dsN2Y`816Y#Np8@w9ShnBh=PC$@Y>KG6ueo+$ znL7Bd_$H@io@p@Elq=Hv^YbjR1xUP2&gzYbpHl8a=Lx@MP(3T}?RNfJn^AXO7KwpR z{dI6R_iPl2xaXUNyM1(;BFC!PMRh^t3qT!~ITZMgPLAqTfG}_9DQr#F;n!WDVF((r z^ct6^RVs&|gRj}11zX+dW(#OXF&4htXJDd;T*x(PdIrySLme;z`V3L$r5r?hrz|W< z2Bk+IOjq$|2)Uc`Z;V5_&HVjJkDN7CR%&3=beb)4bsL}X6(=Z`FQECdPCA6Uj0eG; zuCnJ{9+?f4c%49d0e$!SP!dfcemBy@;|EbE*^x;-i?iJ1Ji@{Wk6t}YxnR!|RD@3o zf}D1ieDLSt0yGLoE+Cx>ooj~C*=vgYAI#lH`B&+UlIe1uMQ)|uVPnCIINXeRzHLoc zlG=lkDYw`fb{q*dGZWg>mOq3f_RnsjzHc*aZSOd&J3O9H*y+hE$0WUZ&Xv*!`lZu$&}-sza3{KOv}rpTpH4HfDa&8%1;|IZtz(^_q=>L z4x!@{pWl*0j`ZoMK*`sz^s$Vd=#AO?s(YUqQGJ2f0SQ&Jz(_;=n&p-qhW(@ zx91dZMf9l#rq#(k$CPw6Pp(J9^;6{^F8mr=h%p|Z*|7k_oxNm4YTLajqga8LtjCjw z&ST$0muAmw(x>GKj^t#{XWnOCbDsKg{dQ{I?#H-y;^uDeNY^tweT?tFu80cVS5#QH zU^df$J5$y%zE28Lp-MJLJU+>Hvay}qO7dRk5v4o*5w}wBWifw#Bf4gDU43d7b7{7; zXb2>(G4)gEH=ZcMsu;0Nai6`U&Z=!nAPVWfW1Ohq!A?ubn$yvY9DmSXq`0nr8{TCc z%D#6O!dBvdt^6C6w-mG(*x(pF8f^SqZ&+`0k_|q!?V>)lvZ?Xy-sRK`W;W&R)bSM zGAxuU?l?#)U^~=aYBLW6Im>Fq%=h4MetdAvnNsXl2nrJI1*aiswNM0{at3VCgnR19 znpOcB=q#%yNCT|tkL)?iq8pjb4_GzdBo0|@H{N7*AC|ORG=7l`70V!cTR-J-xe%6IyZBDRd3k{kI0q3 zXLjdvzHX^Lnq-XFSAkZy$YDz`TQqF4#{J zDnUZ%I<~5B>g-}t3LQ%wGM3Own*iQCpg&ZUP+Dr5z;uf$@|&ae(Q7(U82GDDT2(rp z*Od@=gyOaH6NU)Jhu(IEdtf%o3kMC=RTmVt_enGiSS(H27$!4%qYP7BG#5GZy=1SE z$wA`YIpQwN?%ZfBioqVS4b=CxO29Ty?4mQ?6JmSt#T(wSvS*_7-aC?rS=J4BoA>|z zG=yVd)7d{8zl7V?(KftJ??bqMOGehJ0(#!amqHPt@-32T!hLq;C$!hqGmR&p!hhHP ziGo;aTr?$X_V&;`(Z`tt4jRnzk;TcU4O*uzC#G~}I0YUsskHEf#X`JT1Ox>!{pxg? zv^^@{m-)D`^XyRu4K)$r$po{i9e8Gl3;=FRS zUiqOUYqwj-5IZrIlrF`U3`(F)jxx2}xH$$_@(pd5UCq7#O1*N^JeC;3O>|pJAQm}X z(Co(4RZpO5F-N=ehMC zfuU1oP=|pLkkUu2Bp~CbG@tcJn039E(OS>?-geBESW$dWwPF`b`MYEGS=KG;9vL4b z6i6y0XzAP5V-cHQ_ZBO*n*Mwj(>VrD3j?PMK~Hq3=tQ5>PmY{yo|=lP*QpVGZE}`m)YdpHb-QsmX2UQ?=oq7ND^Mv*Y>6K41ETVO8MI8wSZ4cfHN;MD7~J ze>N@KOM0Q?K%)W~G~`gCEbE(*U3zvHTdE-P*M9=rqf)Y)6nCIsSA7k& zm8h6P;EXgv8<}%DYI3lxH-b(FGH=3cokqH7)7?OE#m!Eskk-%y&F0p&RjDikU8n4; z%uNKF~_+nD#c1CR$7J?2%sM&d-$M@$pCAz~hg{%o^T#E<7|C zW_SvQ=Ue%vzjb#7j!GW4t61V4VG81}Iat*ul!2(=&MGm$=`59=k2{c2r9JeRSO-+R}O{4ZKs2v zwNLx-RTXswEoHo)hNQgS$MZ2(ik=KFu0%sNdQ$`P?kOe4m^`8kffP93dOi6QNkXqm z6&1R(4?9Cw2v9#V6-^;;gLaoq2C1K1)xOCPEEzCHK(ElF>=NQ1?0 zXBNEg)>QuYl|6HkA5yj9zJiHPDd|I+4HK>0Tve zI?C1UK_L9i{G%hnhh!Q?k1tQHaf_-h&(l6$rk6JfYQjwE04qDFZg#mfVAQIth1|+X z$;GStD{gM|g$dOHv%9iEDALnmG1B2@7OU!FmIwV11Wm&0Edt7El=?3Z{3MaRwo>*2 z({o!d7FSe=`E$7XTl0*DiIicYSJ|yY($;uP#VCI(HS6rf%)NV-Z`CYe;a!SZ=~5mk zMRAig+4qR6%;Sf`nZT(<8^j*p$dWpa$w%t1OV;6<_SI1{Mc!}=SdAs+02}m(!|e)K zyVV5!c90ex@Ki8gezifq?B#%7(e)R*Md_eM9$Xb1HoP>wijO4jLO8LBhh#xkrFHWW zRiohMDyurBwiDgpA#-(ioS;BC%)ro_@9o)B(l8WLm4}SXx&c-XBg)sVIvs3nhB%0% zW|0>Fe3paqf@A3t4(q_K?05)AAI?;DIWzs6;7#YYtEL2|)aDsohJ(><@F1bXrqvTZ zeo;i}8VEprOL1(uE}4$x=j)}sA6Kl$N*9sq&i0_F`~3=PQmmAztC%MU_-_Gd!e0MWjh zg*gFSp`*%iU^r`;61l3UYRi%LW8D&zRy>~m&{y^+OjPEvw4rN>ek<#GR)6NrE>;zF zfv$WKSoHUL5s-2a(P11RU+he-$`K(ao(L)Ic3xSLpPblx0T0DSJ^_*An*S>X4p~ZFWjJT;KhE`ft!2als zk3@U4I;oTiiqqG;bI+OCe+Dng)|#rSIZ+rdNF34#stH{bnV~~~M?0Jw2pYOrA3{&* z88AT@p5o6f$GnT`KSQHwp?lew(i14Gz2bMZ_ow=1kX#_8pl55Ip@el$!npNCj0SSS zhhVvtih^FNRe$fWkyzDiqvna6E1MFj6Y9;~zQ0n5n#dr@7$ujQZp5k+ZR7x0){*)J@aJ8)HOYOBQ9Bmawz{6Z=!ZUv*r&P*tYB`+l|QnytW97pbVd) zIO}UYRP2r#Jq%!TP z>r5&0%IF3$Xo}Z-sU+P6aEKI7rAh#tCB#)ib2!i0S*ERa=*1(UGUmJ}O>ByttMSPF z|9mF7-BULUGDuQ7B^Cf(JAppmPU~Qm(@{R?I z@I&yvSz85T?ahY_-0kzdafvlfa9imosR~wD7>?Al3>S5j$OB#TA@aMdeC5h6@kEi9 z*kygrYssJ&Gf6WMeGc>k-YZPyOf(ed@}A+=othCM6<9C@u*?J%A zTaXaQRZnQoV^T2)K6I?~Vq}&GPk>j@gI}fs6u8#O<)mX-Pnh(wx6?y|cQ8SQ`^>Fb zlo<0@Rzi`V=SyI_u>d7jG1~PsvwqqOXzV?FY@x2PA6C8M$l=go{-n^h$Esw-k#YgS z1n99I1<&IACtwrZ_|Yq~@aUtx>q(^)MwpXuJHKUrF&6^xrQ2_AiJiffC931pC-=>+ zW?}`;iJ~a|eAjPlQua=<$34$5GTN5R8Z`#Y!wgEOI8HOf)^?4M3P2w;Spj>dqYzWN zG;b7Y^QLz{)V?B@lDIX;wLOqcRl}0mx0%aBoxG$OLDNsuq5QS-e|2ROYux5Q!V4Jp zYGvU_M=riWf#YAj8Fq(g!%bux0rw9fjvhj4EP3!GM><7bUc;_ypRL~A9`4WQT-_FUQCW{&a_;$f@tTJrDYmBdmnB{J5(^3o$TRS4xAkH~+*W-tNlZU8J#b&Ea z%J3{(bIJ|1ofV>AD~r+g(!S$4jZKJZ6)O|F^vZFcYU6&9b_W;LzWQ@(e?8`A&$~gL zcG_#3XRctYB_o?gQBB^*NjNU-hsO9z-Y+i$5zp+JrP&CX`(*RJt(LSs{cc&2K;gLP z5dDZ*@AGS$=Us5m`bd3>3ZqkL$^-{fq?XiR$2OHUFi!N;{n=#SwUQ(Ar`Yfyh-b!a zIgHhvSa2LsD?H?cs@IOpl(^ZPXN@DH*Z$cSc@2=>R)%5d5{tLRCV{V77>>+bGEDF~ z*MG?vHhd|jGwhae>HMDe-Rtc&pre=KS*d8wH)a+`HEk>+mazCbyy} z?*fuNmGFLQhAYng61&@+rKNWvR?uu{kiN}2)7U#*`);Tpyp6n-`gUw}s4jqu!OCQ) z=qKSWN0J|XOS01y6`$rCLn)X&VWs${A=b>lRTbo)_nl8CDFF|W&I&%q;c`gnXr`No z=*+L$b`EEa;{eSHpq{7?4mV1iN0Z*DHxE05D}_cj9*@W%=E_6ua;~8~`IXl)*=r_+ z!zgP0t&Z<_v~VO}ZyN!vm>7OEby@ZGBdVeDV2Fvjub*-qjQ|d=84 zEibFlTtNX0FHOsb47aWXE@Ir!D+68M9Cqdr>ZxT%$|OnzN)Yz5g0Yr4y%+wPowi(< zcGn|SYVy04OgNvDQ_4zJ%M$yGk@l9Qc=T&f>kx5_sWnJWDhVn{E2)%?bhS}VX#--w zGBZnFlp+32zy*4C+9h$}Bo*KlYeI_Exn1f3jI%XQFdTQL)T1X*e+n**xpu-Z2R}G0 zKW#n=)eA{Ww(y9k3ML@1pWhHxJyZMPo+O?kILZA71$wLBR2A!z#|>&fyvb=&x?S_2 zT&K315+msugi`LAGWt62nkO99vssU=!(3dh&>N|+#&nrXi)@xQXSz#c8Z9w8_Z@vK zEsTblH-P8qEl1bS%*i9FzjoS9F1iNn`^4igSbIHx|@8>=-TBi$;%1^zDjG+GfrJKQP;TJ zt1()_gI+=B!N+Oez+IffX=|VT2neUTpZXJ*X%2=mTdIGJCv)dOcD9ufyr)$b;4ZdR z`ypCyU)^!a8YlI@)=H_OR2S`<_=0^wjr=guKn9U{+fA903Y}U>X^GLF&RpQ8Bs59l zUYA#i(Ce*M{zL(g1a#tPwd3e!#+nJ>AP1U$HobndiMARIJa^1=&WxDlM5gPl+?-(z zJjIP=E4R7tm7dY^BiX*W)+9fw0!dRcr&&Zq(YIzxcqGMyhn?PBf^`0xKPQtr+QP%1 zd$BrACKs<>ISI2p-Z*s@?LfS=6;V zFh7gSq2(Nvi&MK|7Lonv>{4jG%qKoIsw1-o{(@oq7MNIXHT+rt>Dkxjs~X8$9s|=! z_89(964ulys&2HL5ULr*O^@!$f{7`QU2O@&?l5xtX7>&kC9;Q1t&yF-bZM$?93Tb{ zE8w)B>CTh|HgBxvj>65J@F}~emlwj3k&mut$koFF)RyZ456rRv5z&krM5w#inkj_1 z72Es7kp>IIU#9t^WHuESbsw7&xnWSFL5d-B%p{qI!Hc5xyk0((8^_WG`3A{Q2D{6-u8;Lrd<3zpY$UMP%`e*ss8? zOZoB^`ZD}bPvWF2r-1DB@}ZCoqk~KjK7OZcN3U2EFUlyL!z|<6{qc2GC`DKOUH$jU zC5qbNsE6K00=lCkV}N z2HR4PgKQ4%9kev3mkbqXW0716hP#RDKo8Ase%*DScZk7O1v3Cg=B7yx97GeZQjJ`Q z@Qtd5e$Y7#Lso@Q{ANKJrFwB(W00yvZk=WU{k%#Pz#n52yw|ibYF>B&FLxM3)fBRh{YP6oIz^HD(Hr0d;R3vCRfFg}c4Yi&Ws=bX= ztT{_%`kHqr%P3L6<_S6Fsc8sUs}%UA`_s}y@$YU~tV>RS3&uDL08{$A3FNR_xyZmnH*rdVZDGiZRs3C$>HcVp;T#jjB^nQ=L= zG2xz@PD-Ps$;_&tA^)on>22ot0Ij)jTPpE+sPygJi0eJU2CVd};+``?#dgf@x@snn zZ&PZL(7a;e@}M4~rZU(}!Z`6uioi=Zy*=uD+E7m!Nt#{9`u)Mf$F5xW`kQLRJ9QYS zdb8<;qgs8t1SXgGRCkk`dP$?7TcXD~xXNy{$vKHg)|pRmivshzuQT%c?!7(mimXa6 zyY{Arnj#sRH$~WGBhk3Hj<1UN-^m5v6&2f_n(g2WXsA>>HQM=`VZPEE>pFkY#-kc! zlN~Q~+5a#hLEg9IN@l!nQZi`ag=5Ry%Tr91`|jKdZjzv%9zuoshSk2J?Vt3ip4fCw zY(An+7Y}}?=2~L>HEDxTeL9x~7x4b-KAUt@x~Et)Z<=BQLXmM&^Q7oXvm-jxcfVhk zV>0p(*uzp7L#!nlU-G0@naSYalex$%?8Zh@?4D$c>18cNKBQgbvRn5(-zZ0u1V#CK zWS8Bmn9mfie6KZ?W4m1EsBTatu(&nMq-jljd2Wns1v`hrdNQ`u#Pcim{7So=R=EZz zWmkjN0l=hssmIWqD)TqH4?bYEK?Lo`%|A}2FhO^O0{?ruO$UXSw!iy6>4G{ypMpM{}!VCD;LOf2Dg_$B1ID|F%`GThd2f$?y z7$5a9(@HPTrUeUq{xFtPiVG$bXP9JeS8p_FRcm-8J+pOTm-X6b^GO^I(u%GV!1tc%SnP zafCHCs9*sNGsQ(ZOUyE)U+Kpsv%Q!4p3tvqs!O#SC#no4CL#WGdLmCw$JJy|A-Q^% zuh4Bn*Z8C#bagh5;5&#GOjS zORFI4U(h0WG$U(5#96hBJ{lCzxcy^()Dt<|%T^Hu(O*RS>E_$S?Sne$WIG~#ndoyr}@PvHAI9FJxpDGN;1ckPq9A5P9!~Mn+ zZdP_t7D)PBh}Ue<{+HO%=#LHRa^;%ea5{W%+Ay(R#KI3|~)L|wym9q|=hVrSjuEzrZXkh)g znoty7vZ%f5?KlQyb*2`g4XcJmYkKMhCB&3)5m zK9%%2k`wr_*(~V)e8Y3+V}{9VmvulGUa<3nwZ7+vLvGf;Q+Ff5AW1D;G%h0z@H!+!f&v+3?sPP$_O#K>ac!orkvrT|XiO?o?M%LHQvCycqlGgHw!NJHKu}U{dRMC$C9>3SdF^sUI(}Bq*_;ga(5f8*ggC4V8<4KVom+Y zOY-rnOT)}=3FUPqAhTaYaC%!Zoi_(qatz(AiB0z|dRc;b#f{MP+vz@LxEAX8j&ZL_ z(HQnC;z~`FnvW`U7pq#J(0vX26}d(ps;E1XC-#e}>Y25HDml~@B5B;H8i~`h?SL&- zaGKbhX@kHvs_#@QEraNvBu_2*TgWU{%AO8URid%|&XMG%ymBW>9#YD4_l5(UB--=$ zasI{|S5s{Rf$|<#z)3XD$2bm%XKM@+Ta-~t0RHaLo=7d1*fS4+)8L^tpE(Mn+0CXW zh+a}hX*wVvbds#Y=f?jwA-oDB@GjuPFV#F1o<__crLmOc@#g^Ct1D@V<}kt7nfqkm z=gk+HfMogONe`8?epY8e&cjN^u5bql9@{wP^7IY|UiE}ak<(D)`ujo6t~=zI8S@Qn zDbYv$2krIJzy|dZM2_OtJ^eFWv_}P4cD4vnycy0j0wk}hLcb+t&x>BhdjJGMUUTO; z84cC3A`tccIi?j^^6m?WQXP|o^LIdQT&YR2hPB!72?hI0r^IsMw} z@5fTk?U&soz-z9aIZfZYO18g}XPRCTiXB~N^))%6$1)mbhY1L|oI2ESM(6t}z0y5l z+^kY>HdXr+@ChfyOd$c_GXI9v%bg1 zJa?^mFIi}Z-+Ac#v&pA}v_d7UhB!#ecic*_j))yXxeoDqk&G!kjErVy?qz2^TqL1y zps#UPl~rB_cvA}mf`_45xC8_3Qr*WCs65@5JcaY*?>YOZ)Fc=&Iu+H*o*rV!*d*(6 zWahmt(o?4R*cPgx!X_OGY(%8cPSqINebvnoE0soEM49_dVGu6NshWcNc`o%mGc!TV zkRWtI1c_kBZTT6QIM39Pb5$1!h?A5kD$@|#*yRT>pk8|DDO~y}5DAE@Js`V#59gnW zlLaCle!BNRfdlJ6O2crKsYzYXAV$V{Ut)YyowqVks+sCoh&}YZouuP8<5kHzN-@ep z0-+VJrShW6em`|nbT<6Czn82n6i%e_5XW)O!8j?UAohE|J?-cc+2WcS?-N9;%Qn7A z@2Q#u(P(nv#Gbfc+4MBUj+u;uw!AE!EH5?@kY(+Nm2u@*+(w)V2XDL352>s+L1kd9rqqryit?IvFs4Jjw(1L zg?UR@_DPl#Pi{#<#`0d8nr*?*>+OUojXV5$kZ$`GIT>DDI)T;P0Ai0b+}!kVt7B<; z{h&K;i!IM;oTS`<-Y3LZsag!8ZS}!hjnesm_^Nc{0ypl*E%rV0YU|K!LOoieVXAu{DfMiYuvE)?bUa~KkGgE#By5Svx!-5 zb{tem!yAz;Jk^O-@Og|!Tb)p<&|Z?>WGcxOf16Hhze#2TZ4Gt*%=JA<$02`M?|L$Z zv)QPs{HJ0@(Gs-nT}!p&xv;b98<$xGf#D9AK5Q^^-h6he#Nk;lvH@3)qjYspXI5J~ z=Q-G8U2x}cAeL`@(J{I1pJnW>xCM=^Oba<}<W*_3xXa#LG|nVTaH%hiRoW7s5?J$Q9wNfkKzh3CIs7h9zR=FtHTWx+FcDK=Bhkb`J`4?qdwW(l$l;sfm+BW zp#rNiM99v3tnBPA`Xo_svn`?jt%=6Hv^=A`p8lwonmW|%A*cECwUfF!eJymCmGQDQ zbw=Y5#6sn7apx>;$@}4*XSiHW+tt(OcA9Dkr%(xy{ z>HAGtnEzauz@^;W0T@!10E|9vr1Kbf-IA&d8DlTZv%e4jnV-t^M=W zgS>OSFf6oeC(`J`R1MDO^TnGor)T&qcYf3;Y*I`KEroa z=&MW6uldfe$6o{W@LHDq>3_&+GO%-J^ASA_fiku0CPV@2BncYZ7a{3+Is1rF_A=Q{>+WGdT8TpxhSx3)mC7r18O#A2_5$oBZZK8DZ)=Vk(ek-SwSVkH$ zKH##rm)0lHp)L*-%spd!jSanXF$#QcRw%f8e;k>qI-n!?-W zJZR0!QlyQv1~PJ!%!;f>!m3>lwvu;*3f)k%-%jVo*)E}V*_)u+`qjRGsV{v$SVuD7 zMm(oM6AN*D#_$eV)--!$pMi3ui`lFowYxb*Y0F7^K{4P?*;=JT>9WHb{_gqZX}7%s zD5uYTsp8m2c{5I@K7QGG2*VsoT_-^FVxW+;hQgP1xvNMtE_|r1ApQRg!bCxkMd63> zZNqUP70~gFUrDoyifHG$YW3ZrsO@Sa7oJiK`VgBvZFh;Oergn~K+>E;t%bLgu_Hqf!}+9zm(BHWIc||OQ-&EHNf3f6S6e*Dsy;5-|9X>%n`0o0y$y{ zF82`=PiQxf%udJKjG~atf91c$ZXAK;Y;G3ApsZjx!=9VU z+almN(r@egjTHQqGOIM`P?)Sv9O#X2jl*>M%j%3;(PhFMXQ~5r=3Ix{^v|I!=(KMP zuaj7jIQEDy78lLdhKi{tE1WYom*t2XGcknKZ@;WbmCvcSP3+(oifYQEV|hN6q2^m5 z6e}zYG9w=AErE1ag}wYx)4!?R29oa}871?*ro69)-c8LM^-DN91Fm(%^olu7!t!GA z0!Ou<1kRopxfjy`F@yiZUgwY};?pDUsvBFsuUSk|Hb zBFEKxP460K?f%H258P53+M4w>XBSYmI#;uM{y>Z2h;Xsnp%R`*qp_%j8AS+RJmgBd z!R5?UkBHr;FqpPj6*bp;*3&V!oK_meRh~H>bWIP}kJwjHI7Mdd4u(?uWy-qMd&W*a z0a6F8;2`N1mb(}J5UEvt%UWLQ0punre?EK!jE?y!ODTzPBwTS5_C|*)fIbFegU|ev zaeN0QF5xYUfnw3E`;6t z!3PgiWqPny`cEy?r4&u|Ugwh=t^I0GhK$a|Yw<{p`!H>~`jzC`@XC!#z>wr%9e#>BPWD56D8wm4u@T(UXquRS5Q;{77ciI|ZOyOYYI zS3t9^IUatyzpA#9IlX;A4)cQZQs?mui1XwZ+{4}3GPX6e$>SW& zAza^S`yqkXQ^<9%Q4qM0l%L{#mz9+ef z^ONU{hz%FubmbntRRo!=5e2tDS4Cmy1=CHAot~Bh3ZfGR26eB1)q zLp7@m#&spJc+_A$GJKT~I~l;+HRm`#9 zWm!tJr%*H;w&}jtD?mLjXp72mJ&$*z&3X^P;`To^E=24~qqv&uOqPttar=6A4tL@{ zNrni%Nts-TX?CtCsTZ^PU3fSs68&*(f0r7MVVWr~-8B?NHCZ1zk5v^LUh%bYD9uMf zUXJJBl$78viXZAYIBJ5d4|e__R1uO3?%7X zrZuU2ho8p7n@8@<^)Sj2Es)M9HZOtg56e zEBTGh6vx>bA15uH#<1_l?bz#QwiHo3Ew(K*;>v^{VRpe@y_h!G?0A9{yyj#nQODth*Wo_wU}naARbxFWh`9GvHD0WJ zsEXQ(66bp)db4OQY*Ie258%fN3mTeS(lfTB=8SC%!dfx_GMCf4is44^yJ@{MH1$0*{^i zMd+qDUEs;w?pk(v{-7grMb*^n!!|So3;cYsDX(`E#4G#qyC;=|@4aY3);CiJgdYSB zUB5b~@q0W_plE8)_kBj@92TVoF?3WY@>e2BV9}TBz)fPA`aqcKv!@MEt8m>HD-sUd=1wJ@Zgk^7cXr4`jW5MY=P~ z%3b*O7oS5)G5q53G0H24!{0o;@&8nas0GE7=qD}dS<~FyS*K&7KWqX(`6LMWc{2^?%UiFAmpod~Q(5p;b>CfddQ-r0xls~dt>#w0$7m1q5FzgJ zt`vt(f7T|AN45I3AJAo}osd@ukEy)dB2;I8=!`nvcuh1i{b_dpK`ExUg$6#kJ@iV% zAkAYdIB{Cy<^^Zbf&VG!Z5h&2Ofyy)7B^C{O?U-K=Ix=R%vbd&*kEir#guAoxdB- z!}4LXfG*@SmU2eT6S@ELU?Z;Z4s=&mnl48r?W>wsV7Y)Ic+UtXNewO0?~w2@Fpn;r4}!rYZQ}re@(9y>Q7nQF*>%1{dVTHrM3IoqxK?^ z!W4)=r@j(B8-SN`I2qpxV4}MQM6uOjUM+}9LI_~UW>uCfbSl6QzF|AF?|S!C|0P9f z%!$DSKQl+y#Uj1_p|8WKBErP{f3e!(lhBK_>2x?5-jWl$Xc);1dH#El@R|;20;Su{ zUnqn=JpL=p9Bk7tYF?1Ao+qKMFdQXdj)ed?O9$|(zngTCG+?gEyV?vpF%WII9FfFL zb9m^05|qkwAw!R+-Vt3}Pkm3#c@E|pjAXGsJ?1z-$w_0z1KfYFW=Zoh?6=?23PxIh zH#soURKwH}sUMfT>883eO___i(77oqpw*Tmwqg;2pOtj^Y!9lxIp?}Q(vggV&1(?v z=_BFco!|Hl!HCp{WGyJ}T>+a6jTwBCJI(RY!H{hHtev8|oo2x^g{gs$DJ$LNEAQbj zz#IWMHG?A24?6K7LbC{O1_CPMcJ00 z315G_tA~)Sh?H8lrHa^mA(qV#z7{LYR9M#4Q-@RE-_?zFxaxPTB|~^Nn8iU0cnMbZ z_Z;gCEm@FG(2U4Tr!mBNHaGJVV}?&*CoU@Utp7f_L=g%>46hr%R(BS%X(IAK#`d^= zEhbB+9iuS&OaR1@T8s7^dcQI{C;Jkv!0_P2@Mc;`$jdIA+x=VpAvNLhtFG>Mzp`zE zYQ@Vm{QB|2^9W;Y8@J)d+3GM1+c?FfszrHS?{E5n4W zEdY{;@r`X|sT-Bl$ySy67?}^V#zieA%uOPA3P_mD1N^`~qFZ(_ISWZ^OA#%Z;~w1`SVWaS zZESo4?XZRZHJ^9rTde~%rB044HNzAY=SlM^SduM=oRe7?v`;;(Ju9P;53pIR)Hcru z3w3xm5+U`a;YL^8Pv+Tw;@@KfLG*&D36mBCMVSPdQJ>G3LBW|xLBhQcd-r_v@Dhd} z@aKW9DP4~I$@40IWJL~KXZ8T;5ncag^$1P@KqqxuxD_9cJd2c#qLZ zzk50VYE{Jr1>JY&uCiQ!X3_u~Sa}>SvCH8&diIYr(D9#>Am%KSLsPeH-KF5G?} z2^2hM+RV`sOut(gSP&Z8wJF-ztn*jXfQ|(q-ZE2O)>|``?i2p55`^%+377pyE0tRr z`tUJ;N+l&-A@IZ20U zlvV&u*)hwi1G=~uM4qm#7Qg=I5xK84q@zpyakj=uBj1{Cj+$W>$qoVI_mIrR$!B*G zU+bl|3>#@rnA&Q{Py8)wo~E8-7SW#P-8cN8mAlor<%MLBEblkc37WV`bAYv-Jd&=! z2)QUuPX%(q!nv6{3jo5&@e>1Hvo(CY@kaF`J?CXrvWjq33TrjzK>O4^Dh8ynm-mV| zYnGXnz*>~IDYt$eV*J$O6XVD8BzajmHvhMJTdgMPprj?aNu{FU?S}@)hb@oeEmuBt z&p$p2YNE1kY9@b@B4YKLzSUgG;33kxo&H$_M&B4?D44UyD?}Q-nlXloeDkB1Q<6Xh zJ_xb_dPs3%{7*M1>PTkeypE+X#(BMWKDmLU4M@YKl%v~@Qbt;*QcoJE;$j8XrIMU| z)}mZYLrJ&5PzH3i5BI;;6->oLuxY&I}r!8yGIUsIZ9fV$0rW9%5oTz`vngo6S z%HFU0li!9~$%favaLe!Ol|EOS?--JmXN6Ror14Uaqx) zuLy>>+x2P4xw$V>PdjenPYqH0hat8C7$v$rg;89_+_oMg{@Hu7v409N<%uQEHJS#n zA`afudI%`o`dba{|M!m~c_~g=@g8%y`1GOsd}#dZNtq>KRIfVw$o`d2N`T!{s=8Ix z2vA#D+B>aou;eMjx6k|sU*+xNmF>aS(e4-I#*lB)GxZW4?Hy^2BW9iKP&DNm3uDU> z5|M^uY%xThrpGA~4t$r!{zR&mERLwr^9EN03J_y7XOZ<|MLAlSJ({_YzQ_sJChi8> zKAYuYsv1;Q#&m1sqeFc5YmD_42+FMJ=vyRh9MK`@8ZnBJONhj!)E06MYG&A?3tm=O z1kWXoJN3|_Ir-^zggIwDiJZEPpYH6|qswUFOJGT2NXAO;ZRCDhfC1V%QydU2-GdLY zMUAn)!7--t~r1+GT zmi2e>vHu}+`3A3q#ifbtV-$nR72Sd)3r_)(N8re{-N(h=(bZcr*PXq_N_g!8-yRxq zZM`JuCco-iLtXq5tnq&2H8Gcx)Bqx_u3*;~#XbAq#O6`~k#RixFIcM6#L~2l&U&A< zUh_H z#W{0n_mXpF!-a9 z#fhvrCVGwfYf$PfUuyKEJ5dHxmy%5x{bkSA5%VdG%DpBJIe2G?y>%Ynq{b0S8)AoH zzSQwjb@j+7q*eb>k_F>N%L{I}%nc@HiiQx6hCYZwEl7Kz3l7ogjQ9UQPQk3c>F#9#_nqQ2P4ShLsK~|dLjcms;izH&K`p)Rm&yMQ^lWNaq^GPK_btu1T++ouNYwv2rm4)*Vf zWTa@dRq?F>*!-8dR&l=;;mFPd<}3=qHYO3HXyvPi8R+RR|J|Z zJ?k+f$F`~QoFLE3>m;F5k-v-i(bbg&OlMP=?+$0(v@0Io`9y0llAI& zh9>UR$CNuj^?1F`$Gb6wA@}!)@a;UteuzW8G^^B--nCRX^p#Qu#a8&p6mGU%A2g{z z@2zMtrqp>|KSe9Kn;6y|YvRpb%lWTV-fddcxWKY@0}dEP+Qf&f7lc+m!k|yXzcrH1 znA@zQi?Cdyrm;-S7y1fDy7P+nT-j$%5=~ z&2`#jYqP!=3-SU`=;Eax+tUvOHdccYpwAzVah<1&_8zE;{MM;s#FC@k?`62 zgkT>lP6KFo2eF1nbrN-8FtouWk6SO&RBGO5nWhPGStj=UqY5T?Qs&^PB6V$b{`o-wX`o#T= ze>d%(lE2u}lITVCpknv=NiB7~TkkKxv0Mz+aBFMLQ?;41xG0-deX8Hqoh#P*7{{(t zOkU@TD|cJoW)KH&IZojxrn^ta))&9I!>lP`J~_0LY716eo@#eCVoj4ni|-P>7iD~k z(Z?N@qTRL+Hz;O%TIcE0fy3SHQ%bc%hSAt{h9ny;QrT%i;(U}5o{oG-XECvojMSJJ zQUPT*@)OgIyq%K%rL9pp+v)DDE@a1q=oxED0(S9QupX6!-sMvt0(Y~zuQqz}x@is1 za8_^h34Z7%)S5@uHnQqLLI-&tP4=5|H{CK*>=dHv=;osaRk0D}c9|jBq?H1JC0U8Z z;@nPG?}&d3@sMW=ule~2+o(aT5yz$<8Dr@rCD%?EBjwkxp6{>)@b^+E+eElLLEk0g zg&t=_%XgSG+hr8tZjvtWQ}I&hB{v5ML0dU#O`;~Otd%9}nDf*6I0}bLdi+2vWK~No zstI@n+sUDhvpPvSDGs>iE$KMdO+oSa)#f^Q^TYX`A%mQa&Y959^lf8`eX7E7H^OwM zK13w;pMQAJiK*c6I@0K(tRK0wZ}FJPI-0(O1Z-SclS=E(K_BbX_vlsBB>KYF31hv` zA@{FsZMX5MtW<;uCz!25PMu#>sZ1LuEQfzG?MU}gTHPl=z6~~x?!U?|FI5lVHB-&# z8on^pI0t%HJI1x9mM*2MTD!~YsoxA@e6VV;QsA#?o#ki~m8NjJ`CxISTt{4Tw`TE> zu-a;(rP&VVt*It~MpyE<4ZHezL46D`t-V{Hkb2^f}5axBDx1xy4YQ^%%yi z3RU=2JNNOs-Z(C87z$!1%+M3rrL|iv3U}{9F8Rc{Xi$^<2n4=i88Jmig{`)E`$z6}#gbQzEb4n58u9Mb{WQQMCageQ@wiXZAc8(OQi~4%l?LAR6>5Kj#hv(w)+bQm^PZ>2)s7BO4fw<~7CbKOY59mNei2T7?pu!%8{5 zj{K0<#bc};#u8i0v;dj5tz1mkK~R^7!_0MaR=R%ll9bUs!zrny$9m6fjlmR9r%@Z!$|7fz4BFD;V}2 z!bd)t@oTBhB~6O0?4a7&J@FKrQ()IxJb8=|oQs`8>vp<0z#ZH~%s4K0t}5FgCRl!K zJb|z!O0DVyWoy+dbgDD#&5CA`T|PuO{zUBTNX_LGJ|w+NJ10xG;-?yzG@)sNZyCGM z{sR|=hM_kz-S+hyehm%`aPN6%(OwJLw&x&R4}}YKgKIkeo>xbyBcgw-+)pKt zuKLl+;|UUim2o&;-_=WmlkSuhRv1V9-Ivp)RCF3JOeRjef)^0I<%fi;mZi!mw!%Bz z>`09H$=`)FL6Zdp_!LS)QJ&auc`roSUsy|jn@_daqsq!Sk+$pV)v?_LBkNdNGb6V= z-=QjWmiSrOpBkDSkWmnGeje%+hlGZU^UJl|jB!YA0BS?oZ|?I0CGO2$*M$~%HhO5P zXM8;dJ`sLy{T2c+$qFZ^W1*v$L>sNbUwk+27OQIbCLJYz4V;^Ow7v7`{cN zm;G5cquM2WIra-@Pt)Fvq};MMqR|tkP-1-hy}jrG`10y7i#6y(36$r}*KR2}yfAk!@1bxMm;MWACD9@4-t`5v z$RKze;t9Te0V|2&ARGx$-IdmO(ySRB6IjZ)Y4TIK#$t#1`_sC;w${w=K+t@rN*3ac z^DaPiTf`J9(ZCdY#Y*yaTGFvNgyIfUvOAJ#>~8eXBUGJ@RvI_#u6Idh`dUQqdRk-V zt7@g*fbyjZhs#+e{%a?$?L{bmTuww{W2VxGeay3p73|-Z^@%P~^)MSV)>fzYmv|MdxaF4e*1|gjMYU`Qq{Kd(i5R^J_-uv4?14dhRNJZ(@4uJL_=Oho7t_5Kz6WDL5l{mt-3X&MwWh4Ad^B*<~PV zBXH^T$qaYhbWrwH<%>oJ*?i6&X2Gcnd;@v#!YK6-F(X?sB*(_9R#pA;$#J4*jRG*E ze|+F+c&jF<0iWa5JG)3FHCsk)D_@~YvWwskZQuK%y9xEZ!pCYh0)~IYWEirJZV=VK zFJ~zbhnvzA@3#Al8!5%cep^mPpxS9-?3%iZootl#;=4B>Zo7GbnSY4eE)|5@y93~s zDr@X|_l5UU+@g(sy%ZTz+91xWDr*dWzC2uHBs`lq4N6A+AWPz~=C-ZY+3^W=_=N*t4?NgaLUNFIsp9a;DLnyiY2>S!B_ZGhGNX=rPXo_QZ~;s7t2 z7&P~IYpqstObs2lwsW@htmt7q=YEoR=;}C%Mc(K|pba981ocm9reXZ-{n2xKnyReME9wNP5-0VXRuNYc++YNw8R)8r(!E?X@t14 z)(oN%&AOVas6ovANj!$s@K9lfBRx)}and>qB3RwZHV&PAZ9h&)@8VP%6gi?1>?)wR zAS7=_y?shIE%Q`%DgB{(;;fHZ?z&VzareGIw2~IagGAB#&bL%auc-!RP!^N&{b<$3;i) zSq}w9MsnTx%lwoBOS2w*n}0?rk6O}gGr^9nOAtb=b9`dcCj(Y$f>U;IX9Lf^1!IzRnGdXQb^8_%aGW$xNd7iNQ!yfHtvyNy zCUAYNteOdY(R6^~vtwO<*M{qIY<&{hx)y$E=TnC{OuLCTbCN7`xOW_urNj+usOB~) zEDI7=TAE3t`tK`C%+e&F8afNhN5K$WTK@Xqc5Hob7>(^@8kqFyhr##wFd1982^QJX z7NXOnyB#z}1`H&htxDI-2c>jm5ulVqvt1MX_lZbfw01`*{MuB19@ayYV{0wHT&#H$ zv9_8RCDJ%b{Nh>jJb#>;q^R8Wn(&0wv}U)7iXigG`#XT}u5P?Z_PUIC<5Jl=^MgMRHoupysi)m-odAXDF(fX%{I9um?= zED+dk-jIY_+S8ik^Slt)ziYo+n&01AJeT}Og^B=HUyG-dJ+QXZvo-ff15d`i3@8_@ ze`-@_ZM*rNK0o#YK5!Gwvs*!nMxl!?-XW4vBP4sZUySix9$&nmDQ8yZmtUsuNl#Nr1sfx}-@7?rK zScw#UZTL^dC9PudRJ{&Fg38*`n_YF*V>Rs?#yymkZ%9?S@P^fZW?9uy^WlY54x^)> zw|KR71Pd7OWI_6+4IyG+Cs*mORil=i^PBDNSa;*JY7mjQGv4Z{%LmCQUbk4ONbC#* ztQ9$=f$ptRDCbXAk3oPZ!cAE9~6%s)I_bcWn~OYTtqsJF6$!~`J3dCs{QINhD3<1xcS+=86faZvK@F>^CIRJ^0SHXiW`3-p zHXu$(?ad`W8){<8uF7iaBaByYKE?E~o2HerRj%CT{=`9h!9?5HS%K<(BE__4ZXHXh zuz@CI9XT8NbjR$Fo)Y9DTCLYXJ=WO0j*pVsEsZshLhNc=XzD(j&#DFl&8KBql_BEb z;pZC74Zh=wyp#e;WoFtvz`ABlYqfwYMhe)Z$?mtT=v4EV_oGhTQ^|}e=nEW)3JQmj z#uGHpKC*(^Gr7}HhZPB37T4@Xq0xr4yLJmssp;l+Hz&0m_V-IQ6^3mC3mLuJ)z<*9rIuSpH2im%<4KQ-XkH8|Qtt+wm zIHnU0d8}lko@0ulpc4v&ao?g`5sGe}2ltLdv0dV##I^YQia5tHo)sf{;0R?evJipIb=jaNl^5bd+JyA(xrssiEWr%YnC zD9o2n3_(1YH#Yf4ztEN&4@tQRR~jAdCX z=Sy?Id`s_wU6UINx72;hbS_V>E61E%JxlLaXS1NkYNdN5V|Py;tC1*jU0d3Iv|H#9 zr}fxd`*#Qc%9&P<^ofjoyNL0Kl2k?}%@#!Oug?S<|lx++@y z&D;+dA;I=W5xcPgCg`j->dhpX@3a$QZZJAixVEhFNo*TDKEf>BpSvAJPQ&sd!93tTyO_F7L8eEsO zPSx|8bkTNw+%NH=NRN}rUFJTLZxlZgMtUVnV(E*}_-SoxzH7JGm$8QXcb_#*T6Rd$ z<==08FFd-Y6H^lo!#5((gnPQhY@UxNW~d3C(Ho;CW6zab@X$cb^;#q(Z_8qpfLL;e zsnT2byEGddAg+Z?K{MnSwJhY!s?Dq8#;Id828bEJ**xw!?!y#CjP9J=GM`d8pT*Alu|li7P0TUw)8x-`>o#sVkAbPjM8tA!0c(I z=iYBbdyX*{zywxFrzWXoXrve-zh^b}eY1<9sIb3v2YUEC)>j%h`mt>;2Cjs1dC3?j zg9FnpX~^8Oda>X#){VrPtQHgX6C6Zoy*t$mKO}KVP@=_lk}9r4UhpPSY>*~;3v1mZ z`8BoF(21*-#r#b4isy_^fdF9!Ma^(e52M&o#!=e zRw1lZElz<%A?F%ILCswanUbY7l{iLkuT;l3zv{p>#zV&TYqGtiL=X-XxY08Fxp$Fz zDvu6oJ=23=0_jh~81vS-?JYgWdS$J}J(p$xpcjJMx_>I536 zU5<+-0#WSY`nkIf^ICBXP=Q~LfV((h&hbQyjng#%X6ir}zCk8XtW!niGvfa0Nit7X zvbQ{dWlTcpJ|1yv?~$>ItYkgsy=foVQe$tg021%T(@O5*6P_QEDLSv?xPk3DLS41# z$?TsN3HCn8m<17n_^U*!Qf~?v5PDo#h{R}e#(a_lXR4nH)|vy~s?Y#e_wSUebphdvd2G$$9}rZK>Z){TKpaxySUW5D~?jbw5|saBwO#WNx+u3&Lha&rptCvaVyScqkZu0+#4(s1cH}+bh*= zz?)w!5Jt3^6I~B>;FA$ERbM|TfkwVhW4cn3PKqZZr%jwuPxqjTZm~)!Ddl+hD7|RG z;DM%69!2d+o>15JQ=TLwGuD%|B1Zk?1JVt%z&q-rsKWQ3wF7}PXh>s@B|BEpCJ&D#zE(l-=cBJ1`6B@3m5YgjOVA+sE{)mKw_h@)5^qt8$9pw;(C@b@QWu)- z%UN><dU&<|e42Ih#pv0lzN(2+? z;5|VDB3sptG*dG{5V`!S^ZIH3`BSlh%Tc!TMpxT^4xQ2`*vazxKUt|0`87vzy9Cx* zVA|k$A}}rQ+9R5DHlKA;d67O$^`K&{)g9ir-4m3I_pT;K5x+g6;O!aNr^lJi=al6U ziE!NGq}37om*=@4@wQIsWlA=+J2;osONz{e(Rwn9Jyi?5x-z5NdSg-V?Rl3*pl-6l zHhvezsnq@aB|QMFXXyE>)Ez!4k5D??-eSm%Z234?+CcyXv_T%Q5$bPW~7NgJ&-JS91`=ArH%j4v{Va z4&Q-B)tX-qO8A=ADwSm&Et2)}9qxruMcZ6zv15%tyE~7bqCFLBIcs{K`bf7CvwKTd{|j z_7H3A;M@D(na#*VtZXt`t0QGSpo8FB%6S^ucL_jn*L3$l()bCi0J6Lge%B%nGm5R% zEVcR$3;jH4PQ!hi)*=NdSuNkh*v5dTku1;~abCu&0};%h6){V%nYDl4tV?V_Yi61l z_DhNixq>_nirX2wx@E6-v|3CKX?hI5B?aBK*`4ls+as?9!vmIE$D;Ij(%PzLML(Hr5w>#8+!*p{ zhjf*ScW_XRp9+#=s{-XUE*6Xbgx-vw*q*s0z#pa)dP5KCjp^w09#>Sodeg~$(i)rB z_Bm0Ks91M6Eoa`E3GN$ey_%jQ%f#?{gjw!8J$%sdv~YO)`-K~sU{mpwd+zUtT^s5w zXx=~L6B|rT+V=96T9fO#vkt4eAno#7wioNY$Meb+p*~cy@QDhO?n&dC?Yv>}56z;2 zguhaee}}aKlB!y^<$LcYyQ64oTLk0Ex#IHNB$lHgH)vlCu4s5$N-eP07&> zdla)I-ywMAJ-tJW$G;+e`#bGtuqA&(GPFbiHgWJwr{9-iY|aSKkM}RjL7ch-*`Q78 z!zWb1Y^_{g_79og53xNScDRo9u{zMme|7ZKV%{#ZI?U%HpL-r#MBV3jmcY%_(>jH5 z6)pB^`*BE&3)WcI_F3MwG>>^g!x=yG*GPQWA&4{nzGs*R1drm&k_RYnQGig|`CmVF zi@wT>DwA`F$0gb5AgX5r2uQ@~WdUTNJ9!IGq-+PEhT>)IyJppj@&K7!7lES6i5piZL%V*y#a!=N2wVR4Hk9;PIifhJAD z73g4&V`LRpT49#crDd9I3-ckXX<6Bbj*}FI&5G@W3~d=H;w&$wFFl8v1#%;95(2>8 zXHCXr%BCHnWu5DwW$J4Z>qT5&*Ka<%xWjc1;5LVgk!5T;NOq*O7a6ZD^vsaxKi zx~Nj_jgjQM9hNCZRQ-+F6k!et(lU~uOz*Yf^ToVkt<6XCb&4Y7Db1u^K200=$`lt* z<*$Y)(^S0LNbRIfZSk%bXRxCrnd2NWU(b+y3x3U17t%*I4r+dt`iJCjkpOFHj-a>O z+>j}Fy;Vz1G0X~DS3IZ#Wn@V9C2rEzH|UB4cVPQr4FJRxOS;HSD+qt-AD`3qkMp!E zXcJ5C%yJPokx&DV6nodHk920sospg|@b{5snCy?QUP;sa?}z4AYj(9(uY^p^7d!?) z$0}JIOF)T5c=u&%xWB7CGXlcCMf_QP?80=hZCT^>djF9Jfu{2B#Tjd#MWPZ^Af)#j zPw%at3@@lhS*sO+1wpGc1L!eO3H=m^NuFINF6cLN+S`0Z2ZrcOgzL#IrZNIW8_ZCR zd$vh9{awA*96rs#(5<2s03>Z?6*7e00>)cKP)lU*f9T+z#Uf(v+DFAFVb8{P$VI6T+nx z$I5l?tT#vhL+0@C4eLOinGWkat$~`xVPssGBb0 z7@mY3>b2hMhwv$@;3F&Bvbb7k`AUd9kr&UOgiwO3r3&;rep+W#`E-?@ix=8D7Z@WM zGd`0EoNNnM5_6NYls-bC{T^#-tj#-0YI(Cly$roLo<1q zFr(Q(yRV9mrI`%u^_5k(GB7=hb*ZLMNp4PRJUhgK#Y;$Y#(A1xq#EF-CjNGyt zz0G&1UpzH+Ley(Fbwa>W%ue{bSftn5WLFBacfpaME@B`%$@Z4N2))nFUAA4*xQOgj zp>h-VeX2&Nw^7F+8}2Ia`Ia=Jmdz+I?=+(YyK}3)3Bb`y0?*SY1*rEqKL+;e8U z;nDWXh}C}N(KZRmwMTVCFVc!&%a*y^tS)!co9NA{!Jl4V%QG@9%U9D?Fy2Yu^&JUb z+I~oM7KGxSCN^znX}?He#?j1?C&t|CyuXA%!w)_(|0E6&4V_@OnOcy(oTnv<;Lwpd z{CXEdrEw$5->L}dy2C3~i?+=trR&6|D~(uRT(YqaTI(~8e{p!%{-1e=>D=~%%x>-n z%!!2QQoE|3Qg>pGMqW`%CeB-C!hgwgjEqJi7*CKjry>DuAO0()UM3$^tG|mo=*)Mg6ujq$1WL1 zsA?<1u&UveT{4cpH6OHfR=Es}<6O!3tuR}dm9AV>f2do^V#}l3Evkn#e6Z`mT_AKH z7&qMZ;n%wJT3d}Vh+UMG#=*bK<7Nj?t?!9B4uby12K-MB+4v-)Hx#JTG+IR$u&Axt ze1*2JM9tIe8E~FMxfpLXpe&zB_sGQpv(^DBv**i_8W4jbUM}2*ZY6-MKzSwxqfIvt zV=e1ywykW{2NVZ9Kz{j%W#=IS#s+GyUG2>SXsN5~Q%Dlv6%2V8VfNXjiEG zSDbnGra300{BEAY>*3+C{VF zD%_woz{QZRoPPhr6ERtJODxD2UW6uE=*bdgP~Dfb^z>6P2Du0bT~BqI32W?n)qLd; z%6wW2YDG}m5YJ2Uf>?uKxm(85%zLvyU3hA>LWP39ps0B`?8%@ozS zy7FjM>E+(K^Jy-rNt?L#{JnavDbeUJ*^s%#u1=HG6OmWGfE_WGV|ZsRTaYdh6{irh zl2!-zE77j9hc|3=CA|fU=#Tb(`5v8icbS(Es#R+?jt!p{(9oV<zpGWjP^<6j)exzO>Nn6qtYbuq1?dH0<5A%sdb%lY{jbuokDcbd&fsfQi=AZ~ zjrb)F<5v|6zh*Bg&13;8$*gVHnEsqt_}k`Drj?&Kfnl2KGII{iPfI|>^@E!{L?MKy zZ8K_mQ(v0K&un7HEO))Di^PKU>bYyI$twUP(UjFuXBUe^<|djdr=n06x5oz_yv=35m&#AJ#&;G>b4-P^ z=AjWbx5+=mL(fhE%*aY^n`h69hvazeE%4gjOMrotl93>4y#C}86od0xC+O<-^6>ao za^|Ti9-P+!&#%jh5Wswm=CFM&hljE8`Q(sDXwR)KzKJQpc@Fi?^WuCqw-llxSO{=y z3d^)-73mPxI4=z&!n}}Jl(qLfmmA;-BiI}Jnb#fF z;S&8B)i2NR+O3aS^~&+62{D7H%D9>zU~}ALsJJzxaQy-Y76x^2Kas8_1!F z@W@v3;zB9nEXNiy->ols0&nJc8#5ndpPX&6omo3}7O|yI-=oo^v(pUj$4i7`+LE3i zAlIxJ=-z64`9BXk9g~!DlooNCFKc!p;z1>N{h2dvxBn|4?sI8 z<@))|bktvY)byTuo|@T0VKW5E-KiIzBM!ahQxUH%uO=LsnXQeZ&8(_Zbzb_5I zgMO^B6A7FN1 zcFFN@hxGWW>fUNkKI#JEUUh$npr#YH$PpDe*K~+fcja^?Mcnvt<^Q#J?I`ut_$_?X z#PTtk;dFDFbsyj&JyHqM*$K0CBl~ifLWDL3Wr8mW$Kj}H1MO==Ur9txVu!sYsn^mP zJ~zurOayzayhC`S>;c;g$beRk5NhR;Hc#{}uK5m3#dvabTVLxYT&5-n z^~i16?ni+TN!BfXSv8)}`83iv3k{tbgg5c9Zj_A}u#?=YSg!;Lv9@HB(vQ(9lU7r{ zG#%SeA8N?gCwdc_05_%V219hGuJfN7`{HS0$zY8sdc0)72UU1gxVHX`jd?C(-A-__ zj!*(>VWk_{S~Xamt3~6PlGDgLDDQ-^zKO|iK|3RB8D=3DU-DABGCGN4S>jvGnxIUu z?rGUfIw33AE1n=zaM%VD4BLj2D2?r?O_Jj!p{jJDUUhg#k2?FtxHcvueXg8xedim1 z=TkQ>3R%TSe-_Cm$BQunIm5pZo`CK|{RMmGUPRA$kyRd9{RFcY3XU_pHDibbvlp;@ zU@S^i*58uGWot0zb>~FX*h@cNSQo5E9($1UL+$slk&lwgzly*lj5H#;GAE-NL^;KW zX;hn_;B<)TgA6n7HDU9~9`oufZ4Ewa{-W2UCW?8=NGnt(IZSPR0!cR@Se?>$gWH*Y zsQ(0{56^{O=_<&Y=Qz!Za1QB(iow}3ZoGI;F#DXs=M!gPY?7x+c>V&{$XtSq%a@8c z6{3aWM$3`b2#_E}DiWf~1Eh#ws_MRI+nlsQ=HQvTQ=pX%b>u83FXdirc6ws;T=1X&pNk)ME$|#{G(}r9waBiZX}IroyL7uVXy6xFhy`|CXf| z*vuvXA6eJe)-T#d>PDl*_8ka7|%DVMiaIT#k?kOI| zO1TpFt+XLw=z2dS(DLnNKnDWHTt-yQ}O5W-9f;Y~^yGvyg5Drobos&nPx8yoE< zuE&bM-qPBZIGuR1;#yRpqsD8F8?;YcI?@7MKv3BH%1Ne(Dha?bJk)&~bzZG9dNruG zmLGvUv%BVuOk55;?&1W*TFa9;R2vgaL}F%5NN$x2usoygDSs9IL=VBwj{B3sGky9crmmo~Oc}+r6615EfjCR*;{qt0{;TGhV+cfnahh2|+ui&L zQZC9_P<0gb3!Z1~Dl-Z5n>w{vhqgsoYa&pJPDSX0aK*}S_k-d~AC~Zzw{OZQL_!M+ zia2EGYP%-^G^^u6b6C!NArU-kQ{%sCil@QP82jdswNoM02N3a)mJl96iVTX2S;Ryn z?DkBEMv*un-xYLy;@82;PTH23aTGiaO(CXe!}D+~uPcJQ<@G)UXR&QB`b0HZ=J^rY zN4%JmaN%9NYdeXJCT;0du?JluMam}mjQe}6tzI3L)fPFvr{=zSw9^5!)5!Vgskw`% z=D#P;)pZ3yR1T5`86wZD$-!F4?S>!eyt0X!79D zo5_xr{(DsGO(jv!Ir zb*DI7jU)EIsZv0Um4j|=k#Q1`!|y55Jt5B?1R`UBUod4F5qGR+w4dRszAtJoP{bh8 zy-ahV%muT&(3QbUw&}JUrwJem#2U-NNf;1pmrm|*sbd^X)H2E7qZAfaK+|LFv=UAa zSrJI>d54KKo$fH*_01zVXUmTjmng|GgrAAlIiF;{_a&RhEamID?+z;+u&@$RMw)fJ z@e0oeQEMuS-Pxt8N-J+m*VlddA14SU`qkVjG}+lUg*Il#A({2ZdCV;lc#+jnR{7(! zB4@vqWp`q`NI@PDSk@GTC+5p^rx_1Sj7e**#dKr^-Y-(}%ndNB(1(ytL7$l>6)U-} z#Id(d2G)2&*umkw>sbrGTT}+sT_f8?E>W^>02dOC%$yq5hVrXgL7*&o^SVj^SsgK{ zlc8(btlT!TjV+b6KtQGzld;ab78OhxTWZha7NH;c1S;33$^%3a1?V#UIT5RM72o75 zgPh_wsPB37s<3D`JA9nn)wN^i`>g8(Ce|Y@-6^ij%zum14Qgm^TbrxOTq^toj{vpq zdOtR3?@-$hJ@9FrI^t<$I%njNDy;(9D->Kii`XK8fj}N;+J2)C7+2i1VLaW;NmK&D z^M?EwHp1!r2iBM?u=9%>FLyz{>c074cVzXKgug<6=1}~=Ub%;nHXDYVhud}1I^oW@ z2IBp6QV;bt#5WONTTiUb6b-HRZ!)KDaTnt^0){R(&IARaRD&#l_WIkiuE9!B@5>O) z^l`;+f@CVlgNLK4J+UTDrl>@?ULu36x1o$#c^oB!jy;vyP+s` zb6T-D%GJTf?(L07Md8zA^HF5uQ9Vx_iW!&dWhYP;(JhL++}1HkuwYGmG7NTVz30vR!ls{3l1q_#u8{W?u`|y>NA9J%*-Z z4Nt4vTDMzEcuI6?kITv3I>cDbz=}1N(*9Yc3OHPCD9?&ihev8lfhnJ%6GRO)f)~48 zyhVJ*Dr-$Xxv2vE@TJv6EZFYR&U54lnftq0v$e~!#3y^DmM-ZR`J2ZY+Wb~HC26g{ zA{q3|+Gf=HYc_V?VM!Z7)-~@No3gZjlJu)LYjT{&?-}B|=jduCj^pa6P2shs=01r6VLKk9L?;pSHdpi)v99KYtu~i!-xTSmleuO&dS!^gTxbDc5R^_l8k>!qnHC!*8PToX&L(?btgl3;YIv7800Pd5XdWEgu7`AxBuJ>L0!~HW9A``ZZM>z}lMiq9<{DR{#o+;Qf@W zn}FdCnVca~sUbp&2?@5uVKt4`$ssv(;1SKL^?CSISC1mDFP|k`J|Ap&dg1?0SzA`0 zuugGHY8xoUTPQ8D;#bfHm>?_Np^ZGu;Lv$5Puapt^JZFELo&|FjUXW{Jn!i{Z+p#+ zhB3mC?U{U1|9aw|d%~?GW&fW>0h)oKcNj0Cra4|YVeNXywCz~;@R4z7$5K%f>nXYG z!GY#WHDkBWV-}U3i%`CGFPeLA{yq7`Tph9fdB=$OV5wb12(ft9Pktp7h649%M(--3 zSFCccG##r60H)OI?CxwpWBwv*i^R6-bt2&H@7nOhi=Cj6458Dic==7udTHyCP_|yE zrCW#mERq(ouCsEOq=eG^QQd9v?d4Ii-a0L3rm81&pEQ@)BR#H$Vg(=7(oZ$adD8ld z2&4dFBTp4E{7#K26MCmDKId9KY+EXlFd)L$;*Ti0{yN{%Yo!d|W?>Mzs>3ME&=e>H5?e5XQNMz778_sar);Fj*N2;l$Fw#tU zyzG}LDtQ8`MqY`#n96U$UA+2uv)gU;Si?uEK6}X)?6j7?WTgh5CjyPS=N6y7x0&RM z#|=+X`)-p&lBN?fXlHGEWOd9>C=^58U>P0c-n1bcD?V`F zv7xBqyM|xl-Iz$S*juC$fA@PcN+^Fo`%~QPBAf8qYwGFDKCN zqezTaj^AKl{8+8EIv9UgvR9BTg%oi6$kq(M`OF zHI|5e4a(@9_l;evE*sm)BT{Hq?Hs6UfZJW(za;zNY9L}VklXGMc0|p)YHAYgzrndKR!BXt(m0J zu`cSHvXV_Cdn)U(bn8@V8uZApET#Qzc;D@5(F?k#caknCLOn0D$fb?&>7X-b+roNp zJ}=M-y0)>J5d3rl>8Y# z?r{1y(QA3Ciug3QOy4QvP_Us-xV(hq7Hi3ySbjfO7aYctHO}wz0)Z>B9O@DEI(>z8 zDptiqC?jtRe9VOsPC*m8DZc1BOH|OIbgkwtI~KYUNhXbRBGj=W?B%Z-LHh3oTWW?HuwiEHDMbcF>{z!`gFJ2c^IA)| zWiH`q5X#O~vyFGav0tbM{Tb-nW1D)=?S;wixWf_JBb^%7TP=q=ykg6Q=k zCzJiG9hzi`8D`CyJH$c28;fIT=35BrsfAD!_Iz?rQ3;#C!3PWX!f*1Q78_Z9+N*ZG zK48KYVIGMXI^V8!^>pO|l#^X!&SoI(jXV*}VX?{`S5bYuz%wY7Ma=J`bBM=zu5BxyK#OiZ^s4uGiHi;0{{AKIyVlByfgtaXCyoVTK>V8kzL|BQn<94NW z(aJ7s$;Lr`&Dd~VJ1q0SK0%*~uf47OHnl>WFs7tu?4C{$D|6(M{Mh@KG=b;&%G&1J z5lAT>mAk2YS(zH^7X6(cx7U}jC=l~%sAKy7tfK?y9t>X3WGFH{Q{M_-?GxTlae76_ z+4z7d{za^HC|@|Cg|TEayTFh_gg#Zzda}gJy1z;51~p-9;%3q_4s(3!23zH9$Pvft zZK_iByay>pn9yJJHb(3{k^zk!9CGRD~c-3u4A)tbB#h4#K#!fW=lxJJNXJRPo1 zUV%KbKQ%BO>%A`2Ayio!D%XnErcOC?miG_%;8cjdw7A!{APIpE!+gLoYQ+=8j8-TJR zL1_O@_cj67(dcp&2>;UAdzuUw`x+TB6ovghUPu!^YuhMoGf?%HQbEZ@fCdV~`K^JQ zY}mVpRLqXYbRh{s`DKWmWO_svGIk2N)mB%^vxYBDCSZuzCj)t!c|1y}nT@oaP2NdU zuxB}iGaoW?t?GTU(`35^Vt5-{E5l(Vl2NlzJ#%ImV5^*r3xxI7485z!}pMq?-2fUGjWUgtQs$|G_9G^O(m0ys`oi@MG`+e5kbD_cg z?AHN00yu`L8z0A281;b^?_9m-;z3Yqt1b*>>d3ustHf74%TqC7s$~Fvc`gm#Z+)B2 zY!Ns~Dp!|LJ)LI)`uP$O&p3Y#Zhb5JOgGYOpKuq7vMcaxfpuWOz_bEIT(teF;J+ZMWs-LV$uKXN5D*2xIxJVTnC_o- zf_f1rSvM3(_ni{X7V)Z^fRVSPmC__=`0w-8-39i8*xE-9}?!n#N-5nlR z>fKj2AFn@Vy4S3!>Ah#FW~yiJy~>UHu5nar2IVto)HIh`Fn}o3U6O!=I05y17B*jCjCMq^t4Nr|?rY?KWQzd5 zM34Vn2CzKokVL<&z80aALyNl2a$7L4Zh>45F3P4#O%lOoc8sAwCpMa{65r*u)62-j zHH^%1Z}+SwZ=14#&knlY8plk5%~+bT9f9hOal1WSqpnDJejfie$UEP#>b3Slup@WV z&2{&K6vuY}@hv*K>UoI%v#$QAx7%a`w4n%PcW@7 zdalB1u3ObaBUlvq&0y5^(LsFSU+cafV4wgkCj(v164CR{dE``Ky=kEFS6qE=2sdpzw?~K%&*C zypnU+e|tSJn7T+RaD%M6f$i16IJHOb?}naO)CQ-t>CK3w_(EwB-A$?9G7JNK(z+D7 zyUcN6py~8A?Y`T>iT$C_8~-<<6YRsj?C9deC#$=zGfaX&pU#f*kD3^g$>k;loX)bp!(=XY#zV;T}UOyz^4ibayu%@k@ zZ0M*3ihxD0=Wn-GZKjfg2yBTR5w#e6I7oSqIc;f7vu58m@>F6UC#K6EvXiytw>MCR z=#*w+AuBuJh-=xni}w$uSA`u;(ZL^Hf(f9xajENO)(E=3;`9+VM9Y5`Q!(`(O`qji zrh(rAcLHxiFfe$;)=%|Vvtgmd4xHdEWwhQWZLK)#+F6lfdH=d)e*QogOr-gJ>RqVm z4?;uT?2Sy6T|g1$B|Zc;f((1Yrdc8y)v;UgA@&|*6shM}4zIUYv-~!S>&-jnAXx%l z%e^o*0clFcx@LTe6$`Xx#9IVmqc90P%fk=}L6U0*yywV=T39G|z&R_n*{Fs>@=g&k zFFgBXZM|ND`XrH9Qo=;RTA7j)cRjaiR$(FC{eyeWtk3{3kB+|1yA20cSA$}MZrF5i zMH6C+8Kt)|=cTz2U*S7R^fr$H4|yyF1JPEF@j`AF4c~&I$Ac%`OIc@bBKWy6QtL|E zoY7bG206ybeoiiQxaBA;l(_}K*W`#5&zgt%Z)f?N!5*=<{BxVtS08jqodX1z*~~vE z<}VL@NA!nEcE6id&c%>I@xlgB&o90V1htn}Y=j(VMmUi$MkmIyDyhzPc~p?BXc2PT z*dx*=!u4n=JQE&gPoj#g;*Gis;pZ%>c*81Fp3DwzyFn%WRtOS&8ha5)?EI}DB&_g9m5hhe}K{48SUJjkHM3K(Zs>xLb(4K8S&=*h~YOCNxjB* zF0zj;2L)e9XSZu&A_tp*X32eu`2;RS3~PJZc6;BLJw~JP_j1ca)D3fDe?LT95bo|w zWP~`NGAS95u)Cy*Xt%wA7*8HZDu=6w!zmDe4UBzPrV)xoFl5*Jx>gj9ML72rMS9-& zRdnPVr9WRcmt$Ds&bjLG2&H@1uiFPEn$1=y7Zp>=xGz?_6c-=C?kwc9%u|+isVq$r zlRKhM-^T~f{gcK*W>EajivhKK)p;JR zJP8kUF6Pd`(yph9)z*qyq%ZN}3zyuzwLKgnx;)IqcT`EZ!tBw`w`tHiZa=?szN@Y< zi}EkYMD8W{G8L!C7;XpVP$P`9?G8>KYg6U?82af*SYs)sm^2@%J4g~&SH0Hc!;ZT? z1s#@S`dXo9Zcn}TM*CHXTKzTnO2d>p0tuPgB<+w-ixO@RTc?6ZbDXm5wG=E^Pnx4b zQhCG-X?@xe-R}6cc6fc+W*r=-K~-tuA2+^;GSMZ~=dU6@JM#mT_VTXDU? zyWOf$@oLqlm;*@C3)}FW-R@D>LbF)ORfC)B=upY@NW#qQVuz1)9Dwo|RT_$hhc2ut z7o?TNDK@{ISteg%qiQX*fq?S3q0YSn--G#_z57hk0!suPD`%bTxrT`g_6<&@BB7l4 z5AtIJ?7{t^se_&L2TZle*t&Mh3k5jL1=qukf@~-9U9~CviUY_ai+zpw-NkdM89JX9 z`vt~7rK*B@K3s&cdQw(y?V3{M8|Tb(c=jrU!pK@g(^xj)!y^u9i$)pj1RS!fKDBxT^$}i|sRQM3ZZ}~<@%xkc`l(vL~SO9;XSD^g%+ zb7vYX8mX_t^98p_*2K8oXCa9w<-8qT+M&wPTYMubr{_>yq-S7|xcoW?MZasg>CgBS zns07Kz8)4hiP}?Mm`gPhv;nI4QxMuYHckDudiKx7i0=6c^32sX4-eY>Z2)9^W?_tw~9>; z9v(M2&bJ0LgUL=jT)e2|fHonc*Tqygc0Qy3O`)SlD~2=ru}tC@Yx&PnCF=dz;o;yT z2bg1iwcL=!>fWZsF$QMOGi<385fj1)T~ycim{1k!Snh#jxQUP)f2MB`1?=Xp8E`z~ z51em#qSUeADbXK5g$`|VJLJ(%RS-C?-$aC)?ukW(H5vSF z)`dZ}ac}sTao#pG(CHNlqz`~!KV_aQXn=OFB19ODM|O`{!(RFX4#!1 z>*5p$_K3DVyU0d#wTJuc&|-WO!AhS$fnY-lLrSTPe&rKDbp;ZyQ{m!VYK&VoDU6n_ ztD%+c>VVPuie8A7`BIlYh=@=ir6X!6#v><_6Pc2(h^NE;MRF&6jT;%qdSOGdQDXR! zxNkz>7>^UG>j0^1_z+JjTHEnDmZEX?NcTR%ksUENV{5vG5?EtzVQEfvDsue(qDcj- zX4njykmIEuEa{{_6Bt>wK)=9W1f4aT6}oO4utNrVv>Yr(dd7Y> zGO%IXJckHh6dm4yzep=tV`+O1HzQ6+#?ov^)C>-N2lTUz%OoDF$r_u%f^^S+FY~+z zGD%+|V%k0C;(;`sz&2(G;c=q?2nOn&u<-JDS~a;ugIlub3!csKV4SrcaCU`kqK?)w zEp!zMnc&qCkltPk*|Mn1bFW!g^rf-oGe5;SuxiKtke)-=Fdkqd#!BXeMVdX_pAY-? z4UZE?0bO%q^sGl2BjITAs8m|hJtX(%D5cZCz@UUvBuR&d=Xc6EsU%|G=C~lW*_rhX zhuPnu;w?z*TPzX0lv;-W=_wF!p>H~t7Z^J&vz;oRJA)@7^1Ba)-JHmt7bsc6uz3BL zA1BgrBW&*J(41;Sf>#RE57-@@LEs!WimCsA+Y+D=MQ?I!uxv4xm+(Mg@kfo75T2m> zv2Vo)sZhC$U+D)Q@@F{od2{<-|ISFuRyzZ3`1eToWDhhUOCOVdwpLq;@=BqQ56pd$ z-%nb|7a=S$SvzA5A@*~&C=F$d+U5ACCH?v0F(+vOvF%KX4bPapA3rkmK^yr5niyVc zgGegG8m6z|ph4m;GV9)$mKUjr+Op$K1d3Qb8P@l_k@4K7{GHdsd2fsQ89yANNLWC~ zVvMg}O`WO^R=KCf)=WZNWrhV+ID-$FV076zD$*bPSB1WK*QR9e>CJX8%FtS5)NAFt zdRN(EQ8M*hk_Mv%nKe>mvA73Htx@n5clwEG^JYmJC3ZSsa`SjgO5C_W_r_PNx>hjJ zXTh#i2}tqSOFgC%bDAPsd|Dp#Drzd!RKjHQp<-!RH4JYMAzv(mO^z>O^gTLq*lift_4$@XtP647Z=|e@-wSf}7 z>>!6eoW4qLopxKeIO$EfSagTn<85_sM-}o7WN>poI3XL4@DxKfE-f`0k9h2ekODBw zHXuH>nrqT4$cZP;b@l|Y36d>|#CFi0GivxZsK{7iQE3yI!6TOIIa24opo^3*2p_h; zqX3x%;#g{_E3^GD$Mo&s@qAnX!p4&x5|}wKmYKV|Z~1Fp|Ec}mD0zJ;KuDvrh(j)aC{5L_X`K}ic_>C{E> zVmQAFVdS~GWVEFmYrG2`nUIpcXu;&h`k00!DZYCkZ%c5**Eg#&+#b#tIt(X+6~ zAX@tbu8i>{U2Y96v-R&ls@aReT4OHFee$(l734H0@R9nwFWChAYq8^>N)X)#Itu3{ zo-{5X8?taT8G2UtWR18Qk$kjbcqfDB;O(V35MOZt(DT6Jq%K@qfi)2AFO&9pb`DBQ zlDfZoLzKRJ0E7;s?Ub8ppEW&=$8lZnL3Y)Jyd6sP+=8tI*%f3TnS+1dHqO2@o@;)v zB!RzBmZm*DdEEhC30^M0+iZ6LR?UQM~f~E>hq5cEP2;bVQ`M8ZZh8F-&kUADM5gq_$A^sU`#hn;^Y& z`il@E<)ZG3hNR=u$iWYR0nD@}w>P(-X2mLK1m)2ynkOI- zLg}=vWn3BM@|Sp#AN6aJI$~@4XhoCC)nP{y;8m;0af)S%NqsI(i6k-+8hBC~da7nB zmQBhpF#rTXRUeMkRx>)u%*y9>_*CU-$Ue|0CKKJcDgEn&OPX8=>#`Ggt@2-Lgkf?{ zn-L|(tc=fc>()7RE&O$p4c$dW6@Xb5l5SkZoHGa_Vy-!e$e3rz?G@7!+^ehHPMjLf z@MB@tU~r(wRwc~n_p5W6zl67 z!Xo$IPT>OF^FF9D#frdXuT zyn5qkR=`fqW7cnPBJ>>j6jU8;3+qp_b$Mb)c_U|{M}GYjfdghim?p3@@|RF5?3+tL z%geQR^-nt`WFT%UN|hFgOD@7Z{Z^`YWMDBHMN|aMBnEQMfvth9v`mGJ>>dykX+WjZ z37beUALKzDqz03Lve2z3B)R05Vj<4{6hN+iQL;f~MNLj9w+#RI2wy4wy9Y{bl*OXk z4?OyP>T>o~i%Je?(Rt6`J_p64XizmUH~y>#Wtujr6)~kw_VQ7U%p7Dy5jK0<_*cn6 zc31;nPG#-4uqMhjMJLV?PY~CR=`xXA_QZ!N$j=ex@@i>s zF1w>MHOWPVN9nl|lc#6{^&Dt|1Fzp55KeQX6P+-7(Zl_#N^lkMlRYd-Y=FAKG1^r* zo^V2u$Bt2fuWD{3mb|763V$C4xk7^mfVIzOKTYo=75t{_6>b2vpVtvc22K}(t^p~B zD1dDp|8zJhEwAIp3h7NiqCXz8vG_zP>utS6Pt|giOc$UWzTI@fiwy-(9NhBALUd*# zSWZim#1f8GPj-N_qvt{I_Q9G9kj#5&i-QZ`yvodRF}gWCtJ zA}Z&SdHy2LPRxVcfF3fs?cHf3I6?F=eP45{!~|m~crEdr0H2-18gB)z3cIk(1Z_x6 z^ov0|&c&-|oZ~1fE%(ZYYQIc_xq51Jf_cShv<$|6z$AkiH8VVFmqc1#c5EYNo>_8OnYhR^M}4Qy$?I+ zk-7-Ga&G33kJ93!@LnTAp*Cc+doH!%0$Qt^e^J53UIWEx6jjw7xUYK_E-eB}%g9?T3 zx)+bo#+6%z!HEkh(>S1x)Zqx#d#p>LCv`Or$~Rh-RR_h0=3-TJZv+s-ah8DGd{gew z)7sN)5{l|{S!^i`iidf9o>;-F5CwR9%7w%xQC(5w!DQK%xNg?cC1hDfjE(pqbPhI~ zwFvg5CD|Q0C&+^`P_Q;G{fW+wvjvR!S?EY0{Z-AU^^x`G zIAm+k0W*k7{nQx)>_;4MTnyYmytve4QqS@N;kEkB??o8>XvJ6gpW-W^ zFX*mZY&cZ5371;FW(Jtsb`}#`Vl-8m-rd+B9(iFzFHPct;5F!-nA0IM*{&&=UmRZ< zy~E>=mAzI{5t~d~Uqzdn5x}wJF7sp&HpW!fVravQG%1%fYJ({Mq_K5}EnzX1Rg_ddR=o3gQ8KRRem&NNWm zI}kjmPmH$l68?#m31v?_K&?bOUi!P-O`u{s+=_p=(4+`=rL$dqv$?}qx5w-zq8=DK z>u%&hraDjZHZzfg35palh6=>WNvK&uSTBG3RgI;J@Do`d z(KzPNajd@sv^IdkNn4o&NA7+4ogSzWy*K<0XQROJGI($qP17M6ab5WZAB4i1da9y+ z&-K;f{8&%pj&L^ri97VP$bGpa+JvgVU^sMfgHETV6PDEWBcGQB_bW=SQO>YNMHm>wfYypb zOb(g;Qco{&+m3=U`36KcO3`$xqY@Qo0Q?&~!p744fwnHjtSG>(RsSdHzS`xl_40cl z-+^XQtte{j_ek zb2(VKe_BbrrlT*bK!D9xewNJLh+y7R1RKKc1&K&{ZD;zIx=?E;kkN~eKy5|DkviDI+=r6|Edt>_8-p|R&S{wGyP6#u zz^{)aB8<>$YJLKPB#A=3Q#?n3)$&>0cWSL#Btv_ndB@2!3ncRR9`|H16Z42@G_CGn z)Gy<2eFgozoWv!-81hV!K5sNV3g0r3s+Flu_Rd&k_vO9w=PzW$p>IXHE`|*YXr|0@ z3a?9j!G0iGME~`x_S?HiCzR*<8tM120VHtb#;?reCJ)dfluyKBf*(A<73y?rSp=YA zx4GBorq#i5wy@g+!}tA4u|T>l@pWpS;C0j=<#!1o$;+Xe{KI`;Q<#G zF2lPcO&H_W-28UGp$i9_K?$Rzo8#*|zm?JJI$(11?@`(fQao-OW%*|bJdO>raS+-X z+^m@gVLGFG@K+6Gj(0)0*0@EOH;FdUneAg1BxM9u)P?j_9zaEMXC44XG?sPe}(myE1_dI3-{f0~?cQu4PYZ7cQ6xsOp@P*Va z)jU}f?wNeycp^`UvZ$Ew^ffwZdG~&PGt75&+;mrCNDnDY_$_Jd*D^NDSTc#b{%hVa zP^1b+EEo(7rD z$dJurZKeKY(gFJ|_BSl6ynaHHefwZK?`J}FMuC)qACtV+#3`E7Auv!h5#|rlaf#|| zR2P!z{Z);ut9Y!+mJ~@By>A13(u|)|+;)G4smGCIGD8R$?45zq?oDPnJ_G1CF7ev zf2xI9@}B>*2?&YQj<%W=p>WEh*GSE#5yav+&(761D!&+27q*k}_ zOx|~!2`SogI|QOr4j5_sR_8$rk$NXwiPSndbpZkZ+WF8 z2BnCN6uZdk^D*ZL2V3^Ogh6KRO+-ELm#?1bh9FeHlV}YmN+KcSf79zr%2+FI-d$jd zt&PDC$49+nD;#nrLw7yrFUc`Qglde zZKTLNBO4b3-@GQ`!cW~z{z@qvt9x%|2o=|BaNXg72TQP7yoI$!+8Z0$kNLsZ>pI;{{>-@GNHLF&#NThZeRM2`e92gBQO2FCC(XP|6X!r6TT! z0hlvXj!Rs^SVhbimv$Oby6Tj@8E43^)rxTa}MKik6vEGmXE?Zp9=nRYszt!MiW zFs(#jc~1CxF zpJkDQ?K4_C2I)wyhD$@uy-!W@K-nfI6+`7@I*@h1#QR(+_M%)}`&u}o7>4ZRH;c?m zVypmn4mNlxPv$9>Tvbbt<#y}@RXrQUZe19}z-#^0Sq*LK1>odU!Gq1QZ0XdxMu@Rm zkC?xl9%dvP*snjIiy}&}%tAJnXP5;H&U0#iUe(gYTOZxp)#8B3ZQPz6ara$_=mBIz zrNE2j$EMz(N$)BLinhY~`Qe!FotsOB`pNv-DdX+nm-I2#lw<4Desvpd8@bEvHEpi; zGJZezCZ0y&i1Vx=LR#iL#oeCc-R&CoIDIiqesdJa94oCh!b(7x5Y`>`dpS6);>V@i z+1*CcO#jZ^^!)n>LlZ4bKQK7S%ZVtQ?j%5B!?)%cd9!=C{C#ZKrhn)&f#ePgt~fv{CG;Wgc}H9qD+Q6$ska>Y7C&ly?rz2GdrN zo&G1r%rnX)XkXz=D@%3fCxSz+(rND`>@J)kdH>+5vc1fw6X)(77%ra2VZ~OO?!|Dz zc4Dg`GwTazViuIX_z?8|nb)Bo<&AUPb8BmS$vMw)WNOJ^KcNR&-oAL>CoJm%nUhKQ zT~lL@{Yv$(2u!uLDJAGwAgN*nb|NvqHA9VxtJ{Gq?=c8~BL5N_;#HJ$J8`X@Q+q<^lPtSs7hxAcbc2+;H0 z?Tt#RRO3f3#j!_&mEdD=U?;sR4*K=he^!Llv2pg^W0$l_XC^YrKVZ2(c{}_2&-Lf< z+VJ)qzR0r)2+33o< z8$a}E>NM?Ux9*6bd|htTzPz~OwMirTv$2nx6W)(~auIKg zhgKEc$k*lfpJy7LjFt{(XAyUJ3g(XQo>x~0>8XxF)slKSk{kZjC%vzh_X?+P*yFa2 z2QL7@-tXj%_SXO(PXS#s2Vn;4ZQ+W5H+Yo5+f{)iGkSE+k8Hjkf*XW34&a{ayo73U z6yq{lp%dm{J4H~y`EbwTj6cMhT%?Er2)P0qYgHt~#c3|eszST<+U5N!_KBwtjrxiq zjQH1=b+joj@G-uly0_d%zPXzvB`YP$GZ(fCnHJCB_NEDR?5aA-wGeg`Tri9PLm|!V$`u;RkX`BX5P*~~9+h|zl{L5u)&XxAAB)4&$a$QQ8vYS^7x(Lsu?|2h?+(AAt zl`BujzSNPgdf}`is&xm$`o!}FUQia@q9?|vPnlcVuNK-qIsuhv^mPa4%)5*@gz|LS zM@75~`J0yI@PD5a)?(5+Ap>S~+yaZM_e|{!Ybr@1s!z%1R35!H}naM)SOa2EOXCzeI3p z9{Ubsk84Gq1-e>eL6Dk7kRF3aH(gKwE@CptPI-_c0p{hCeRIW{L z>*iMrt$6DJ7lJ>Rb3%UT9AoTLV}lUulQrIVgvR})`!oxjd$=)S^T;zhc(P7X8I%Ll z&e!Y7Idr((0t3vs6RTlN>Zh4z z4d`6Q`!?7djrDf<+`t^=qFUJd2kLXnnGGZ%#k_PpfFz`uQ5igfh-x4?xqm>zo*E zM^ugAjVjb+QqR~p%1^Bat^NqEZKoyetTR5-EoNw+CV^ljUut#0B|nVh&gVHN12dz( zF+_$RvG>ECAf*v(I+$H2j?krYRrXYstu!uA`a4~p3&P0w>NSYwq$CYxG>QsYFQ@xW z&26-0F@!FE#fgbV44JHb5C4AjV6WOi_q@^hUg^)=z<2aUs|yL}qC`%*gQqG3?^%`e zngw5z%F-+USip|x*dEaxTW^^?%Dcy4H8exMZ&EDHk2l)!GvvMO9}9q)$jpY<$oE35 zlMPThT8;ku@p!a?4ae(~-M2!to&!4JoFtR$bJ(CUN8P|>z>bV?PL)%90|EE8DkjHTyD+6OMNCO<>R8e*a7%R6qp zY;%Yo=)RC*tveeGpHflpJ2~$-95ML3$e+b0%-!5wEleE#v+HDPi;Td=OUg?6pFKTN zp3kl-DJk23?G65iFEfT#*q31(6Y0RGpmMNk6?o zs`@#QakO+I<@oP`nhnt6^JcNgNazW2uu1T6^NDgwvPntuh_msCaq)7CinH^4UX7hy mLV{iR|IaWG7Dh&Jb2o8y|E~w(b5^{Z$Ou$al1fs@2>%C#eVHZz literal 0 HcmV?d00001 -- 2.49.1 From 809a8834fa52f577ec4faf6f1b3a316d561062be Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 15 Jul 2025 10:02:25 +0200 Subject: [PATCH 5/7] test: add tests for ddg1 and ddg2 implementation at csaxs --- .../epics/delay_generator_csaxs/__init__.py | 12 ++ .../test_delay_generator_csaxs.py | 161 ++++++++++++++++++ 2 files changed, 173 insertions(+) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py b/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py index 04a1eea..beab8e9 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/__init__.py @@ -1,2 +1,14 @@ from .ddg_1 import DDG1 from .ddg_2 import DDG2 +from .delay_generator_csaxs import ( + BURSTCONFIG, + CHANNELREFERENCE, + OUTPUTPOLARITY, + STATUSBITS, + TRIGGERINHIBIT, + TRIGGERSOURCE, + AllChannelNames, + ChannelConfig, + DelayChannelNames, +) +from .error_registry import ERROR_CODES diff --git a/tests/tests_devices/test_delay_generator_csaxs.py b/tests/tests_devices/test_delay_generator_csaxs.py index 2274c5d..0d6e5b4 100644 --- a/tests/tests_devices/test_delay_generator_csaxs.py +++ b/tests/tests_devices/test_delay_generator_csaxs.py @@ -8,14 +8,42 @@ import ophyd import pytest from ophyd_devices.tests.utils import MockPV, patch_dual_pvs +from csaxs_bec.devices.epics.delay_generator_csaxs import DDG1, DDG2 from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import ( BURSTCONFIG, + CHANNELREFERENCE, STATUSBITS, TRIGGERSOURCE, DelayGeneratorCSAXS, ) +@pytest.fixture(scope="function") +def mock_ddg1() -> Generator[DDG1, DDG1, DDG1]: + """Fixture to mock the DDG1 device.""" + name = "ddg1" + prefix = "test_ddg1:" + with mock.patch.object(ophyd, "cl") as mock_cl: + mock_cl.get_pv = MockPV + mock_cl.thread_class = threading.Thread + dev = DDG1(name=name, prefix=prefix) + patch_dual_pvs(dev) + yield dev + + +@pytest.fixture(scope="function") +def mock_ddg2() -> Generator[DDG2, DDG2, DDG2]: + """Fixture to mock the DDG1 device.""" + name = "ddg2" + prefix = "test_ddg2:" + with mock.patch.object(ophyd, "cl") as mock_cl: + mock_cl.get_pv = MockPV + mock_cl.thread_class = threading.Thread + dev = DDG2(name=name, prefix=prefix) + patch_dual_pvs(dev) + yield dev + + @pytest.fixture(scope="function") def mock_ddg() -> Generator[DelayGeneratorCSAXS, DelayGeneratorCSAXS, DelayGeneratorCSAXS]: """Fixture to mock the camera device.""" @@ -126,3 +154,136 @@ def test_ddg_set_delay_pairs(mock_ddg): assert np.isclose(getattr(mock_ddg, channel).width.get(), 0.2) assert np.isclose(getattr(mock_ddg, channel).ch1.setpoint.get(), delay) assert np.isclose(getattr(mock_ddg, channel).ch2.setpoint.get(), delay + 0.2) + + +def test_ddg1_on_connected(mock_ddg1): + """Test the on_connected method of DDG1.""" + mock_ddg1.on_connected() + # IO defaults + assert mock_ddg1.burst_mode.get() == 0 + assert mock_ddg1.ab.io.amplitude.get() == 5.0 + assert mock_ddg1.cd.io.offset.get() == 0.0 + assert mock_ddg1.ef.io.polarity.get() == 1 + assert mock_ddg1.gh.io.ttl_mode.get() == 1 + + # reference defaults + assert mock_ddg1.ab.ch1.reference.get() == 0 # CHANNELREFERENCE.T0.value + assert mock_ddg1.ab.ch2.reference.get() == 1 # CHANNELREFERENCE.A.value + assert mock_ddg1.cd.ch1.reference.get() == 0 # CHANNELREFERENCE.T0.value + assert mock_ddg1.cd.ch2.reference.get() == 3 # CHANNELREFERENCE.C.value + assert mock_ddg1.ef.ch1.reference.get() == 4 # CHANNELREFERENCE.D.value + assert mock_ddg1.ef.ch2.reference.get() == 5 # CHANNELREFERENCE.E.value + assert mock_ddg1.gh.ch1.reference.get() == 0 # CHANNELREFERENCE.T0.value + assert mock_ddg1.gh.ch2.reference.get() == 7 # CHANNELREFERENCE.G.value + + # Default trigger source + assert mock_ddg1.trigger_source.get() == 5 # TRIGGERSOURCE.SINGLE_SHOT.value + + +def test_ddg1_stage(mock_ddg1): + """Test the on_stage method of DDG1.""" + exp_time = 0.1 + frames_per_trigger = 10 + + mock_ddg1.burst_mode.put(1) + mock_ddg1.scan_info.msg.scan_parameters["exp_time"] = exp_time + mock_ddg1.scan_info.msg.scan_parameters["frames_per_trigger"] = frames_per_trigger + + mock_ddg1.stage() + + assert np.isclose(mock_ddg1.burst_mode.get(), 0) # Burst mode is disabled + # Trigger DDG2 through EXT/EN + + assert np.isclose(mock_ddg1.ab.delay.get(), 2e-3) + assert np.isclose(mock_ddg1.ab.width.get(), 1e-6) + # Shutter channel cd + assert np.isclose(mock_ddg1.cd.delay.get(), 0) + assert np.isclose(mock_ddg1.cd.width.get(), 2e-3 + exp_time * frames_per_trigger + 1e-3) + # MCS channel ef or gate + assert np.isclose(mock_ddg1.ef.delay.get(), 0) + assert np.isclose(mock_ddg1.ef.width.get(), 1e-6) + + assert mock_ddg1.staged == ophyd.Staged.yes + + +def test_ddg1_trigger(mock_ddg1): + """Test the on_trigger method of DDG1.""" + mock_ddg1.state.event_status._read_pv.mock_data = ( + 5 # STATUSBITS.END_OF_DELAY.value + STATUSBITS.TRIG.value + ) + status = mock_ddg1.trigger() + assert status.done is True + assert status.success is True + assert mock_ddg1.trigger_shot.get() == 1 + + +def test_ddg1_stop(mock_ddg1): + """Test the on_stop method of DDG1.""" + mock_ddg1.burst_mode.put(1) # Enable burst mode + mock_ddg1.stop() + assert mock_ddg1.burst_mode.get() == 0 # Burst mode is disabled + + +def test_ddg2_on_connected(mock_ddg2): + """Test on connected method of DDG2.""" + mock_ddg2.on_connected() + # IO defaults + assert mock_ddg2.burst_mode.get() == 0 + assert mock_ddg2.ab.io.amplitude.get() == 5.0 + assert mock_ddg2.cd.io.offset.get() == 0.0 + assert mock_ddg2.ef.io.polarity.get() == 1 + assert mock_ddg2.gh.io.ttl_mode.get() == 1 + + # reference defaults + assert mock_ddg2.ab.ch1.reference.get() == 0 # CHANNELREFERENCE.T0.value + assert mock_ddg2.ab.ch2.reference.get() == 1 # CHANNELREFERENCE.A.value + assert mock_ddg2.cd.ch1.reference.get() == 0 # CHANNELREFERENCE.T0.value + assert mock_ddg2.cd.ch2.reference.get() == 3 # CHANNELREFERENCE.C.value + assert mock_ddg2.ef.ch1.reference.get() == 0 # CHANNELREFERENCE.T0.value + assert mock_ddg2.ef.ch2.reference.get() == 5 # CHANNELREFERENCE.E.value + assert mock_ddg2.gh.ch1.reference.get() == 0 # CHANNELREFERENCE.T0.value + assert mock_ddg2.gh.ch2.reference.get() == 7 # CHANNELREFERENCE.G.value + + # Default trigger source + assert mock_ddg2.trigger_source.get() == 1 # TRIGGERSOURCE.EXT_RISING_EDGE.value + + +def test_ddg2_stage(mock_ddg2): + """Test the on_stage method of DDG2.""" + exp_time = 0.1 + frames_per_trigger = 10 + mock_ddg2.on_connected() + + mock_ddg2.burst_mode.put(0) + mock_ddg2.scan_info.msg.scan_parameters["exp_time"] = exp_time + mock_ddg2.scan_info.msg.scan_parameters["frames_per_trigger"] = frames_per_trigger + + mock_ddg2.stage() + + assert np.isclose(mock_ddg2.burst_mode.get(), 1) # Burst mode is enabled + assert np.isclose(mock_ddg2.ab.delay.get(), 0) + assert np.isclose(mock_ddg2.ab.width.get(), exp_time - 2e-4) # DEFAULT_READOUT_TIMES["ab"]) + assert mock_ddg2.burst_count.get() == frames_per_trigger + assert np.isclose(mock_ddg2.burst_delay.get(), 0) + assert np.isclose(mock_ddg2.burst_period.get(), exp_time) + + assert mock_ddg2.trigger_source.get() == TRIGGERSOURCE.EXT_RISING_EDGE.value + + assert mock_ddg2.staged == ophyd.Staged.yes + + +def test_ddg2_trigger(mock_ddg2): + """Test the on_trigger method of DDG2.""" + mock_ddg2.trigger_shot.put(0) + status = mock_ddg2.trigger() + assert mock_ddg2.trigger_shot.get() == 0 # Should not trigger DDG2 via soft trigger + status.wait() + assert status.done is True + assert status.success is True + + +def test_ddg2_stop(mock_ddg2): + """Test the on_stop method of DDG2.""" + mock_ddg2.burst_mode.put(1) # Enable burst mode + mock_ddg2.stop() + assert mock_ddg2.burst_mode.get() == 0 # Burst mode is disabled -- 2.49.1 From 4812323e4b7241e1a7e4a88f6c3b7f07154b8ace Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 15 Jul 2025 13:59:24 +0200 Subject: [PATCH 6/7] refactor: cleanup and remove old classes from configs --- .../bec_device_config_sastt.yaml | 83 ------------------- .../device_configs/epics_devices_config.yaml | 50 ----------- 2 files changed, 133 deletions(-) delete mode 100644 csaxs_bec/device_configs/epics_devices_config.yaml diff --git a/csaxs_bec/device_configs/bec_device_config_sastt.yaml b/csaxs_bec/device_configs/bec_device_config_sastt.yaml index 35dc259..11784f1 100644 --- a/csaxs_bec/device_configs/bec_device_config_sastt.yaml +++ b/csaxs_bec/device_configs/bec_device_config_sastt.yaml @@ -53,89 +53,6 @@ eiger9m: enabled: true readoutPriority: async softwareTrigger: false -# ddg_detectors: -# description: DelayGenerator for detector triggering -# deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS -# deviceConfig: -# prefix: 'delaygen:DG1:' -# ddg_config: -# delay_burst: 40.e-3 -# delta_width: 0 -# additional_triggers: 0 -# polarity: -# - 1 # T0 -> DDG MCS -# - 0 # eiger -# - 1 # falcon -# - 1 -# - 1 -# amplitude: 4.5 -# offset: 0 -# thres_trig_level: 2.5 -# set_high_on_exposure: False -# set_high_on_stage: False -# deviceTags: -# - cSAXS -# - ddg_detectors -# onFailure: buffer -# enabled: true -# readoutPriority: async -# softwareTrigger: false -# ddg_mcs: -# description: DelayGenerator for mcs triggering -# deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS -# deviceConfig: -# prefix: 'delaygen:DG2:' -# ddg_config: -# delay_burst: 0 -# delta_width: 0 -# additional_triggers: 1 -# polarity: -# - 1 -# - 0 -# - 1 -# - 1 -# - 1 -# amplitude: 4.5 -# offset: 0 -# thres_trig_level: 2.5 -# set_high_on_exposure: False -# set_high_on_stage: False -# set_trigger_source: EXT_RISING_EDGE -# trigger_width: 3.e-3 -# deviceTags: -# - cSAXS -# - ddg_mcs -# onFailure: buffer -# enabled: true -# readoutPriority: async -# softwareTrigger: false -# ddg_fsh: -# description: DelayGenerator for fast shutter control -# deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS -# deviceConfig: -# prefix: 'delaygen:DG3:' -# ddg_config: -# delay_burst: 0 -# delta_width: 80.e-3 -# additional_triggers: 0 -# polarity: -# - 1 -# - 1 -# - 1 -# - 1 -# - 1 -# amplitude: 4.5 -# offset: 0 -# thres_trig_level: 2.5 -# set_high_on_exposure: True -# set_high_on_stage: False -# deviceTags: -# - cSAXS -# - ddg_fsh -# onFailure: buffer -# enabled: true -# readoutPriority: async -# softwareTrigger: false falcon: description: Falcon detector x-ray fluoresence deviceClass: csaxs_bec.devices.epics.falcon_csaxs.FalconcSAXS diff --git a/csaxs_bec/device_configs/epics_devices_config.yaml b/csaxs_bec/device_configs/epics_devices_config.yaml deleted file mode 100644 index 1f69623..0000000 --- a/csaxs_bec/device_configs/epics_devices_config.yaml +++ /dev/null @@ -1,50 +0,0 @@ -# ddg_detectors: -# description: DelayGenerator for detector triggering -# deviceClass: csaxs_bec.devices.epics.delay_generator_csaxs.DelayGeneratorcSAXS -# deviceConfig: -# prefix: 'X12SA-CPCL-DDG3:' -# ddg_config: -# delay_burst: 40.e-3 -# delta_width: 0 -# additional_triggers: 0 -# polarity: -# - 1 # T0 -> DDG MCS -# - 0 # eiger -# - 1 # falcon -# - 1 -# - 1 -# amplitude: 4.5 -# offset: 0 -# thres_trig_level: 2.5 -# set_high_on_exposure: False -# set_high_on_stage: False -# deviceTags: -# - cSAXS -# - ddg_detectors -# onFailure: buffer -# enabled: true -# readoutPriority: async -# softwareTrigger: True -bpm4i: - readoutPriority: monitored - deviceClass: ophyd_devices.SimMonitor - deviceConfig: - deviceTags: - - beamline - enabled: true - readOnly: false -samx: - readoutPriority: baseline - deviceClass: ophyd_devices.SimPositioner - deviceConfig: - delay: 1 - limits: - - -50 - - 50 - tolerance: 0.01 - update_frequency: 400 - deviceTags: - - user motors - enabled: true - readOnly: false - \ No newline at end of file -- 2.49.1 From 909f0d2c96e3dcb8f79cdd182cbc57a666f44a38 Mon Sep 17 00:00:00 2001 From: gac-x12sa Date: Wed, 16 Jul 2025 14:21:41 +0200 Subject: [PATCH 7/7] fix: add sleep to ddg2 for pre_scan to be ready for triggers --- csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py | 8 ++++++++ .../epics/delay_generator_csaxs/delay_generator_csaxs.py | 3 --- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py index d9169bc..9b9c76b 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_2.py @@ -103,6 +103,14 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS): burst_pulse_width = exp_time - DEFAULT_READOUT_TIMES["ab"] self.set_delay_pairs(channel="ab", delay=0, width=burst_pulse_width) self.burst_enable(count=frames_per_trigger, delay=0, period=exp_time) + + def on_pre_scan(self): + """ + The delay generator occasionally needs a bit extra time to process all + commands from stage. Therefore, we introduce here a short sleep + """ + # Delay Generator occasionaly needs a bit extra time to process all commands, sleep 50ms + time.sleep(0.05) def on_trigger(self) -> DeviceStatus | StatusBase | None: """ diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py index a04b9c9..981f09c 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/delay_generator_csaxs.py @@ -140,7 +140,6 @@ class StatusBitsCompareStatus(SubscriptionStatus): name = 'no object received' else: name=obj.name - logger.info(f"Receive update for {name} with value {value}") if any((STATUSBITS(value) & state) == state for state in self._raise_states): self.set_exception( ValueError( @@ -149,7 +148,6 @@ class StatusBitsCompareStatus(SubscriptionStatus): ) return False if self._add_delay !=0: - logger.info(f"Sleeping for {self._add_delay} for {name}") time.sleep(self._add_delay) return (STATUSBITS(value) & self._value) == self._value @@ -671,7 +669,6 @@ class DelayGeneratorCSAXS(Device): raise ValueError( f"Length of width {len(width)} must match length of channel {len(channel)}." ) - logger.info(f"setting widths of channels {channel} to {width}") for ii, ch in enumerate(channel): delay_channel = getattr(self, ch) delay_channel.width.put(width[ii]) -- 2.49.1