diff --git a/tomcat_bec/device_configs/microxas_test_bed.yaml b/tomcat_bec/device_configs/microxas_test_bed.yaml index 1edd1ef..f27b883 100644 --- a/tomcat_bec/device_configs/microxas_test_bed.yaml +++ b/tomcat_bec/device_configs/microxas_test_bed.yaml @@ -99,7 +99,7 @@ es1_psod: onFailure: buffer readOnly: false readoutPriority: monitored - softwareTrigger: false + softwareTrigger: true es1_ddaq: diff --git a/tomcat_bec/devices/aerotech/AerotechPso.py b/tomcat_bec/devices/aerotech/AerotechPso.py index eb7c8d7..aeb4001 100644 --- a/tomcat_bec/devices/aerotech/AerotechPso.py +++ b/tomcat_bec/devices/aerotech/AerotechPso.py @@ -75,6 +75,7 @@ class AerotechPsoDistanceMixin(CustomDeviceMixin): def on_trigger(self) -> None | DeviceStatus: """Fire a single PSO event (i.e. manual software trigger)""" # Only trigger if distance was set to invalid + logger.warning(f"[{self.parent.name}] Triggerin...") if self.parent.dstDistanceVal.get() == 0: status = self.parent._eventSingle.set(1, settle_time=0.1) return status diff --git a/tomcat_bec/devices/aerotech/AerotechTasks.py b/tomcat_bec/devices/aerotech/AerotechTasks.py index 5a763bf..1432e4a 100644 --- a/tomcat_bec/devices/aerotech/AerotechTasks.py +++ b/tomcat_bec/devices/aerotech/AerotechTasks.py @@ -55,7 +55,7 @@ class AerotechTasksMixin(CustomDeviceMixin): settle_time = 0.2 if self.parent._is_configured: if self.parent._text_to_execute is not None: - status = self.parent._execute.set(self._text_to_execute, settle_time=settle_time) + status = self.parent._execute.set(self.parent._text_to_execute, settle_time=settle_time) else: status = self.parent.switch.set("Run", settle_time=settle_time) else: diff --git a/tomcat_bec/devices/gigafrost/stddaq_client.py b/tomcat_bec/devices/gigafrost/stddaq_client.py index 8f7f47b..cbf8c63 100644 --- a/tomcat_bec/devices/gigafrost/stddaq_client.py +++ b/tomcat_bec/devices/gigafrost/stddaq_client.py @@ -36,7 +36,7 @@ class StdDaqMixin(CustomDeviceMixin): # Fish out our configuration from scaninfo (via explicit or generic addressing) scanparam = self.parent.scaninfo.scan_msg.info alias = self.parent.parent.name if self.parent.parent is not None else self.parent.name - logger.warning(f"[{alias}] Scan parameters:\n{scanparam}") + # logger.warning(f"[{alias}] Scan parameters:\n{scanparam}") d = {} if 'kwargs' in scanparam: scanargs = scanparam['kwargs'] @@ -52,6 +52,10 @@ class StdDaqMixin(CustomDeviceMixin): # Perform bluesky-style configuration if len(d) > 0: + # Stop if current status is not idle + if self.parent.state() != "idle": + self.parent.safestop() + # Configure new run (will restart the stdDAQ) logger.warning(f"[{self.parent.name}] Configuring with:\n{d}") self.parent.configure(d=d) @@ -64,19 +68,9 @@ class StdDaqMixin(CustomDeviceMixin): file_path = self.parent.file_path.get() num_images = self.parent.num_images.get() message = {"command": "start", "path": file_path, "n_image": num_images} - - # FIXME: This should be simplified once we don't crash the DAQ with stop ii = 0 - self.parent.connect() while True: - try: - reply = self.parent.message(message) - except ConnectionClosedOK: - # # NOTE: Sometimes the stdDAQ rejects both start and stop commands, needs nuking - # self.parent.restart() - # sleep(0.5) - self.parent.connect() - reply = self.parent.message(message) + reply = self.parent.message(message) if reply is not None: reply = json.loads(reply) @@ -106,14 +100,8 @@ class StdDaqMixin(CustomDeviceMixin): def on_unstage(self): """ Stop a running acquisition and close connection """ - if self.parent._wsclient is None: - self.parent.connect() - try: - self.parent.message({"command": "stop"}, wait_reply=False) - except (ConnectionClosedOK, ConnectionClosedError): - self.parent.connect() - self.parent.message({"command": "stop"}, wait_reply=False) + self.parent.message({"command": "stop_all"}, wait_reply=False) except (RuntimeError, TypeError): # The poller thread locks recv raising a RuntimeError pass @@ -136,12 +124,12 @@ class StdDaqMixin(CustomDeviceMixin): for msg in self.parent._wsclient: message = json.loads(msg) self.parent.status.put(message["status"], force=True) - logger.warning(f"[{self.parent.name}] {message['status']}") + logger.info(f"[{self.parent.name}] Pushed status: {message['status']}") except (ConnectionClosedError, ConnectionClosedOK, AssertionError): # Libraty throws theese after connection is closed return except Exception as ex: - logger.warning(f"[{self.parent.name}] {ex}") + logger.warning(f"[{self.parent.name}] Exception in polling: {ex}") return finally: self._mon = None @@ -164,7 +152,7 @@ class StdDaqClient(PSIDeviceBase): """ # pylint: disable=too-many-instance-attributes custom_prepare_cls = StdDaqMixin - USER_ACCESS = ["set_daq_config", "get_daq_config", "safestop", "restart", "connect"] + USER_ACCESS = ["set_daq_config", "get_daq_config", "safestop", "restart", "connect", "message", "state"] _wsclient = None # Status attributes @@ -236,19 +224,23 @@ class StdDaqClient(PSIDeviceBase): "The stdDAQ websocket interface refused connection 5 times.") logger.debug(f"[{self.name}] Connected to DAQ after {num_retry} tries") - def message(self, message: dict, timeout=1, wait_reply=True): + def message(self, message: dict, timeout=1, wait_reply=True) -> None | str: """Send a message to the StdDAQ and receive a reply Note: finishing acquisition means StdDAQ will close connection, so there's no idle state polling. """ + # Connect if client was destroyed + if self._wsclient is None: + self.connect() + # Send message (reopen connection if needed) + msg = json.dumps(message) if isinstance(message, dict) else str(message) try: - msg = json.dumps(message) if isinstance(message, dict) else str(message) self._wsclient.send(msg) - except (ConnectionClosedError, ConnectionClosedOK) as ex: - print(ex) - # self.connect() + except (ConnectionClosedError, ConnectionClosedOK, AttributeError) as ex: + # Re-connect if the connection was closed + self.connect() self._wsclient.send(msg) # Wait for reply @@ -257,8 +249,8 @@ class StdDaqClient(PSIDeviceBase): try: reply = self._wsclient.recv(timeout) return reply - except (ConnectionClosedError, ConnectionClosedOK, TimeoutError) as ex: - print(ex) + except (ConnectionClosedError, ConnectionClosedOK, TimeoutError, RuntimeError) as ex: + logger.error(f"[{self.name}] Error in receiving ws reply: {ex}") return reply def configure(self, d: dict = None): @@ -308,6 +300,10 @@ class StdDaqClient(PSIDeviceBase): # Restart the DAQ if resolution changed cfg = self.get_daq_config() if cfg['image_pixel_height'] != self.cfg_pixel_height.get() or cfg['image_pixel_width'] != self.cfg_pixel_width.get(): + # Stop if current status is not idle + if self.state() != "idle": + self.safestop() + # Stop running acquisition self.unstage() # Update retrieved config @@ -359,24 +355,44 @@ class StdDaqClient(PSIDeviceBase): cfg = self.get_daq_config() self.set_daq_config(cfg) - def safestop(self): - """ - The current stdDAQ refuses connection if another session is running. This is safety so - we don't accidentally kill a running exposure. But this also means that we have to wait - until a dead session dies of timeout. + def state(self) -> str | None: + """ Querry the current system state""" + r = self.message({'command': 'status'}, wait_reply=True) + if r is None: + return None + else: + r = json.loads(r) + return r['status'] - NOTE: REST reconfiguration restarts with systemd and can corrupt currently written files. + def safestop(self, timeout=5): + """ Stops a running acquisition + + REST reconfiguration restarts with systemd and can corrupt currently written files. """ - try: - if self._wsclient is not None: - self._wsclient.close() - self._wsclient = connect(self.ws_url.get()) - msg = json.dumps({"command": "stop"}) - self._wsclient.send(msg) - except (ConnectionClosedError, ConnectionClosedOK, ConnectionRefusedError): - pass - self._staged = Staged.no - sleep(1) + # Retries to steal connection from poller + state = self.state() + for rr in range(5): + try: + r = self.message({"command": "stop_all"}) + if r is not None: + r = json.loads(r) + logger.warning(f"[{self.name}] Stop-all command {r['status']}") + else: + # NOTE: stdDAQ just closes connection if idle + logger.warning(f"[{self.name}] Stop-all command unknown") + finally: + sleep(0.2) + + # Wait until status is back to idle + if timeout > 0.2: + wait_time = 0 + while wait_time < timeout/5: + state = self.state() + if state is not None: + if state in ['idle', 'stoped']: + return + sleep(0.2) + wait_time += 0.2 # Automatically connect to microXAS testbench if directly invoked diff --git a/tomcat_bec/devices/psimotor.py b/tomcat_bec/devices/psimotor.py index a6fadfb..158979f 100644 --- a/tomcat_bec/devices/psimotor.py +++ b/tomcat_bec/devices/psimotor.py @@ -68,7 +68,8 @@ class EpicsMotorMR(EpicsMotor): except UnknownStatusFailure: status = DeviceStatus(self) status.set_finished() - return status + # return status + raise class EpicsMotorEC(EpicsMotorMR): diff --git a/tomcat_bec/scans/__init__.py b/tomcat_bec/scans/__init__.py index 9292522..c526363 100644 --- a/tomcat_bec/scans/__init__.py +++ b/tomcat_bec/scans/__init__.py @@ -1,2 +1,3 @@ from .tutorial_fly_scan import AcquireDark, AcquireFlat, TutorialFlyScanContLine from .tomcat_scanbase import SnapAndStepScanBase, GigaStepScanBase, SequenceScanBase, SimpleFlyer +from .tomcat_scans import TomcatStepScan, TomcatSnapNStep, TomcatSimpleSequence diff --git a/tomcat_bec/scans/tomcat_scans.py b/tomcat_bec/scans/tomcat_scans.py new file mode 100644 index 0000000..5ad5fe1 --- /dev/null +++ b/tomcat_bec/scans/tomcat_scans.py @@ -0,0 +1,415 @@ +# -*- coding: utf-8 -*- +""" Tomcat scan base class examples + +A collection of example scan base classes using Automation1 rotation stage, +GigaFrost camera and the StandardDAQ pipeline. + +Created on Mon Sep 16 16:45:11 2024 + +@author: mohacsi_i +""" +import jinja2 +import os +import time + +from bec_lib import bec_logger +from bec_server.scan_server.scans import AsyncFlyScanBase, ScanBase + +logger = bec_logger.logger + + + +class TomcatStepScan(ScanBase): + """Simple software step scan forTomcat + + Example class for simple BEC-based step scans using the low-level API. All it does is + translate conventional kwargs to tomcat specific naming scheme. + + Example + ------- + >>> scans.gigastep(scan_start=-25, scan_end=155, steps=180, exp_time=0.005, exp_burst=5) + """ + + scan_name = "tomcatstepscan" + required_kwargs = ["scan_start", "scan_end", "steps"] + gui_config = { + "Movement parameters": ["steps"], + "Acquisition parameters": ["exp_time", "burst_at_each_point", "roix", "roiy"], + } + + def _get_scan_motors(self): + self.scan_motors = ["es1_roty"] + + def __init__( + self, + scan_start: float, + scan_end: float, + steps: int, + exp_time=0.005, + settling_time=0.2, + burst_at_each_point=1, + roix=2016, + roiy=2016, + sync="event", + **kwargs, + ): + # Converting generic kwargs to tomcat device configuration parameters + # Used by gigafrost + kwargs['parameter']['kwargs']["exposure_time_ms"] = 1000 * exp_time + kwargs['parameter']['kwargs']["exposure_period_ms"] = 2 * 1000 * exp_time + kwargs['parameter']['kwargs']["exposure_num_burst"] = burst_at_each_point + kwargs['parameter']['kwargs']["image_width"] = roix + kwargs['parameter']['kwargs']["image_height"] = roiy + # Used by stdDAQ and DDC + kwargs['parameter']['kwargs']["num_points_total"] = burst_at_each_point * (steps + 1) + t_modes = {"pso": 0, "event": 1, "inp0": 2, "inp1": 4} + ddc_trigger = t_modes[sync] + kwargs['parameter']['kwargs']["ddc_trigger"] = ddc_trigger + # Use PSO trigger + kwargs['parameter']['kwargs']["pso_wavemode"] = "pulsed" + + super().__init__( + exp_time=exp_time, + settling_time=settling_time, + relative=False, + burst_at_each_point=1, + optim_trajectory=None, + **kwargs, + ) + + # For position calculation + self.motor = "es1_roty" + self.scan_start = scan_start + self.scan_end = scan_end + self.scan_steps = steps + self.scan_stepsize = (scan_end - scan_start) / steps + + + def _calculate_positions(self) -> None: + """Pre-calculate scan positions""" + for ii in range(self.scan_steps + 1): + self.positions.append(self.scan_start + ii * self.scan_stepsize) + + + + + +class TomcatSnapNStep(AsyncFlyScanBase): + """Simple software step scan forTomcat + + Example class for simple BEC-based step scans using the low-level API. All it does is + translate conventional kwargs to tomcat specific naming scheme. + + Example + ------- + >>> scans.tomcatsnapnstepscan(scan_start=-25, scan_end=155, steps=180, exp_time=0.005, exp_burst=5) + """ + + scan_name = "tomcatsnapnstepscan" + required_kwargs = ["scan_start", "scan_end", "steps"] + gui_config = { + "Movement parameters": ["steps"], + "Acquisition parameters": ["exp_time", "burst_at_each_point", "roix", "roiy"], + } + + def _get_scan_motors(self): + self.scan_motors = ["es1_roty"] + + def __init__( + self, + scan_start: float, + scan_end: float, + steps: int, + exp_time=0.005, + settling_time=0.2, + burst_at_each_point=1, + roix=2016, + roiy=2016, + sync="event", + **kwargs, + ): + # Converting generic kwargs to tomcat device configuration parameters + # Used by gigafrost + kwargs['parameter']['kwargs']["exposure_time_ms"] = 1000 * exp_time + kwargs['parameter']['kwargs']["exposure_period_ms"] = 2 * 1000 * exp_time + kwargs['parameter']['kwargs']["exposure_num_burst"] = burst_at_each_point + kwargs['parameter']['kwargs']["image_width"] = roix + kwargs['parameter']['kwargs']["image_height"] = roiy + # Used by stdDAQ and DDC + kwargs['parameter']['kwargs']["num_points_total"] = burst_at_each_point * (steps + 1) + t_modes = {"pso": 0, "event": 1, "inp0": 2, "inp1": 4} + ddc_trigger = t_modes[sync] + kwargs['parameter']['kwargs']["ddc_trigger"] = ddc_trigger + # Use PSO trigger (disable triggering) + kwargs['parameter']['kwargs']["pso_distance"] = 0 + + # For position calculation + self.scan_start = scan_start + self.scan_end = scan_end + self.scan_steps = steps + self.scan_stepsize = (scan_end - scan_start) / steps + self.scan_ntotal = burst_at_each_point * (steps + 1) + + # Aerotech DDC settings: Internal event trigger: PsoEvent = 1 + p_modes = {"pso": "PsoOutput", "event": "PsoEvent", "inp0": "HighSpeedInput0RisingEdge", "inp1": "HighSpeedInput1RisingEdge",} + filename = "AerotechSnapAndStepTemplate.ascript" + filesubs = { + "startpos": self.scan_start, + "stepsize": self.scan_stepsize, + "numsteps": self.scan_steps, + "exptime": 2 * exp_time * burst_at_each_point, + "settling": settling_time, + "psotrigger": p_modes[sync], + "npoints": self.scan_ntotal, + } + filetext = self.render_file(filename, filesubs) + + # Task inteface + kwargs['parameter']['kwargs']["script_text"] = filetext + kwargs['parameter']['kwargs']["script_file"] = "bec.ascript" + + + super().__init__( + exp_time=exp_time, + settling_time=settling_time, + relative=False, + burst_at_each_point=1, + optim_trajectory=None, + **kwargs, + ) + + def render_file(self, filename, filesubs): + """Prepare action: render AeroScript file""" + # Load the test file + if filename is not None: + filename = os.path.join( + os.path.dirname(__file__), "../devices/aerotech/" + filename + ) + logger.info(f"Attempting to load file {filename}") + with open(filename) as f: + templatetext = f.read() + + # Substitute jinja template + tm = jinja2.Template(templatetext) + filetext = tm.render(scan=filesubs) + return filetext + + def scan_core(self): + """The actual scan routine""" + print("TOMCAT Running scripted scan (via Jinjad AeroScript)") + t_start = time.time() + + # Complete + # FIXME: this will swallow errors + # yield from self.stubs.complete(device="es1_tasks") + st = yield from self.stubs.send_rpc_and_wait("es1_tasks", "complete") + st.wait() + task_states = yield from self.stubs.send_rpc_and_wait("es1_tasks", "taskStates.get") + if task_states[4] == 8: + raise RuntimeError(f"Task {4} finished in ERROR state") + + t_end = time.time() + t_elapsed = t_end - t_start + print(f"Elapsed scan time: {t_elapsed}") + time.sleep(0.5) + + # Collect + # if self.daqname is not None and self.daqmode=="collect": + # print("TOMCAT Collecting scripted scan results (from EPICS)") + # positions = yield from self.stubs.send_rpc_and_wait(self.daqname, "collect") + # logger.info(f"Finished scan with collected positions: {positions}") + + + def cleanup(self): + """Set scan progress to 1 to finish the scan""" + self.num_pos = 1 + return super().cleanup() + + + + + + +class TomcatSimpleSequence(AsyncFlyScanBase): + """Simple software step scan forTomcat + + Example class for simple BEC-based step scans using the low-level API. All it does is + translate conventional kwargs to tomcat specific naming scheme. + + Example + ------- + >>> scans.tomcatsimplesequencescan(33, 180, 180, exp_time=0.005, exp_frames=1800, repeats=10) + """ + + scan_name = "tomcatsimplesequencescan" + scan_report_hint = "table" + required_kwargs = ["scan_start", "gate_high", "gate_low"] + gui_config = { + "Movement parameters": ["scan_start", "repeats", "repmode"], + "Acquisition parameters": [ + "gate_high", + "gate_low", + "exp_time", + "exp_frames", + "roix", + "roiy", + "sync", + ], + } + + def _get_scan_motors(self): + self.scan_motors = ["es1_roty"] + + def __init__( + self, + scan_start: float, + gate_high: float, + gate_low: float, + repeats: int=1, + repmode: str="PosNeg", + exp_time: float=0.005, + exp_frames: float=180, + roix: int=2016, + roiy: int=2016, + sync: str="pso", + **kwargs, + ): + # Auto-setup configuration parameters from input + self.scan_start = scan_start + self.gate_high = gate_high + self.gate_low = gate_low + self.scan_repnum = repeats + self.scan_repmode = repmode.upper() + self.exp_time = exp_time + self.exp_frames = exp_frames + + # Synthetic values + self.scan_velocity = gate_high / (exp_time * exp_frames) + self.scan_acceleration = 500 + self.scan_safedistance = 10 + self.scan_accdistance = ( + self.scan_safedistance + + 0.5 * self.scan_velocity * self.scan_velocity / self.scan_acceleration + ) + self.scan_ntotal = exp_frames * repeats + + if self.scan_repmode in ("POS", "NEG"): + self.scan_range = repeats * (gate_high + gate_low) + elif self.scan_repmode in ("POSNEG", "NEGPOS"): + self.scan_range = gate_high + gate_low + else: + raise RuntimeError(f"Unsupported repetition mode: {repmode}") + + + + # Converting generic kwargs to tomcat device configuration parameters + # Used by gigafrost + kwargs['parameter']['kwargs']["exposure_time_ms"] = 1000 * exp_time + kwargs['parameter']['kwargs']["exposure_period_ms"] = 2 * 1000 * exp_time + kwargs['parameter']['kwargs']["exposure_num_burst"] = exp_frames + kwargs['parameter']['kwargs']["image_width"] = roix + kwargs['parameter']['kwargs']["image_height"] = roiy + # Used by stdDAQ and DDC + kwargs['parameter']['kwargs']["num_points_total"] = exp_frames * (self.scan_repnum + 1) + t_modes = {"pso": 0, "event": 1, "inp0": 2, "inp1": 4} + ddc_trigger = t_modes[sync] + kwargs['parameter']['kwargs']["ddc_trigger"] = ddc_trigger + # Use PSO trigger (disable triggering) + kwargs['parameter']['kwargs']["pso_distance"] = 0 + + # Aerotech DDC settings: Internal event trigger: PsoEvent = 1 + p_modes = {"pso": "PsoOutput", "event": "PsoEvent", "inp0": "HighSpeedInput0RisingEdge", "inp1": "HighSpeedInput1RisingEdge",} + filename = "AerotechSimpleSequenceTemplate.ascript" + filesubs = { + "startpos": self.scan_start, + "scanrange": self.scan_range, + "nrepeat": self.scan_repnum, + "scanvel": self.scan_velocity, + "scanacc": self.scan_acceleration, + "accdist": self.scan_accdistance, + "npoints": self.scan_ntotal, + "scandir": self.scan_repmode.upper(), + "psotrigger": p_modes[sync], + } + filesubs = self.get_positions(filesubs) + filetext = self.render_file(filename, filesubs) + + # Task inteface + kwargs['parameter']['kwargs']["script_text"] = filetext + kwargs['parameter']['kwargs']["script_file"] = "bec.ascript" + + + super().__init__( + exp_time=exp_time, + settling_time=0.5, + relative=False, + burst_at_each_point=1, + optim_trajectory=None, + **kwargs, + ) + + def render_file(self, filename, filesubs): + """Prepare action: render AeroScript file""" + # Load the test file + if filename is not None: + filename = os.path.join( + os.path.dirname(__file__), "../devices/aerotech/" + filename + ) + logger.info(f"Attempting to load file {filename}") + with open(filename) as f: + templatetext = f.read() + + # Substitute jinja template + tm = jinja2.Template(templatetext) + filetext = tm.render(scan=filesubs) + return filetext + + def scan_core(self): + """The actual scan routine""" + print("TOMCAT Running scripted scan (via Jinjad AeroScript)") + t_start = time.time() + + # Complete + # FIXME: this will swallow errors + # yield from self.stubs.complete(device="es1_tasks") + st = yield from self.stubs.send_rpc_and_wait("es1_tasks", "complete") + st.wait() + task_states = yield from self.stubs.send_rpc_and_wait("es1_tasks", "taskStates.get") + if task_states[4] == 8: + raise RuntimeError(f"Task {4} finished in ERROR state") + + t_end = time.time() + t_elapsed = t_end - t_start + print(f"Elapsed scan time: {t_elapsed}") + time.sleep(0.5) + + # Collect + # if self.daqname is not None and self.daqmode=="collect": + # print("TOMCAT Collecting scripted scan results (from EPICS)") + # positions = yield from self.stubs.send_rpc_and_wait(self.daqname, "collect") + # logger.info(f"Finished scan with collected positions: {positions}") + + def get_positions(self, filesubs: dict={}): + # Fill PSO vectors according to scan direction + # NOTE: Distance counter is bidirectional + pso_bounds_pos = [] + pso_bounds_neg = [] + if self.scan_repmode in ("pos", "Pos", "POS", "neg", "Neg", "NEG"): + pso_bounds_pos.append(self.scan_accdistance) + for ii in range(self.scan_repnum): + pso_bounds_pos += [self.gate_high, self.gate_low] + pso_bounds_pos = pso_bounds_pos[:-1] + if self.scan_repmode in ("posneg", "PosNeg", "POSNEG", "negpos", "NegPos", "NEGPOS"): + pso_bounds_pos = [self.scan_accdistance, self.gate_high] + pso_bounds_neg = [self.scan_accdistance + self.gate_low, self.gate_high] + + filesubs["psoBoundsPos"] = pso_bounds_pos + filesubs["psoBoundsNeg"] = pso_bounds_neg + filesubs["psoBoundsPosSize"] = len(pso_bounds_pos) + filesubs["psoBoundsNegSize"] = len(pso_bounds_neg) + return filesubs + + def cleanup(self): + """Set scan progress to 1 to finish the scan""" + self.num_pos = 1 + return super().cleanup() \ No newline at end of file