From 02ac2e19cf9f31f0ae186da712384f912e942ba0 Mon Sep 17 00:00:00 2001 From: appel_c Date: Mon, 11 Aug 2025 11:24:20 +0200 Subject: [PATCH] feat(eiger): add jfj integration --- csaxs_bec/device_configs/endstation.yaml | 26 ++ .../epics/delay_generator_csaxs/ddg_1.py | 27 +- .../devices/epics/mcs_card/mcs_card_csaxs.py | 2 +- csaxs_bec/devices/jungfraujoch/eiger.py | 284 ++++++++++++++++++ csaxs_bec/devices/jungfraujoch/eiger_1_5m.py | 54 ++++ csaxs_bec/devices/jungfraujoch/eiger_9m.py | 58 ++++ .../jungfraujoch/jungfrau_joch_client.py | 202 ++++++++----- .../jungfraujoch/jungfraujoch_preview.py | 95 ++++++ frame_dump.cbor | Bin 0 -> 41698 bytes pyproject.toml | 1 + 10 files changed, 663 insertions(+), 86 deletions(-) create mode 100644 csaxs_bec/devices/jungfraujoch/eiger.py create mode 100644 csaxs_bec/devices/jungfraujoch/eiger_1_5m.py create mode 100644 csaxs_bec/devices/jungfraujoch/eiger_9m.py create mode 100644 csaxs_bec/devices/jungfraujoch/jungfraujoch_preview.py create mode 100644 frame_dump.cbor diff --git a/csaxs_bec/device_configs/endstation.yaml b/csaxs_bec/device_configs/endstation.yaml index c9a2c20..6f7d9ca 100644 --- a/csaxs_bec/device_configs/endstation.yaml +++ b/csaxs_bec/device_configs/endstation.yaml @@ -42,3 +42,29 @@ ids_cam: enabled: true readoutPriority: async softwareTrigger: True + +eiger_1_5: + description: Eiger 1.5M in-vacuum detector + deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M + deviceConfig: + detector_distance: 100 + beam_center: [0, 0] + onFailure: raise + enabled: true + readoutPriority: async + softwareTrigger: 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 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 314730d..f678741 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -33,6 +33,7 @@ from __future__ import annotations import threading import time +import traceback from typing import TYPE_CHECKING from bec_lib.logger import bec_logger @@ -160,6 +161,9 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): # f = e + 1us # e has refernce to d, f has reference to e self.set_delay_pairs(channel="ef", delay=0, width=1e-6) + time.sleep( + 0.2 + ) # After staging, make sure that the DDG HW has some time to process changes properly. def _prepare_mcs_on_trigger(self, mcs: MCSCardCSAXS) -> None: """Prepare the MCS card for the next trigger. @@ -188,7 +192,13 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): while ( self._poll_thread_run_event.is_set() and not self._poll_thread_kill_event.is_set() ): - self._poll_loop() + try: + self._poll_loop() + except Exception: # pylint: disable=broad-except + content = traceback.format_exc() + logger.error( + f"Exception in polling loop thread, polling continues...\n Error content:\n{content}" + ) self._poll_thread_poll_loop_done.set() @@ -201,13 +211,17 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): to be necessary to avoid missing events. """ self.state.proc_status.put(1, use_complete=True) - time.sleep(0.02) # 20ms delay for processing, important for not missing events - if self._poll_thread_run_event.is_set() and not self._poll_thread_kill_event.is_set(): + if ( + self._poll_thread_run_event.wait(timeout=0.02) + and not self._poll_thread_kill_event.is_set() + ): return self.state.event_status.get(use_monitor=False) - if self._poll_thread_run_event.is_set() and not self._poll_thread_kill_event.is_set(): + if ( + self._poll_thread_run_event.wait(timeout=0.02) + and not self._poll_thread_kill_event.is_set() + ): return - time.sleep(0.02) # 20ms delay for processing, important for not missing events def _start_polling(self) -> None: """Start the polling loop in the background thread.""" @@ -253,6 +267,9 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS): 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. """ + # Keep sleep here for software trigger mode as 20ms delay between subsequent commands + # to the HW are necessary to avoid crashes and missing events. + time.sleep(0.02) # Stop polling, poll once manually to ensure that the register is clean self._stop_polling() self._poll_thread_poll_loop_done.wait(timeout=1) diff --git a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py index 25958c9..91149eb 100644 --- a/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py +++ b/csaxs_bec/devices/epics/mcs_card/mcs_card_csaxs.py @@ -158,7 +158,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard): self.acquire_mode.set(ACQUIREMODE.MCS).wait(timeout=self._pv_timeout) # Subscribe the progress signal - self.current_channel.subscribe(self._progress_update, run=False) + # self.current_channel.subscribe(self._progress_update, run=False) # Subscribe to the mca updates for name in self.counter_mapping.keys(): diff --git a/csaxs_bec/devices/jungfraujoch/eiger.py b/csaxs_bec/devices/jungfraujoch/eiger.py new file mode 100644 index 0000000..e32f482 --- /dev/null +++ b/csaxs_bec/devices/jungfraujoch/eiger.py @@ -0,0 +1,284 @@ +""" +Generic integration of JungfrauJoch backend with Eiger detectors +for the cSAXS beamline at the Swiss Light Source. + +The WEB UI is available on http://sls-jfjoch-001:8080 + +NOTE: this may not be the best place to store this information. It should be migrated to +beamline documentation for debugging of Eiger & JungfrauJoch. + +The JungfrauJoch server for cSAXS runs on sls-jfjoch-001.psi.ch +User with sufficient rights may use: +- sudo systemctl restart jfjoch_broker +- sudo systemctl status jfjoch_broker +to check and/or restart the broker for the JungfrauJoch server. + +Some extra notes for setting up the detector: +- If the energy on JFJ is set via DetectorSettings, the variable in DatasetSettings will be ignored +- Changes in energy may take time, good to implement logic that only resets energy if needed. +- For the Eiger, the frame_time_us in DetectorSettings is ignored, only the frame_time_us in + the DatasetSettings is relevant +- The bit_depth will be adjusted automatically based on the exp_time. Here, we need to ensure + that subsequent triggers properly + consider the readout_time of the boards. For Jungfrau detectors, the difference between + count_time_us and frame_time_us is the readout_time of the boards. For the Eiger, this needs + to be taken into account during the integration. +- beam_center and detector settings are required input arguments, thus, they may be set to wrong + values for acquisitions to start. Please keep this in mind. + +Hardware related notes: +- If there is an HW issue with the detector, power cycling may help. +- The sls_detector package is available on console on /sls/X12SA/data/gac-x12sa/erik/micromamba + - Run: source setup_9m.sh # Be careful, this connects to the detector, so it should not be + used during operation + - Useful commands: + - p highvoltage 0 or 150 (operational) + - g highvoltage + - # Put high voltage to 0 before power cylcing it. + - telnet bchip500 + - cd power_control_user/ + - ./on + - ./off + +Further information that may be relevant for debugging: +JungfrauJoch - one needs to connect to the jfj-server (sls-jfjoch-001) +""" + +from __future__ import annotations + +import os +import time +import traceback +from typing import TYPE_CHECKING, Literal + +import requests +import yaml +from bec_lib.file_utils import get_full_path +from bec_lib.logger import bec_logger +from jfjoch_client.models.dataset_settings import DatasetSettings +from jfjoch_client.models.detector_selection import DetectorSelection +from jfjoch_client.models.detector_settings import DetectorSettings +from jfjoch_client.models.detector_timing import DetectorTiming +from ophyd import DeviceStatus +from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase + +from csaxs_bec.devices.jungfraujoch.jungfrau_joch_client import ( + JungfrauJochClient, + JungfrauJochClientError, +) + +if TYPE_CHECKING: # pragma no cover + from bec_lib.devicemanager import ScanInfo + from bec_server.device_server.device_server import DeviceManagerDS + from jfjoch_client.models.detector_list_element import DetectorListElement + +logger = bec_logger.logger + +EIGER_READOUT_TIME_US = 500e-6 # 500 microseconds in s + + +class EigerError(Exception): + """Custom exception for Eiger detector errors.""" + + +class Eiger(PSIDeviceBase): + """ + Base integration of the Eiger1.5M and Eiger9M at cSAXS. All relevant + """ + + USER_ACCESS = ["detector_distance", "beam_center"] + + def __init__( + self, + name: str, + detector_name: Literal["EIGER 9M", "EIGER 8.5M (tmp)", "EIGER 1.5M"], + host: str = "http://sls-jfjoch-001", + port: int = 8080, + detector_distance: float = 100.0, + beam_center: tuple[int, int] = (0, 0), + scan_info: ScanInfo = None, + readout_time: float = EIGER_READOUT_TIME_US, + device_manager=None, + **kwargs, + ): + """ + Initialize the PSI Device Base class. + + Args: + name (str) : Name of the device + detector_name (str): Name of the detector. Supports ["EIGER 9M", "EIGER 8.5M (tmp)", "EIGER 1.5M"] + host (str): Hostname of the Jungfrau Joch server. + port (int): Port of the Jungfrau Joch server. + scan_info (ScanInfo): The scan info to use. + device_manager (DeviceManagerDS): The device manager to use. + **kwargs: Additional keyword arguments. + """ + super().__init__(name=name, scan_info=scan_info, device_manager=device_manager, **kwargs) + self._host = f"{host}:{port}" + self.jfj_client = JungfrauJochClient(host=self._host, parent=self) + self.device_manager = device_manager + self.detector_name = detector_name + self._detector_distance = detector_distance + self._beam_center = beam_center + self._readout_time = readout_time + self._full_path = "" + if self.device_manager is not None: + self.device_manager: DeviceManagerDS + + @property + def detector_distance(self) -> float: + """The detector distance in mm.""" + return self._detector_distance + + @detector_distance.setter + def detector_distance(self, value: float) -> None: + """Set the detector distance in mm.""" + if value <= 0: + raise ValueError("Detector distance must be a positive value.") + self._detector_distance = value + + @property + def beam_center(self) -> tuple[float, float]: + """The beam center in pixels. (x,y)""" + return self._beam_center + + @beam_center.setter + def beam_center(self, value: tuple[float, float]) -> None: + """Set the beam center in pixels. (x,y)""" + self._beam_center = value + + def on_init(self) -> None: + """ + Called when the device is initialized. + + No siganls are connected at this point, + thus should not be set here but in on_connected instead. + """ + + def on_connected(self) -> None: + """ + Called after the device is connected and its signals are connected. + Default values for signals should be set here. + """ + logger.debug(f"On connected called for {self.name}") + self.jfj_client.stop(request_timeout=3) + # If stop failed, it will not raise. So the next calls may raise instead.. TODO logic could be discussed + # Get available detectors + available_detectors = self.jfj_client.api.config_select_detector_get(_request_timeout=5) + detector_info = [ + det for det in available_detectors.detectors if det.description == self.detector_name + ] + # If the specified detector is not found, or multiple definitions for that name are available, something is wrong. + if len(detector_info) != 1: + raise ValueError( + f"Detector {self.detector_name} not found in available detectors: {available_detectors}" + ) + detector_info: DetectorListElement = detector_info[0] + try: + self.jfj_client.api.config_select_detector_put( + DetectorSelection(id=detector_info.id), _request_timeout=10 + ) + except requests.exceptions.Timeout: + raise TimeoutError(f"Timeout while selecting detector {self.detector_name}") + except Exception: + content = traceback.format_exc() + raise EigerError(f"Error while selecting detector {self.detector_name}: {content}") + + # Try to connect, needs to be in Inactive or Error state + self.jfj_client.connect_and_initialise(timeout=10, _request_timeout=10) + + # This sets the energy threshold for the EIGER detector + settings = DetectorSettings(frame_time_us=int(500), timing=DetectorTiming.TRIGGER) + logger.debug(f"Setting detector_settings: {yaml.dump(settings.to_dict(), indent=4)}") + self.jfj_client.set_detector_settings(settings, timeout=10) + + def on_stage(self) -> DeviceStatus | None: + """ + Called while staging the device. + + Information about the upcoming scan can be accessed from the scan_info object. + """ + start_time = time.time() + + scan_msg = self.scan_info.msg + # TODO add mono energy logic, if energy changed more than ~1-2%, adapt thresholds! + # Add logic to set the detector energy threshold from mono_energy pv + # dev.mono_energy from device_manager + incident_energy = 12.0 + + exp_time = scan_msg.scan_parameters.get("exp_time", 0) + if exp_time <= self._readout_time: + raise ValueError( + f"Receive scan request for scan {scan_msg.scan_name} with exp_time {exp_time}s, which must be larger than the readout time {self._readout_time}s of the detector {self.detector_name}." + ) + frame_time_us = exp_time # - self._readout_time # Needs to be checked if needed + # convert frame time to us + # settings = DetectorSettings(frame_time_us=int(frame_time_us * 1e6)) + # self.jfj_client.set_detector_settings(settings, timeout=10) + # Set acquisition parameter + ntrigger = int(scan_msg.num_points * scan_msg.scan_parameters["frames_per_trigger"]) + # Fetch file path + self._full_path = get_full_path( + scan_msg, name=self.name + ) # We can discuss if this should be used with name.. + # Get Path from Broker + # Fetch basepath from JFJ broker config + path = os.path.relpath(self._full_path, start="/sls/x12sa/data") + data_settings = DatasetSettings( + image_time_us=int(frame_time_us * 1e6), # This is currently ignored + ntrigger=ntrigger, + file_prefix=path, + beam_x_pxl=int(self._beam_center[0]), + beam_y_pxl=int(self._beam_center[1]), + detector_distance_mm=self.detector_distance, + incident_energy_ke_v=incident_energy, + ) + logger.debug(f"Setting data_settings: {yaml.dump(data_settings.to_dict(), indent=4)}") + prep_time = start_time - time.time() + logger.info(f"Prepared information for eiger to start acquisition in {prep_time:.2f}s") + self.jfj_client.start(settings=data_settings) + start_call_returns = time.time() - start_time - prep_time + logger.info(f"Start Rest call from JFJ took {start_call_returns:.2f}s") + + def on_unstage(self) -> DeviceStatus: + """Called while unstaging the device.""" + + def on_pre_scan(self) -> DeviceStatus: + """Called right before the scan starts on all devices automatically.""" + + def on_trigger(self) -> DeviceStatus: + """Called when the device is triggered.""" + + def on_complete(self) -> DeviceStatus: + """Called to inquire if a device has completed a scans.""" + + def wait_for_complete(): + timeout = 10 + for _ in range(timeout): + try: + self.jfj_client.wait_till_done(timeout=1, _request_timeout=5) + except ( + JungfrauJochClientError + ): # Means that timeout was triggered, and not _request_timeout + continue + except TimeoutError: # Timeout exception from wait_till_done + content = traceback.format_exc() + raise TimeoutError(f"Timeout for request during complete call: {content}") + except Exception as e: # This should actually never occur.. + raise ValueError(f"Error in complete for {self.name}, exception: {e}") from e + else: + break + + status = self.task_handler.submit_task(wait_for_complete, run=True) + self.cancel_on_stop(status) + return status + + def on_kickoff(self) -> DeviceStatus | None: + """Called to kickoff a device for a fly scan. Has to be called explicitly.""" + + def on_stop(self) -> None: + """Called when the device is stopped.""" + self.jfj_client.stop( + request_timeout=0.5 + ) # Call should not block more than 0.5 seconds to stop all devices... + self.task_handler.shutdown() diff --git a/csaxs_bec/devices/jungfraujoch/eiger_1_5m.py b/csaxs_bec/devices/jungfraujoch/eiger_1_5m.py new file mode 100644 index 0000000..22432c7 --- /dev/null +++ b/csaxs_bec/devices/jungfraujoch/eiger_1_5m.py @@ -0,0 +1,54 @@ +""" +Eiger 1.5M specific integration. It is based on the Eiger base integration for the JungfrauJoch backend +which is placed in eiger_csaxs, and where code that is equivalent for the Eiger9M and Eiger1.5M is shared. + +Please check the eiger_csaxs.py class for more details about the relevant services. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from csaxs_bec.devices.jungfraujoch.eiger import Eiger + +EIGER1_5M_READOUT_TIME_US = 500e-6 # 500 microseconds in s +DETECTOR_NAME = "EIGER 1.5M" + + +if TYPE_CHECKING: # pragma no cover + from bec_lib.devicemanager import ScanInfo + from bec_server.device_server.device_server import DeviceManagerDS + + +# pylint:disable=invalid-name +class Eiger1_5M(Eiger): + """ + Eiger 1.5M specific integration for the in-vaccum Eiger. + + The logic implemented here is coupled to the DelayGenerator integration, + repsonsible for the global triggering of all devices through a single Trigger logic. + Please check the eiger.py class for more details about the integration of relevant backend + services. The detector_name must be set to "EIGER 1.5M: + """ + + USER_ACCESS = Eiger.USER_ACCESS + [] # Add more user_access methods here. + + def __init__( + self, + name: str, + detector_distance: float = 100.0, + beam_center: tuple[float, float] = (0.0, 0.0), + scan_info: ScanInfo = None, + device_manager: DeviceManagerDS = None, + **kwargs, + ) -> None: + super().__init__( + name=name, + detector_name=DETECTOR_NAME, + readout_time=EIGER1_5M_READOUT_TIME_US, + detector_distance=detector_distance, + beam_center=beam_center, + scan_info=scan_info, + device_manager=device_manager, + **kwargs, + ) diff --git a/csaxs_bec/devices/jungfraujoch/eiger_9m.py b/csaxs_bec/devices/jungfraujoch/eiger_9m.py new file mode 100644 index 0000000..ca47caf --- /dev/null +++ b/csaxs_bec/devices/jungfraujoch/eiger_9m.py @@ -0,0 +1,58 @@ +""" +Eiger 9M specific integration. It is based on the Eiger base integration for the JungfrauJoch backend +which is placed in eiger_csaxs, and where code that is equivalent for the Eiger9M and Eiger1.5M is shared. + +Please check the eiger_csaxs.py class for more details about the relevant services. + +In 16bit mode, 8e7 counts/s per pixel are supported in summed up frames, +although subframes will never have more than 12bit counts (~4000 counts per pixel in subframe). +In 32bit mode, 2e7 counts/s per pixel are supported, for which subframes will have no +more than 24bit counts, which means 16.7 million counts per pixel in subframes. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from csaxs_bec.devices.jungfraujoch.eiger import Eiger + +if TYPE_CHECKING: # pragma no cover + from bec_lib.devicemanager import ScanInfo + from bec_server.device_server.device_server import DeviceManagerDS + +EIGER9M_READOUT_TIME_US = 500e-6 # 500 microseconds in s +DETECTOR_NAME = "EIGER 8.5M (tmp)" # "EIGER 9M"" + + +# pylint:disable=invalid-name +class Eiger1_5M(Eiger): + """ + Eiger 1.5M specific integration for the in-vaccum Eiger. + + The logic implemented here is coupled to the DelayGenerator integration, + repsonsible for the global triggering of all devices through a single Trigger logic. + Please check the eiger.py class for more details about the integration of relevant backend + services. The detector_name must be set to "EIGER 1.5M: + """ + + USER_ACCESS = Eiger.USER_ACCESS + [] # Add more user_access methods here. + + def __init__( + self, + name: str, + detector_distance: float = 100.0, + beam_center: tuple[float, float] = (0.0, 0.0), + scan_info: ScanInfo = None, + device_manager: DeviceManagerDS = None, + **kwargs, + ) -> None: + super().__init__( + name=name, + detector_name=DETECTOR_NAME, + readout_time=EIGER9M_READOUT_TIME_US, + detector_distance=detector_distance, + beam_center=beam_center, + scan_info=scan_info, + device_manager=device_manager, + **kwargs, + ) diff --git a/csaxs_bec/devices/jungfraujoch/jungfrau_joch_client.py b/csaxs_bec/devices/jungfraujoch/jungfrau_joch_client.py index 2a02dc6..7e679b9 100644 --- a/csaxs_bec/devices/jungfraujoch/jungfrau_joch_client.py +++ b/csaxs_bec/devices/jungfraujoch/jungfrau_joch_client.py @@ -1,20 +1,36 @@ +"""Module with client interface for the Jungfrau Joch detector API""" + +from __future__ import annotations + import enum import math +import time +import traceback +from typing import TYPE_CHECKING -import jfjoch_client +import requests from bec_lib.logger import bec_logger +from jfjoch_client.api.default_api import DefaultApi +from jfjoch_client.api_client import ApiClient +from jfjoch_client.configuration import Configuration +from jfjoch_client.models.broker_status import BrokerStatus +from jfjoch_client.models.dataset_settings import DatasetSettings +from jfjoch_client.models.detector_settings import DetectorSettings logger = bec_logger.logger +if TYPE_CHECKING: + from ophyd import Device + +# pylint: disable=raise-missing-from +# pylint: disable=broad-except class JungfrauJochClientError(Exception): """Base class for exceptions in this module.""" -class DetectorState(enum.StrEnum): - """Detector states for Jungfrau Joch detector - ['Inactive', 'Idle', 'Busy', 'Measuring', 'Pedestal', 'Error'] - """ +class DetectorState(str, enum.Enum): + """Possible Detector states for Jungfrau Joch detector""" INACTIVE = "Inactive" IDLE = "Idle" @@ -24,24 +40,30 @@ class DetectorState(enum.StrEnum): ERROR = "Error" -class ResponseWaitDone(enum.IntEnum): - """Response state for Jungfrau Joch detector wait till done""" - - DETECTOR_IDLE = 200 - TIMEOUT_PARAM_OUT_OF_RANGE = 400 - JUNGFRAU_ERROR = 500 - DETECTOR_INACTIVE = 502 - TIMEOUT_REACHED = 504 - - class JungfrauJochClient: - """Thin wrapper around the Jungfrau Joch API client""" + """Thin wrapper around the Jungfrau Joch API client. - def __init__(self, host: str = "http://sls-jfjoch-001:8080") -> None: + sudo systemctl restart jfjoch_broker + sudo systemctl status jfjoch_broker + + It looks as if the detector is not being stopped properly. + One module remains running, how can we restart the detector? + """ + + def __init__( + self, host: str = "http://sls-jfjoch-001:8080", parent: Device | None = None + ) -> None: self._initialised = False - configuration = jfjoch_client.Configuration(host=host) - api_client = jfjoch_client.ApiClient(configuration) - self.api = jfjoch_client.DefaultApi(api_client) + configuration = Configuration(host=host) + api_client = ApiClient(configuration) + self.api = DefaultApi(api_client) + self._parent_name = parent.name if parent else self.__class__.__name__ + + @property + def jjf_state(self) -> BrokerStatus: + """Get the status of JungfrauJoch""" + response = self.api.status_get() + return BrokerStatus(**response.to_dict()) @property def initialised(self) -> bool: @@ -53,101 +75,121 @@ class JungfrauJochClient: """Set the connected status""" self._initialised = value - def get_jungfrau_joch_status(self) -> DetectorState: + @property + def detector_state(self) -> DetectorState: """Get the status of JungfrauJoch""" - return self.api.status_get().state + return DetectorState(self.jjf_state.state) - def connect_and_initialise(self, timeout: int = 5) -> None: + def connect_and_initialise(self, timeout: int = 10, **kwargs) -> None: """Check if JungfrauJoch is connected and ready to receive commands""" - status = self.api.status_get().state + status = self.detector_state if status != DetectorState.IDLE: - self.api.initialize_post() - self.wait_till_done(timeout) - self.initialised = True + self.api.initialize_post() # This is a blocking call.... + self.wait_till_done(timeout, **kwargs) # Blocking call + self.initialised = True - def set_detector_settings(self, settings: dict | jfjoch_client.DatasetSettings) -> None: + def set_detector_settings(self, settings: dict | DetectorSettings, timeout: int = 10) -> None: """Set the detector settings. JungfrauJoch must be in IDLE, Error or Inactive state. Note, the full settings have to be provided, otherwise the settings will be overwritten with default values. Args: settings (dict): dictionary of settings """ - state = self.api.status_get().state + state = self.detector_state if state not in [DetectorState.IDLE, DetectorState.ERROR, DetectorState.INACTIVE]: - raise JungfrauJochClientError( - f"Detector must be in IDLE, ERROR or INACTIVE state to set settings. Current state: {state}" - ) + time.sleep(1) # Give the detector 1s to become IDLE, retry + state = self.detector_state + if state not in [DetectorState.IDLE, DetectorState.ERROR, DetectorState.INACTIVE]: + raise JungfrauJochClientError( + f"Error in {self._parent_name}. Detector must be in IDLE, ERROR or INACTIVE state to set settings. Current state: {state}" + ) if isinstance(settings, dict): - settings = jfjoch_client.DatasetSettings(**settings) - self.api.config_detector_put(settings) + settings = DetectorSettings(**settings) + try: + self.api.config_detector_put(detector_settings=settings, _request_timeout=timeout) + except requests.exceptions.Timeout: + raise TimeoutError(f"Timeout while setting detector settings for {self._parent_name}") + except Exception: + content = traceback.format_exc() + raise JungfrauJochClientError( + f"Error while setting detector settings for {self._parent_name}: {content}" + ) - def set_mesaurement_settings(self, settings: dict | jfjoch_client.DatasetSettings) -> None: - """Set the measurement settings. JungfrauJoch must be in IDLE state. + def start(self, settings: dict | DatasetSettings, request_timeout: float = 10) -> None: + """Start the mesaurement. DatasetSettings must be provided, and JungfrauJoch must be in IDLE state. The method call is blocking and JungfrauJoch will be ready to measure after the call resolves. - Please check the DataSettings class for the available settings. - The minimum required settings are: - beam_x_pxl: StrictFloat | StrictInt, - beam_y_pxl: StrictFloat | StrictInt, - detector_distance_mm: float | int, - incident_energy_keV: float | int, - Args: settings (dict): dictionary of settings + + Please check the DataSettings class for the available settings. Minimum required settings are + beam_x_pxl, beam_y_pxl, detector_distance_mm, incident_energy_keV. + """ - state = self.api.status_get().state + state = self.detector_state if state != DetectorState.IDLE: raise JungfrauJochClientError( - f"Detector must be in IDLE state to set settings. Current state: {state}" + f"Error in {self._parent_name}. Detector must be in IDLE state to set settings. Current state: {state}" ) if isinstance(settings, dict): - settings = jfjoch_client.DatasetSettings(**settings) + settings = DatasetSettings(**settings) try: - res = self.api.start_post_with_http_info(dataset_settings=settings) - if res.status_code != 200: - logger.error( - f"Error while setting measurement settings {settings}, response: {res}" - ) - raise JungfrauJochClientError( - f"Error while setting measurement settings {settings}, response: {res}" - ) - except Exception as e: - logger.error( - f"Error while setting measurement settings {settings}. Exception raised {e}" + self.api.start_post_with_http_info( + dataset_settings=settings, _request_timeout=request_timeout ) + except requests.exceptions.Timeout: + raise TimeoutError( + f"TimeoutError in JungfrauJochClient for parent device {self._parent_name} for 'start' call" + ) + except Exception: + content = traceback.format_exc() raise JungfrauJochClientError( - f"Error while setting measurement settings {settings}. Exception raised {e}" - ) from e + f"Error in JungfrauJochClient for parent device {self._parent_name} during 'start' call: {content}" + ) - def wait_till_done(self, timeout: int = 5) -> None: - """Wait until JungfrauJoch is done. + def stop(self, request_timeout: float = 0.5) -> None: + """Stop the acquisition, this only logs errors and is not raising.""" + try: + self.api.cancel_post_with_http_info(_request_timeout=request_timeout) + except requests.exceptions.Timeout: + content = traceback.format_exc() + logger.error( + f"Timeout in JungFrauJochClient for device {self._parent_name} during stop: {content}" + ) + except Exception: + content = traceback.format_exc() + logger.error( + f"Error in JungFrauJochClient for device {self._parent_name} during stop: {content}" + ) + + def wait_till_done(self, timeout: int = 10, **kwargs) -> None: + """Wait for JungfrauJoch to be in Idle state. Blocking call with timeout. Args: timeout (int): timeout in seconds """ - success = False try: - response = self.api.wait_till_done_post_with_http_info(math.ceil(timeout / 2)) - if response.status_code != ResponseWaitDone.DETECTOR_IDLE: - logger.info( - f"Waitin for JungfrauJoch to be done, status: {ResponseWaitDone(response.status_code)}; response msg {response}" + self.api.wait_till_done_post_with_http_info(math.ceil(timeout=timeout / 2), **kwargs) + except requests.exceptions.Timeout: + raise TimeoutError( + f"Timeout in JungfrauJochClient for parent device {self._parent_name} for 'wait_till_done' call" + ) + except Exception: + logger.info( + f"Waiting for device {self._parent_name}, jungfrau joch to become IDLE, retry after {timeout/2} seconds" + ) + try: + self.api.wait_till_done_post_with_http_info( + timeout=math.floor(timeout / 2), **kwargs ) - response = self.api.wait_till_done_post_with_http_info(math.floor(timeout / 2)) - if response.status_code == ResponseWaitDone.DETECTOR_IDLE: - success = True - return - except Exception as e: - logger.error(f"Error while waiting for JungfrauJoch to initialise: {e}") - raise JungfrauJochClientError( - f"Error while waiting for JungfrauJoch to initialise: {e}" - ) from e - else: - if success is False: - logger.error( - f"Failed to initialise JungfrauJoch with status: {response.status_code}; response msg {response}" + except requests.exceptions.Timeout: + raise TimeoutError( + f"Timeout in JungfrauJochClient for parent device {self._parent_name} for 'wait_till_done' call" ) + except Exception: + content = traceback.format_exc() raise JungfrauJochClientError( - f"Failed to initialise JungfrauJoch with status: {response.status_code}; response msg {response}" + f"JungfrauJoch Error in wait_till_done post for device {self._parent_name}: {content}" ) diff --git a/csaxs_bec/devices/jungfraujoch/jungfraujoch_preview.py b/csaxs_bec/devices/jungfraujoch/jungfraujoch_preview.py new file mode 100644 index 0000000..d1cbe0a --- /dev/null +++ b/csaxs_bec/devices/jungfraujoch/jungfraujoch_preview.py @@ -0,0 +1,95 @@ +"""Module for the Eiger preview ZMQ stream.""" + +from __future__ import annotations + +import json +import threading +import time +from typing import Callable + +import numpy as np +import zmq +from bec_lib.logger import bec_logger + +logger = bec_logger.logger + +ZMQ_TOPIC_FILTER = b"" + + +class JungfrauJochPreview: + USER_ACCESS = ["start", "stop"] + + def __init__(self, url: str, cb: Callable): + self.url = url + self._socket = None + self._shutdown_event = threading.Event() + self._zmq_thread = None + self._on_update_callback = cb + + def connect(self): + """Connect to the JungfrauJoch PUB-SUB streaming interface + + JungfrauJoch may reject connection for a few seconds when it restarts, + so if it fails, wait a bit and try to connect again. + """ + # pylint: disable=no-member + + context = zmq.Context() + self._socket = context.socket(zmq.SUB) + self._socket.setsockopt(zmq.SUBSCRIBE, ZMQ_TOPIC_FILTER) + try: + self._socket.connect(self.url) + except ConnectionRefusedError: + time.sleep(1) + self._socket.connect(self.url) + + def start(self): + self._zmq_thread = threading.Thread( + target=self._zmq_update_loop, daemon=True, name="JungfrauJoch_live_preview" + ) + self._zmq_thread.start() + + def stop(self): + self._shutdown_event.set() + if self._zmq_thread: + self._zmq_thread.join() + + def _zmq_update_loop(self): + while not self._shutdown_event.is_set(): + if self._socket is None: + self.connect() + try: + self._poll() + except ValueError: + # Happens when ZMQ partially delivers the multipart message + pass + except zmq.error.Again: + # Happens when receive queue is empty + time.sleep(0.1) + + def _poll(self): + """ + Poll the ZMQ socket for new data. It will throttle the data update and + only subscribe to the topic for a single update. This is not very nice + but it seems like there is currently no option to set the update rate on + the backend. + """ + + if self._shutdown_event.wait(0.2): + return + + try: + # subscribe to the topic + self._socket.setsockopt(zmq.SUBSCRIBE, ZMQ_TOPIC_FILTER) + + # pylint: disable=no-member + r = self._socket.recv_multipart(flags=zmq.NOBLOCK) + self._parse_data(r) + + finally: + # Unsubscribe from the topic + self._socket.setsockopt(zmq.UNSUBSCRIBE, ZMQ_TOPIC_FILTER) + + def _parse_data(self, data): + logger.info(f"Received data of length {len(data)}") + self._on_update_callback(data) diff --git a/frame_dump.cbor b/frame_dump.cbor new file mode 100644 index 0000000000000000000000000000000000000000..891658175ed967e4d76eb41247ca9269c72e70aa GIT binary patch literal 41698 zcmeHw2UrtJ_xJ23KtLd&s36KNhysd&*g;Jw7A%0+MGYXm3n-vKLQ%0}?||*vdoL7w z7kd}57Z4CddiFcBfgoPr_x`{4zW00n&-a*nF|)I?=lte3XU>$J%shEgnd2Q58}8#1 z5aQ|Q6Ab?ZOcjSt5ApJeXyay$l)@u@A_9CO#nVFrrcL(|2Y65IZfz6L5Ez4IA$h86FlDIeVLDOpQZ<6nVzh*cZO*=jl}A&=Mg~>FpEa_e!wZsew{K9 zXG}abYi+*zJG&s{QKS0*DXvl0*LqGfY$N-Ln_V}i!{7VP zj<@}$ySo24x72=T^X&Q!Kd`Ew#rkU7&_1gD`~rQUY<$D?17A*$@R{lpFvBN8>^V*B z?K1=Zf<0r&8H#A%2p=DDB(`L)>Aw0CW(G$_g+)N^5>NFB4py5U=@~UW!ZXUpTO1w` z;}a||d=wzRh@2kMh9D;Op5_w~5f;%bMMPv&L|CZbk8yezW`qQUif06PdIkG5+?e4R zJlzM)2=R>hMZ7+riD~2Q8RZFDgM)phMg@e0ihX>21EvP}giejE5lRsX5BHoJvl z9SG4l*qK5EV*cWz;>tetJNch@YGP&>CbADj4@Z6YBw|b`X>X#4;xVWQUzYI^w-^E? zd_*!OIOdlhD+SSGu{c|bc0sm~nxc5!5rrSK0Rf>92&Kp*6ii8_C|E8n95jH;GWpe? zq<2{%Tk4a*0GT+CPFg)+_?&J#-_B!EOboG+XZaFG83 zhp__3IF`Sm6giprMA0(z2_BwCNoNCdpyoY@VZw0;YvhJfJj7vx>S27OhC}#Pv(JW~ zMBk*Og+GwTnhP+92rQ6=Ptg*b2NJ}vN}=r4fZ9JvX4WV~`(e~0DB2T^fOHxZ&j@Ki zU<5_VW7H`rjnpA#7|6xt%`l(kSwYHx0PDi4OZiA0e>ckjd(m2e`>#?iD7gVDgv;IQ55ZmM!~Zx6wd&uKwuO_ z%41R*Gr^VtD?W^pCX#`m53<06XwVJWfSmn{T9c+wHUTSpMDdEyBX}f{qF16s{Okml zl`j+-%4Ks0!ojN843KQ4|JTA7DTFV@!Y5F42$}#glPGc(3ZaNZ6ad1(cw{I5kx0N% zLU0$y;VR06Q&JpK7%}ihLgj@*3a3Pfi_^$~K(&Qz<69URt_;c{RBnTmpUZHe0@OAP zY7%7?jwWf4s2xumYoA1M;98wTZF9Bo^+Z-6puuE@Qe2|Jn}Dt$TrYwxksuM=2F4%) z3{*Hc7&~Y~bd~Yrj2~aZ0YGYUP|`Xwkn(6q(Uwi&4{})^e+D{F0n`fvWxObIIw3ED zMlS|ckmZky>o;3zSl0s=awk@T4G+6awi=aSJU@TtD75%sEnXBo19?HhSc+VPV&zXv z#lk7+Hx4k&%44kx$}I^BfCSe7)&;5>yb$93GCRo%MXaSUA1G-p!F(V{1Wy)g@stRx z>;qhYN2On?laaAG!E{2?pbn+>>%xgwC>)O!f+>KnvOoN#uEs7&0&NhC|O%(&Q4Kk^f~ zayW@RKs}T55gT$Mxb;8hJxGBnS`Sc}Nl8uY8ayaE3VA@Fipqo@0>#rLtKjM-o9iS_ z5I+H0KN7K39KKlxRK%C4Vwy4e>^CI^hXhzk`AMoJI92nHuR=B3ABv~wrN9NYBLyKK z4NRUD#|?y@7E!|HyHOOV$8D8*WBc`^{HY4*Qw9Vkjwuf zug7dIK!YaRwlu1if4@{@BnE3xqNMsr2}CmEDYYPi8IOYio(maiiRotDUPE6}f(Zj( zf_pcy`jP;jjjf7xQAr%=RWKr#z_gfBge@`!e1|FC zWpo$Hu3hRVh56WfG#N^g|Ty5;_C{#tdhhBF_*IB2hQEk~>?-ArE(Olwp+3u|%hA{!1(4Xr|LI~n9cOeh}`K+IT%GWUT1_Zxs z7C?D7g*vwP$ef|bJo&tY!YzP1rlbkj*nqu;hC!|ddsKx>1GQ?uWRYRIDwG7{JlRsx z#%U>9J}qTS@#HZZaF(!eJUxZAeQL}V_Ct;C6jxH*DF*!wCf1zBJqmsD5s@9m8%;2F z^2F;X>28j^QT9;R5w!fe)?y`pC^R@>x-L@mlO~t?V=Kyb{6LO;0_2xynWhP_kyzLjihkZC z47xXX)((+)8c4x82*S35>HoD%ro95;KrHk+MHl=@C_D@uMAl^fg%pV1tPu2jBDpzB zO*6w=FvY2q#|D^fQ*M}lgLX)RPqR~ST`Z-9J?a95HIJVUb>=Lo!QX^5p-{U}dKrW| zTxh_}V!n{T-(BE_CjO>k?AWIm3%;16!Nwvk7NY^eaSCQH|15U|4(#J^;^JqUBV?~$ zG!~%x7y-Z(h}Z^|08FhAp#noN=CFT?LHmIt`mb>M_$0*f!zqVh^PRuuFWen6C&?-A z)3DQM60z92o$Zs) z<7f4rWbh0fu(dn0aqz8k<8N4epv>)Z+fn!Ee_AL|sZ)0n@Y9UXtny5&{r zRbr2&P3Zms^YzvY36!MW95(3ImF$+sYagv1e%X13?ZbSHN0)k`VrN0v`=y7iT6J$x zGkEB2rc%T2HD&PLK&{%dWQ6-XufE0MJt*XUrd;1PG{1ai-UEBi(YHqu+6Y^^b;|2L zYx6XTvc>*RhwAzIzUysQ&#{?I%ZQY6>e=z_2B~TFZR1{cMBZUTCvVZUu?x??Gkn%o z-+hAzoqANzVMkz5cv`(>@1fluiF`Li73$8*D_G?Hc;4)1XAdsEcQil!;QP^T`p$;m zbkg5;zf%8l(JPnq<7e_CkKPuA^xd0r$z}G6YhQ1q`#;YhuJ4{7%s9+>^6u8QP0p=T zC1_LK*<6)tnF>i8Vc8CKbUl&YkCJ`Q-gK8JV)9L z`sS#82W{(BzjDulvrG9l;WI4yUw&gDxoLS!_m4MPeU4qccjQLkC8grAHqoAKA09S6 z99XF0Gv6XEV&kat;y$(Kg|cknL56v<_`2GdSCKm39Fxa?8#itfr`}=s*x;}}Mzi~` zjMN*xf5xhv3)7qrTwd}=SkKmb7WmD(a&^uhf#X*!*6P31v4?2q2uX(`ukbg?nj6{; z%j?$ny7=pYF0;*Jxr;--^yqSU-8AC#%3G85=2jG6-P5MZZmn^Pj7jhB-#PU7l*xMcczuGRGH$XS6gzBkvCHBdZ`uPhdX_tf z98f#iqHgP=x%BtSr5%)lB9z=O>#uX&=+@~zvu*z3arUob!`q&GsXR*OL*b=%Cr-`% z5acrbj`_4;^YS!0Sz}E9mFeByO)z+O!tiO`G*3rs^FslS^x^iy><4&{*#1ZPn@d!` z6cb}F;%G-yv7ohqu;2P2o0b)6T2}13tGUQIsK*qc=}fMRQl#z0VfEv@l^(qw)aT{( z6&40>;y=;cUeTE$1rh4%d}q#WuWCul4TODq9?E9qpxi zB>oDnm|SS8HKB4oKi2+z?Z=04kx^)~NB`4vPq}SUR+B#Vh*MPjR`iV{FBSl_?vKM2HHy*44LNN@Yr0E)lkx(j(@CN9r*pQPg_mZ3=9^bOGw)wJ@^P=M&yO8f4hs5wrq?!K1JB?o zy?yeR%lvP4Nnv(JkKL+rXSCTARnNitbuG88ODR11_`Yr20Mnz3+_TD89FKW3|0?zR zThPVn8Z|{lL#18kp+N?3j-K37Q?)GWvPG#jb7PEh!Ka~iY8`vdS`*OS_p2gDKjI=$I|xf#g=lty23zyydhpVWzIt_MGdeV%d~aqmq&dRl$K& z?r_VyGrl^`NJ<^VT{iT@u()NCGwBaC;R8G5p%U$P}t5oDD4Ddpvc z>Cwj|Sv9l1%KE;ai$soUqC%K4Yo%vDbBrF(g8raCqm~{P*806FGDp08vkmKzxt4im zn$|g!)FDXCfjiggzV7Lq)g>zmJwzne!kDf>v&w?JVRlHlkV^{C0inSvV>dw;z4x+g zrl6K7&12k|Tg>DKcUG^=Z@0}t3!UZcRqqfgfO937m^ zR5SGxP#MF^%E_7aOuN18$yiR$K_Avkx?RXykuPZ(TerIYI3qb-o5R%CGdWC7Et5kr z^~}}xIV*P+RDONI04`#fq_s>IcA2U4cr?|A`40a{N+i#E_h#yk)Cw8qR1U*1wM;hr z^K2ty5k8DOAL>ysd0)bW&r3@sG6@5MC8edM`NuA#N{&4cEz+{-*t$m_a$BW%pkWRY zAS9B)UoMi8jwsdmgvrhnY0%`_(n=vCVUqT%BV@eH2q7s!a}gSc!q1%eo>vbb3mB%p zAbBqID@6hx4=qybKqC>-HAYBDcSTkOBS`@C?)g)s$|#9W6u@FGS`S$rIm0}uYLYsMAojMs6PJAS*tUbl^bu$MyRq6b8iyeZMT#8616&VxO8jw4N8IfRlq^_hD;;`*Q zhnzjCka#*&Zn+ zqwFKn9Rx~)g@-WzU7@LMr-q0*2zvJb*9yw51rkWr@EZ!m*F#Dk2&hK6{cr?_zZWUP zQv=~%q6TUK{wDMQQ&c3K`x?8ndB#(@R7g6SpL5Z&XR;m);1OP7{Z zM62$4YSY%6xxUElM$a^T({_CZzWN&7UVnzy%NgN&UPtXc?pNG*<>)!fRq9jxs?{ST zF zMg!~#uRC9Q6w=D$<`w5J2iu(g;2c&uvcu&w&n``M(^9=MN%Z=;=W^wXpC9^Vo$M~q zS*xCa&g8?mFB^t+g`VBfx&Wq!W=5V<$m_BD`@QXAc+#u87FY(;K78{oA;ob|iBl zFNmu_#R%ao1tKkB_=pFUc<4Bv>8?7rfRWV|)n?V_jL$k=k@J~xudL0IJzdwsS(YO% zsO6|Sf0!aGEjXTC`<`LG*H?aZ6G(|ZiBL(4^X62e$&RBvQ~BkPcT1yPq9knS z;I!D+ojgjpEkYEn3v~e2Hc-4!Sm1!))TOR~SEB-$Wf4MJJVz+dosHWDCJ5$U2t47+lz>}{90_e>?LIsK;cy_>y(v?q0GKVO^MS@|ccbQHU zw;u^p!|a?bs4l^3|NO~O$m)q@{gl9+5_s?-4(74&)NI)gGMbjS{GPmN&RWyt&2MIY zGxK|qY;zl6epip52vJ=A|G|vqFWM2D#pM)lg^nVx5Rfm5!p-P!KlvjsFep?s%DaW}5TS zboHB=CN7$X{$pl@?1;^ql13AZH%#%(z^!1zd|?@$hQuGd!o0aYhDrS08vaa4!}Wh$ z7QY+M=8XO(vN<13SHGEQ;-Yy-!Hf#~do^EJk2nhrXPDMfpQ9&VUp{bLn=C)>QQ3LR zybhf;hhMlf>g$-u1pzbr-HU#Bz>EoX9{+f?^Buo=JKot?-#@L^CqQ_-w!-$=QP-ub zUhY#}Vt0prFm79F#e2Qi#^Xm!ZMSh_RK?T2XY*B;7mYugTQuBx@u0ic>>tJ6UATC> zuor2Ty=IyB+EKGphTpfn<2@myq^eu--V|wFm&f(j{JpNu=ja^#azF0DbFX!VdEYkn zw78bDUZ>UfZKJ+TFy3`?v3kWm()nrL=Y;xOGrEl4>T@FK+*H>dLn_9Yd)4>7bb>eb z-uS_OrjxT~I3I{U_r_+dRhiB5Idpq^dXFvbA1+yh=3D+@GU455BO{#(=N^le zjJlDOC%rhOL};Bby4-Ev9p&!+XK%)~_(H^=jOlzzKRGFd7!IyJ0O%5Zu6~?Z+L771 zV^o3d-tnh0%2lI3sEc;)@#cqG!J$H3w&z6P;)+^n7QS1L|I!JnNc>{!_>3d#@sRV$mPxb+uFL%8qn**>8nEXPxmXGJFhC)9(S&t4d+2wXJ1?9 z>gSx46di8Kmvc)N*sTspA0GDnUBU|6%(0d`x*NPV9eU56JoGX?Ox-Rn?skyo?jEz5 z8C%y+l-Na94pAoiHYxQ);%^&6u46$>2qgY zYm5$(+ML*9Y4a9u8FHxa+)q2-`(#Z^c5k0%Z8&erm&Zfx_C7;} zPb0#g^Crx(-Bqz9C;9%%>yr+9o(LW}_M*xc$8ei=s_x0_d&%OL2OY8u`ZCkZ>!@() z`hzF7ow&&FcDQ^^FV3)*Qwr3s4L3ANo7m|B<$UCw($(nMH8r`Hhg{8mWErohjnVa4P;p{N*&i(i6-Z_i&R|@A*tl-V zq$jmjCHE*OP1l@SA^yanH-yy8TcLL#ZHORWd7e_$p>yQcU4tCE7A&Z3b+ax$kG_cr zhc-HA_NVDpjeT26eU=%V4ykf+aT!)$nHRtOu+Sqk^%q2&RtqduUEdx9@t${u)Y7XUaR-|e;%ZkI@YM#;{7I-n~pZ6f;D%j zklixVGi&eUi7l(wzern3Y#w-|&uhyMol$?++_X#F(#|Hz*<-3}2cKpxg&N52-Mi<; z)JGP0JL+^K-cH-$dZTuhY)`q#M>G2e18=MunmPONqRorx^EnTPW)OZrmow0nd$OI{ zQs(3PtFwwdEB1GKsrZ5{_pp=IZ>@9$x(f;p7L9Z2u<>AUMrE&Zjp&m1 zwFSGwN_BSjs-N|AlkfCH?Wp_PLwrKD9)^d`wSOb2i7hvrHNJlL&=YnQ%XyAUqmfbj zD+>vvyEyK`_flJ7c9CCv=QE+3Z!9`7Ypl4_ot=+v<xZI1cqKj+BxHea7<^f!BEWsE zdrD7>Vn>2pTN!(KS5?_n=_zUODdI}n!V|-tY;LzHF|;(z$h|&CZe6>jGG^UrSO8ED zvpd=n0a`}MU?o?btfYXCSArzQ*XE2Ta*^&)R7QNUJm10s*>_e;mBNCf%333Yf8buJ zjM5n9CDZHsi0IN5c*TvDgoBJ3u_jv1>}PV`!D66z=o-T!mk5+H7Z!Kf=H%46q5}*g zDUex?h4n2;iKvVe)m<+mTS#xuCyfhXIZ?gL7>noJMKdH-Ig8@&y;uuE3nn40M;#G* zh03%mD|2976D-TBWwMxectkYan@~=B zzp7t&hQHLRdT*e0wknx?Sv+poyfx|Unkgz>aCMEps`WcQ4uEJojLij^=-m zm%8jQ{jAKJziaH(RJ92SAs;&Sd;CZi^!l~#;ll172UpiFSxBusGurFOS@ntGk@@y< zB5~LJ`JtCGX~WWaF87aq_R{cO>hMV|Qh(;`MOycR&gnllZ9@-F*u8FHSW<4`mq!oW zj5|7X+q-yq@Q8@XOI5crA6`m7e0sg(o@ej^r};aj(^`CTI!z>P-ZjR0v;8Wu!6{Y# z_oGZ3;E015b>?=g%gKowJB6y(Y<@7vePSzRCahn8N2hs{RVK_-JNM3JTc-ylSsbs5Pv=X&*>q;l8*9k3Izp1%-t{Z!;I?=Do_s%EQfP`^IjcZ;+hr zfAr3(RkP1B1}<&Z4E1_sw<n7HoJy>;koS{^pHG0w4(a@_<{rWBeoVC?c=*w{HBP=lv{c=J%R=hHZz3P;CSS zXH-tK?Lrm|yU@AyAi8NRIy9VXeygy#E#O?ds zWrWR(i=o4J_gxVqJ{)!Jk4^e_Qk+DwZ9D0Hm8A=J_j9tD?>b$p`ybl=Ml-hTXn&<_ z|53N&(~7xv>-2_@0j4&}PmX1#XUUGJsEm3xSNTHkQljhN1-X^lU#`v8;>L=1#rb5- zOKb1A&tS!j*{}G9uWILfA3f=h>rW5Ms`tvatf=4QEp?7QXmBr3vqSzPi;af@%wX#f zHSka6w|DCF8h&AV59^uSrM^V*ouo%!4Lgsty|E6xxlRv2{i!uf+3Co5iOaIWY{??Y zwGk_PH3vWHP~UQ4ZSRF;)9(s`|7hEJVe}%4%ORR+?*hVeGY@2MKAdRZTXI}``u*Y- zwj-sYck%fLI}M1l&g@FB4@K4{?YoXzrg>z}>C4xznwCX;O3~1n)^5>4L%XdL(5xec z6=yCFy{~z=BdWGe<%{xE-U;hUCKVJcH#t!%ew`7avu*A9JJX#cO0Q)a?c+A~;I22n ztoC|x`|H!zbGF8|X=`P+Xa9%b%-c5$_ByufY1>eqW!f|9^D1le3koW0Wd#KcEELvI z;$~jx4i?QtXA?KS9IT3%-BRg^nr~-H9~6{~co?ebGPc8T0k6GPYWT*l(ecM;&dOs- zPcx-r+1rodcR#+%{yNN6~|{SKf3<> z@-eD9+;cp2<4N!9CKv3@&;QEQmN2!eC9yUeqE4OMU*o^S^=3hr8*O_EY6wOZ)`&XM z2syPv9b!a$RMuOmg@d3It#!zJ>6ZgLpB^r)e4ZD*ZJ2Mh!1O)(#1VB=ONO=VLfaDQ0cj>V4j;XEYsCeLp%?_64X{+M3 ztOCRSIO2<$={cHq&mjkvifE5Z52gxj1#fz(KA` ztH?=3SNQyff)&xg3 z8YL_d<~3yZTVq^f_QQYAbTm@&)A#=$Ch@<@K7WI0ZezbqtT`V|SHGEQ;-Y!zH#5!o zXuA5%OcNK)L;sJMQEG$xA59zcwN_TgZ!`%?uO-6!5%htL!A2m9GG%y+LzG=GfgMTU zMGudL4O5VaI~r-hCM#uJksflCZWhhcRUJa2T-f{onYY7BAeMT-NPs3aQ)nHeWJzd| z@Ov72qD)mJDnmm!qG@n82s&ql2=2!Vc5YjV)IO*@PRe(@+MCoFTxw8zLG50QVdD^*>)1{lt_4gDynXk~6#@COCE}z_G!^_??d_RSzjl5m4WBF`R z=*eTtbS?~Z41ZSEr{ehRF5BOy50~`WVaNCxW_{ZEIq$=@U3YKJ(m}lR1MLJx);3*E z#-=XY{`x%e^zv-WZqo8axfkx#UfPeT$mVY%HN?_fb!pOf)B-&qlx`F1ur-7+NAnK5 zf6P(@yQ0vD9*j_TRF}LA4dDVwZU_}uk2W2;t=~J%DMc1Uo=3#V=i6NSjCOM$cHog- z{xG*wbK17(*|vAo?B(}2-(7s8P$zxr5tY~$yGkZ^(XUL*vj6B8qIn_r@fbhoMjq~IMK-t%0GILoC3osbG*H}g}4%|Q@sHw8Ph zRK)g~^eM0Nr1DiKS3eF=GclXq^F-^29<9@hhrJQbGTh`fPc-CdRe#~zP-sT-|rSpj`Wyd-kY|hpQyn4{iFkDI7H~BfJIV4e8vuuKytBO^t_9Amyoh#VNk5o)OY}B#| zHh$TpreSR+BB%23o7*V0B-n-oUgP2lZjTHRsfVUP-Gy2%SfspKofIL!gSeZJKzF_x z_<&`IXOD=?;c)B@scnVG2q}V%{6P{4d!3M?GAhzjK+&I8ezg+>b;sFQG^+>;bdgRWhY0_JZZ~Lxr zFfvN>Q_t^oWtRU?<<2o~YTLLTV#&RYapPL}RV~`M_LPCQZdCtI-bQKe`}V%!PSetQ z-m9N;eTe7vk0(v&8goiU+m z>XD;wxjS;!_zyWXZ^?Oj?Dx#$wdUGel|=&4k*!B|cepY_lx%)oZKNmHj__PMFW# zNu;?<{Uh#DrpnP&+p&keZ<3|1$NEUG8EoV)*e-M&6cbs~BYa@6Z`qOoHW)x12mbfC zWp66pp0ujG#&qLd&dQW?d0mQBFT7XUe*DP>^X^6G2Msvmt$sW>U7gT1$>O>O*h(J+ zsT60ZoeLYhVKSlP?M!T)Rz06YdI1*(PC=WyjrTj2rqboC)1~pmAi8mM=|P`mVm{zpH<` zW5T>O4|bP1+_b*fZ_nX*FLqs9U-af#l+g0(lK~p1KDNAh$7>>g%ED3Arl|*92by~% z+>@QF>G?eL@LK<^eWw`v*W{eZyj$IB$nC0)p_Xc=m$r47+TuKQI+xpT>P5#@)`{~T zuW#4d(0w0~YT9#GU5hhE@0H&RgUrgyA94EzcU&PU?^Ne{cz8E{QLxwXUFGj=s(Zc2 z8y0cQB{_8Fz{n$O9wxVbJzAz}Y%zMI+M0qRIR_Fnjz0_T*d}1{YTi1TrR9#OY$fd; z3EQ77I2USi`S#;;WtY2J5qaagE!{TU!}z=J<=K~41)*0?i#jo6*ykg4-wY607J%$D z*Q>tGkXmqa5%+dx=?>aSTZ7VjA2)n}3N0KV4WFDa!DVVkJ-{kOsnTRvQi(dieoG#O z0_3>q&6?8CXZlI^v!xsd?s=C9~V!oNZdGU7eRcc*l5S z!LnWnc`mc8^5;G+(kVZxWIj7AMtiWK==y}O@dcZooIjB(Dx2`V)56o+gYNd7^n~~M z;MoP8w-*IIc(^{seOBsBzkuxLB(Ha`@F%AXY(Cw7DOu>g;-2?}^N)0U)Ry{2_d8b- z?b2i0V>9897h#_dPLM5V{o>vf>scSq>6k=CWEOXdf^~x}hH3;HJ!yQ;)Gt)y`{f(^ z_eR8j)?0ErU-ZUZyT_F=Xr5EbGHv0={QFCW$CkwL+U*#tvhc(qn{Vw-*`G)m6rGwf zMCAUi{@RBHn!9;D5EqE=<~L`u^P!yD7dj)%dtKwZRl3g#Ud6l`Sb1dijVY(fxT+i5 znw?hLnC)MY{)xEc{HMPAyQwq9^q(YvDk zsB~M(US-z^J3+y(O&B_vpzpjy(bn`Z@XtQV#)~jXf5Gtz0rDtP|N#iuEk@PylJ_^dd{rU z&Bl+bW{zCqWwx%r?0NfJhbxblcKNl zwgk-<)_>@~ZPo+zpfYCQsLJQ{q3w=4R1es=uk93b5Dytv(hKe zGL%3GlG5DOf_)Lm8hzMf-p~TBG!wcEQmA(LFt`|Pq(-9Gg)MQHBgHy2!hQ>7(H6>= z>w&CxEixOVgT6mg<8cVCIS4~EtSP4TIw~P8FFg_I^?hOHAv97t>=4Ran7UBl{ct{b zhGe8m^XwcEobe+@Nc|!~NV#K_28vX3VW~2ggy>}LA;`=?*+dk}JCJKXTxly9oRk(J zk3=*CS#zC4&>Pae=z#<`J|ERc?lb37LFdw%gb{{k%0eFnBt)d!N|@T-*M&P}rCJ@e z)=cY~(e=gm?4?!KohO&2c6)a)S6f|amn&qe4-UIp` zT6okqt8iBLyy7?2T@2IFwZ8qe_ZD3}U!s%pM=wNv_sf5!{y8H-GArafy?E1?%4?^y z#miMJ&peyHE@Q*{Td6*uZ6M2+e@bO-g~FCBEyKP`ldsf#eLC^M=5q^ddaQL>Y%z831k0-Y8{KVV6Bf)hl}0!(aV(lyexR1S zX(!@JrN{d?)NU_Ok>c?J2d(447v4Bt1BwI6%#$SM9UM>UXu`&mw$ zwW05ugrEpSiAV&OU>#+8>cyp{%*+BA6NGzz1+p5TotcTdZneVeIUi+k1%0~xTBuwb zcHWef))&+<@4v#r%7SmQf-_9b7rcC*IR;(BTE-EYH%21M$&u8*nRPg??oIXH3{63< zzJ{?$W^Qf?V^HX=gz8nwjGz~Eg7cNh7(mw6*5>3NXPBoTfH|Hc1Lkw`aYs~^R~~i> zz5obQq=f~CF$J@j-V9S+2R+o@SxoQJoP;_Cy0bZ-Wy}slfOY|s!IYMYh1L1R-G#9U zMcX;t@IlEuRWqvtwTyBfZWrte!%<0Y9kYFj0QaC(mAr64z=qDGMk|j#h={{%a}akV z;<}!{&2kU-JTh!gu8_o{5D*82A_RKY}Tc zWkezLmpsnaL$6+BGz1!9L!yj3L`2Yt5E%g$L|AF04MZ_5Ax4+goQCKWm>@vP91)_g z*rO`~QQ~WG0;EWr7(f6&a5e+&PIWBz3h*PR3P!f0XCKMX2f^-d@zR z3L!K;oOL8aw4`V8FB>@68WB=ehiw~CCm3XKku4VnF+Ywt&<+9y9f$&k)h%zpXhRvr zaD)scqmeRDY9y&r!Gx>mzcfHv)B&7Fb5v)5H&aE+xLujKdU&H&7$mnPOP~#cy;GfF z6hn@HXGowAL=f{4I>)7%fkD$`RJsW357)Tcp#udu4E*jRhq+mM>^b52*#@kqy9A@0 z#6fuA;@9v6Ehbz&12`ZOZ^!u4K`4YPaE3p`UYOZI-RzM_9UT;jkOx#y*pX6&wKakk z*cqX=$_6l2(?Z_}0n(#kIEVy}D^tBx2%;^bd8%s2l{kMR&rlV^;87m()6%ply^e@9 zA<5QASA)I9g;9D}4P)F&)6z6{KTNll(isE~jya-fO{6PjlNrPL6eKQI2@y#n=|X4$ zrW22Hg}ke75J%X-MY< z=~lycmj)&H8jg5{+%(;X0&Dz4!<~k(q_M!@Y*;8TPfMQa4CDzsnc9OVhIAg5g=y2F z_~76n+Jrsf&|S>37Kz+}nTch5d&~@^bQjmXW(mRZLm6bkut%?s$5?*x!P#zzj#GeO ze=zF}AYzd>^tDU|k?e1Trdp=0h(S}KHgxDnx4_D#00_ZV;S4kdu#PLU6qWN?)0!f# zGgw*2k;e~a9BGEnViV>PitAdet>eh!2Qm&F8J671 z4X|JbAV6fvNyGnwW&Q$K!=bGh@eBp9XbddSlE;&vKoLUrBy0YD04oko0fbX^#JSAU ztHL=bKwKu-S`IZOKwL-dtiwlq4xuNjJYQ*6mNhR8ElaN7Vuj2k{9KjxPLj-epbt zOBDL&;ZY=Q@@okGF3eyxz~CPSgRQjlauC9;WpV=eed_^)uNDI;!L?xyasiKkk0B6# zRxmFBp+tmFX8V(c`~c>&0?Y|6QGP{-uw|?WZ2IM2IufUhy8t|FY zG+QaWC>|VE3@*4MNwTjAJOLm$C8%Z&%-~xLTmQY}0B0gEh#plYc~j44(|U>Pgs}_< z;(^z{UBYh^2sgF?gq>(w?qtB|BheJ-4;a8?CfLwqN;(Zq22V5?Gst=za8S(BN`-^M z3^qEb3}6r(6qLZ<2KZ6|FuffB>_WpU5Wq8u;>nM@wjiN*3lZ)>VK{P!euWdJknPh< zl2aIkCPDNBmO}Yi+43@mcqoIvjWAz`BfN>#zXXEfLX_HC6BM8pr$}PG&g@6<}(G%Kd?{b0!)3j zX&nPF1JM`&qC}DM!&gTEnf~nI?W7Cikk7y#`EYx*9A_{Z4HqQ%JSg!t4R`7&ut~TB zUA(`I^sxfzc8pX7khWzHkaq*5e(c$@DnMvM93_<>H_HK!cnf|Qibq+aX57K!xLfK#vPR^+xzjh8jR^h@+%a*(hazn$N(SJ-F>E zhw6`9;X+vs)v)0X3sruH^S4>OtANU$*Q-g<#_Ty{!zrEz!W&X(Qlva)7$p^>VHmm# z6PC{yu^Ac;IM-JSz1gH{;xYyb-?-sUT@FwxzoYWEapoy-veik8qC3D2`y?9VLc=h3 zG8zP7U5YeAx)2yhNhhFz5a5fY7^^AtPr*k4*0q&FvAp9du7cg_`HgpExcKFF)PKSn z=z^#0;e0|DJahhwmXZ0Pi*iE&GZP!BE=9``4Wf7x|7RErn_Il;QuMiGHjZSFzfyanCBHR+2U?T(cMrxV5L9B8;SZupdCfZWBO52 zSJV$;^uP+iB(0DhTnvC31132Z4PfuE7#r^}*d*n5TK*j>a}^kuW0P#BMYG+PAUMSw z1zAD=n;lU3Q&Q-=0XDyU=s)^}!;1>cUPIqZ24_a_8~SVvE@C#`>+is7Iw_ZJc-m8R z;}OEV#zTckc}y=#Iuy?8C6OT*9#Gc{8Nx*$aTNyWiu$m3WVm?ccY23OnVfirZBVfP3~fggw6RDnC|bmxPTZB^*~37e zg!76?c}y2dDnAv|6siMoC}a<9wiMT49tX204x5S@T)^@>U4c(rz=K&a9sYI!A5||Bi*4u zx*C^kABrB%`U_)<*9#c~loOQn1~6EKi&xH|Ou?Wx_z^gi3n=u4F_lLl%pfuxb~O02 z`Jcs*Iw;EQJwL#YLQ&^*neL2hM!eC$W>4H z=T)!$S@j^f>Pi2+>Qz6hc9N?W|MRMs|EzkrT(#G)RhRvX#`8Zhv!>INB)Ha%pt$n0 zotZ^)2wRDN+|n2QoO?I9YJbQ*^XE$T-^~5|pH+{Ms}A~6b*`d*|2I|7Rj6i(A4_oq z(O7W6Oo|-BdLUkr`+o*PiULA|cY}$0?A@mQ^SMv_Sv5@8W7Sdryy}FXReQ=+NB{Gx zXDL+2;mXFmrFd0Dpj>^Dyd`O9QwskPn~PECpM~|mrKHv9E!bQXG-g2lLTFI`Y;rDD z;BJx#|DXCEKR6pPyMzvU3x6?9K3$=HI-C8^6ueCIm+O}PI{TJYaA0M?&okgZ=(haj z41|myr|pj`ll&8KbSLD!MsNI@ zft~O8H*kFaf`L*65VioAa7vI&1cqUT8BQ|#w_Nx(Ot<_S^?x;RYkE&HgDrqB4VqQ| zeJT7GG=EZLT(0?BgJ$(#YX(QlcB%dgnw#D&$Te3sXy$`v23tM0yj+FZ{}(kky)%$& zt_iDS%USy$*WC0nK(4vIL37K0TyxVTe?yZ(G^p15$5l5?+{?WS!wr++>d!IwLA4;4 zASm2x7gQx83Oeoo1q*AMhi_17nPTGGWu2C|Ev0&=Cm7l*J;q*>4(hIsO%r$y=kg?Cd+#(N?=YD zVJo9JT;f+b7t}R3^uI%V(u*|NjH