From 578be60a2d5c2f65157e2a7c45d585dbaba9dad7 Mon Sep 17 00:00:00 2001 From: hitz_s Date: Wed, 9 Sep 2026 10:11:43 +0200 Subject: [PATCH] wip --- .../bec_ipython_client/plugins/auto_gain.py | 94 ++++-- .../digital_twin_core/digital_twin_core.py | 149 +++++++++- .../digital_twin_core/motion_worker.py | 269 ++++++++++++++++++ .../plugins/digital_twin_core/types.py | 8 +- .../widgets/digital_twin/digital_twin.py | 97 ++++--- .../digital_twin/panels/input_panel.py | 58 ++-- .../digital_twin/panels/mover_panel.py | 28 +- .../digital_twin/widgets/move_widget.py | 265 ++--------------- 8 files changed, 634 insertions(+), 334 deletions(-) create mode 100644 debye_bec/bec_ipython_client/plugins/digital_twin_core/motion_worker.py diff --git a/debye_bec/bec_ipython_client/plugins/auto_gain.py b/debye_bec/bec_ipython_client/plugins/auto_gain.py index a5ccd5c..35b8d14 100644 --- a/debye_bec/bec_ipython_client/plugins/auto_gain.py +++ b/debye_bec/bec_ipython_client/plugins/auto_gain.py @@ -6,11 +6,12 @@ from bisect import bisect_right import xraydb from bec_lib import bec_logger +from ophyd_devices import CompareStatus from ...devices.absorber import STATUS as ABS_STATUS from ...devices.eh_shutter import STATUS as EH_PH_STATUS from ...devices.ionization_chambers.ionization_chamber_enums import AmplifierEnable -from ...devices.nidaq.nidaq_enums import NidaqState +from ...devices.nidaq.nidaq_enums import EpicsMode, NidaqState from ...devices.op_shutter import STATUS as OP_PH_STATUS logger = bec_logger.logger @@ -21,10 +22,12 @@ EMAX = 200 MIN_RING_CURRENT = 5 # Minimum ring current to use auto-gain NOMINAL_RING_CURRENT = 400 # Nominal ring current of SLS2 +TIMEOUT_PUT_PV = 5 # Timeout to set a PV + MONO_VELOCITY = 20 # Move velocity in deg/s -TIMEOUT_MONO_PV = 5 # Timeout to set a PV on the mono TIMEOUT_MONO_MOVE = 30 # Timeout to finish a movement on the mono +LINKED_CHANNELS = {"ic0", "ic1"} AVAILABLE_GAINS = [1e6, 1e7, 5e7, 1e8, 1e9] # ascending order MIN_SIGNAL = 0.05 # Minimum signal to count as valid signal FULL_SCALE_V = 10.0 # NIDAQ AI full-scale range @@ -58,6 +61,10 @@ class AutoGain: """Start the auto-gain sequence. Measure the signals of the specified amplifiers and set the gains accordingly. Makes sure there is actually beam available. + Note: + If 'ic0' and 'ic1' are in the amplifier argument, the same gain will be assigned + to both amplifiers to get the best glitch removal during processing later. + Args: element(str): Element which defines the energy at which the gain will be set, e.g. 'Cu' edge(str): Corresponding edge, e.g. 'L1' @@ -155,11 +162,16 @@ class AutoGain: init_vel = self.dev.mo1_bragg.velocity.get() logger.info(f"Move mono to start of {emin} eV") + self.dev.mo1_bragg.velocity.put(MONO_VELOCITY) + status = CompareStatus(self.dev.mo1_bragg.velocity, MONO_VELOCITY) + status.wait(TIMEOUT_PUT_PV) status = self.dev.mo1_bragg.move(emin) status.wait(TIMEOUT_MONO_MOVE) # Set NIDAQ to max mode - # TODO implement + self.dev.nidaq.epics_mode.put(EpicsMode.MAX) + status = CompareStatus(self.dev.nidaq.epics_mode, EpicsMode.MAX) + status.wait(timeout=TIMEOUT_PUT_PV) # Set gains to lowest gain for name, ch in active_channels.items(): @@ -180,15 +192,14 @@ class AutoGain: raise AutoGainError("Ring current dropped to 0 mA right before measurement") # Scan range, recording the peak NIDAQ signal per channel - status = self.dev.mo1_bragg.velocity.put(MONO_VELOCITY) - status.wait(TIMEOUT_MONO_PV) self.dev.mo1_bragg.move(emax).wait(timeout=TIMEOUT_MONO_MOVE) status.wait(TIMEOUT_MONO_MOVE) for name, ch in active_channels.items(): data[name] = max(data[name], ch["signal"].get()) # Rest max values of NIDAQ signals - # TODO implement + self.dev.nidaq.epics_max_reset.put(True) + time.sleep(0.1) # Measure current ring current again ring_current_2 = self._get_ring_current() @@ -202,52 +213,83 @@ class AutoGain: # Choose gain per channel based on the max signal recorded during the scan remeasure = False + processed = set() + for name, ch in active_channels.items(): - raw_signal = data[name] - logger.info(f"Raw signal for self.device {name} is {raw_signal} V") - if comp_ring_current: - raw_signal = raw_signal * NOMINAL_RING_CURRENT / ring_current - logger.info(f"Compensate for ring current, new raw signal is {raw_signal} V") - if raw_signal < MIN_SIGNAL: - logger.info(f"Raw signal for self.device {name} is below {MIN_SIGNAL}") - # Choose next gain to be 100x the current gain, or if this gain does not exist, - # choose the next smaller one - if ch["gain"] == AVAILABLE_GAINS[-1]: + if name in processed: + continue + + # ic0/ic1 must always end up with the same gain -> treat whichever of + # them are present (one or both) as a single group for this decision + if name in LINKED_CHANNELS: + group_names = [n for n in LINKED_CHANNELS if n in active_channels] + else: + group_names = [name] + + group_signals = [] + for gname in group_names: + gsig = data[gname] + logger.info(f"Raw signal for device {gname} is {gsig} V") + if comp_ring_current: + gsig = gsig * NOMINAL_RING_CURRENT / ring_current + logger.info(f"Compensate for ring current, new raw signal is {gsig} V") + group_signals.append(gsig) + + # worst case: the weakest signal drives "need more gain", + # the strongest signal drives "don't saturate" + raw_signal_min = min(group_signals) + raw_signal_max = max(group_signals) + gain_ref = ch[ + "gain" + ] # linked channels always share gain, so any member's value works + label = "/".join(group_names) + + if raw_signal_min < MIN_SIGNAL: + logger.info(f"Raw signal for {label} is below {MIN_SIGNAL}") + if gain_ref == AVAILABLE_GAINS[-1]: logger.warning( - f"Amplifier of {name} at highest gain {ch['gain']} and still not" + f"Amplifier of {label} at highest gain {gain_ref} and still not" + f" measured signal above {MIN_SIGNAL}" ) else: next_gain = AVAILABLE_GAINS[ - bisect_right(AVAILABLE_GAINS, ch["gain"] * 100) - 1 + bisect_right(AVAILABLE_GAINS, gain_ref * 100) - 1 ] - ch["self.dev"].set_gain(next_gain) - logger.info( - f"Setting gain of self.device {name} to {next_gain:.0e} and remeasure" - ) + for gname in group_names: + active_channels[gname]["dev"].set_gain(next_gain) + active_channels[gname]["gain"] = next_gain + logger.info(f"Setting gain of {label} to {next_gain:.0e} and remeasure") remeasure = True else: gain = max( ( g for g in AVAILABLE_GAINS - if raw_signal / ch["gain"] * g <= FULL_SCALE_V * SAFETY_MARGIN + if raw_signal_max / gain_ref * g <= FULL_SCALE_V * SAFETY_MARGIN ), default=min(AVAILABLE_GAINS), ) - ch["self.dev"].set_gain(gain) - logger.info(f"Calculated final gain for {name} of {gain:.0e}") + for gname in group_names: + active_channels[gname]["dev"].set_gain(gain) + active_channels[gname]["gain"] = gain + logger.info(f"Calculated final gain for {label} of {gain:.0e}") + + processed.update(group_names) # Wait for mono to return to start position status.wait(TIMEOUT_MONO_MOVE) # Reset NIDAQ to mean mode - # TODO implement + self.dev.nidaq.epics_mode.put(EpicsMode.MEAN) + status = CompareStatus(self.dev.nidaq.epics_mode, EpicsMode.MEAN) + status.wait(timeout=TIMEOUT_PUT_PV) # Wait for mono to move to initial position and reset velocity status = self.dev.mo1_bragg.move(init_pos) status.wait(TIMEOUT_MONO_MOVE) self.dev.mo1_bragg.velocity.put(init_vel) + status = CompareStatus(self.dev.mo1_bragg.velocity, init_vel) + status.wait(timeout=TIMEOUT_PUT_PV) def _get_ring_current(self) -> float: ring_current = 0 diff --git a/debye_bec/bec_ipython_client/plugins/digital_twin_core/digital_twin_core.py b/debye_bec/bec_ipython_client/plugins/digital_twin_core/digital_twin_core.py index 8acc1b5..b147608 100644 --- a/debye_bec/bec_ipython_client/plugins/digital_twin_core/digital_twin_core.py +++ b/debye_bec/bec_ipython_client/plugins/digital_twin_core/digital_twin_core.py @@ -1,4 +1,6 @@ +import builtins import re +import threading from pathlib import Path from typing import Literal, cast @@ -10,6 +12,7 @@ from xrt.backends.raycing.physconsts import AVOGADRO, CHeVcm from . import parameters as bl from .beamline import get_beamline_id +from .motion_worker import MotionWorker from .types import BeamlineId, ConfigDict, DataDict, SurfaceDict H = 6.62606957e-34 @@ -22,18 +25,65 @@ OFFSET_FILE_X10DA = Path(__file__).with_name("x10da_offsets.yaml") logger = bec_logger.logger -""" -The idea is to move the core logic of digital_twin to this file. Only keep the gui elements in the widget. -This way, the scheduler widget can access digital twin without loading the GUI (GUI is still needed for the item creation, but not the item execution) - Scheduler will extract assistant inputs (get_assistant_config) during item creation - Scheduler will use digital_twin.calculate_positons to calculate positions and digital_twin.move_all to move the motors -""" + +class _ExclusiveGroupManager: + """Coordinates one exclusive group: only motors from the same + subgroup may be active at once; a different subgroup must wait until + the active one fully finishes.""" + + def __init__(self): + self._cond = threading.Condition() + self._active_subgroup = None + self._active_count = 0 + + def acquire(self, subgroup_id): + with self._cond: + while self._active_subgroup is not None and self._active_subgroup != subgroup_id: + self._cond.wait() + self._active_subgroup = subgroup_id + self._active_count += 1 + + def release(self): + with self._cond: + self._active_count -= 1 + if self._active_count == 0: + self._active_subgroup = None + self._cond.notify_all() + + +# Each entry is one exclusive group, made of subgroups. Motors in the same +# subgroup may move concurrently; motors in different subgroups of the +# same group may not. Motors not listed here move completely freely. +# Example from below: Each listed cm_* motor needs to move alone, but +# sldi_gapx and sldi_gapy can move together. sldi_gapx can move together +# with ot_try +EXCLUSIVE_GROUPS: list[list[list[str]]] = [ + [["sldi_gapx", "sldi_gapy"], ["sldi_centerx", "sldi_centery"]], + [["cm_trx"], ["cm_roty"], ["cm_try"], ["cm_rotx"], ["cm_rotz"]], + [["fm_trx"], ["fm_roty"], ["fm_try"], ["fm_rotx"], ["fm_rotz"]], + [["sl1_gapx", "sl1_gapy"], ["sl1_centerx", "sl1_centery"]], + [["sl2_gapx", "sl2_gapy"], ["sl2_centerx", "sl2_centery"]], + [["ot_try"], ["ot_rotx"]], +] + + +class DigitalTwinCoreError(Exception): + """DigitalTwinCore specific error""" class DigitalTwinCore: def __init__(self): logger.info("This is the digital twin from the ipython client!") + dev = builtins.__dict__.get("dev") + bec = builtins.__dict__.get("bec") + if dev is None: + raise DigitalTwinCoreError("Did not get dev") + if bec is None: + raise DigitalTwinCoreError("Did not get bec") + self.dev = dev + self.bec = bec + self.beamline = get_beamline_id() self.offset_file = Path() match self.beamline: @@ -44,10 +94,69 @@ class DigitalTwinCore: self.offsets = {} self.load_offsets() + def _exclusive_group_key(self, motor: str): + """Return (group_index, subgroup_index) for `motor`, or (None, None) + if it isn't part of any exclusive group and can move freely.""" + for gi, group in enumerate(EXCLUSIVE_GROUPS): + for si, subgroup in enumerate(group): + if motor in subgroup: + return gi, si + return None, None + def move_with_config(self, config): + """ + Move all motors in `config` to their target positions, all starting at + once. Motors are free to move concurrently unless they belong to the + same exclusive group (see `EXCLUSIVE_GROUPS`) but different subgroups, + in which case one subgroup must fully finish before another starts. + + Returns: + dict: {"success": True, "moved": [...]} if every motor completed + without error, else {"success": False, "moved": [...], + "failures": {motor: error}}. + """ positions = self.calc_positions(self.beamline, config) positions = self.apply_offsets(positions, nested_config=True) - logger.info(f"Would now move to these positions: {positions}") + + managers: dict[int, _ExclusiveGroupManager] = {} + for motor in positions: + gi, _ = self._exclusive_group_key(motor) + if gi is not None: + managers.setdefault(gi, _ExclusiveGroupManager()) + + moved: list[str] = [] + failures: dict[str, Exception | str] = {} + results_lock = threading.Lock() + + def run_motor(motor: str, target: float): + gi, si = self._exclusive_group_key(motor) + manager = managers.get(gi) if gi is not None else None + if manager: + manager.acquire(si) + try: + worker = MotionWorker(self.beamline, self.dev, motor, target) + worker.run() # blocks this thread until finished or errored + with results_lock: + if worker.last_exception is not None: + failures[motor] = worker.last_exception + else: + moved.append(motor) + finally: + if manager: + manager.release() + + threads = [ + threading.Thread(target=run_motor, args=(motor, pos["value"]), daemon=True) + for motor, pos in positions.items() + ] + for t in threads: + t.start() + for t in threads: + t.join() + + if failures: + return {"success": False, "moved": moved, "failures": failures} + return {"success": True, "moved": moved} def load_offsets(self): if self.offsets == {}: @@ -255,7 +364,7 @@ class DigitalTwinCore: ## Focusing Mirror p = bl.fm.center[1] - q = cfg["smpl"] - bl.fm.center[1] + q = (cfg["ot_es1_trz"] + cfg["es1man_trz"]) - bl.fm.center[1] f = (p * q) / (p + q) # focal length # Bender radius @@ -350,9 +459,22 @@ class DigitalTwinCore: pos["ot_rotx"] = {"value": ot_pitch * 1e3} # TRZ ES1 - ot_es1_trz = cfg["smpl"] + ot_es1_trz = cfg["ot_es1_trz"] pos["ot_es1_trz"] = {"value": ot_es1_trz} + # Sample manipulator TRZ + es1man_trz = cfg["es1man_trz"] + if es1man_trz is None: + raise ValueError("es1man_trz is None") + pos["es1man_trz"] = {"value": es1man_trz} + + # TRZ ES2 + smpl_to_xrd = cfg["smpl_to_xrd"] + if smpl_to_xrd is None: + raise ValueError("smpl_to_xrd is None") + ot_es2_trz = smpl_to_xrd + ot_es1_trz + es1man_trz - 32 + pos["ot_es2_trz"] = {"value": ot_es2_trz} + # ES0 exit window pos["es0wi_try"] = { "value": 5 @@ -930,6 +1052,7 @@ class DigitalTwinCore: beam["y"].append(bl.sourceHeight) beam["x"].append(bl.cm.center[1]) # CM beam["y"].append(bl.sourceHeight) + smpl = cfg["ot_es1_trz"] + cfg["es1man_trz"] if cfg["mo1_mode"] == "Monochromatic": diag = bl.mo1.xtalGap[0] / np.sin(cfg["mo1_bragg"]) # Calculations for Mono dy = diag * np.sin(2 * (cfg["cm_pitch"] + cfg["mo1_bragg"])) @@ -951,23 +1074,23 @@ class DigitalTwinCore: + np.tan(2 * cfg["cm_pitch"]) * (bl.fm.center[1] - bl.cm.center[1] - dz) + dy ) - beam["x"].append(cfg["smpl"]) # Experiment + beam["x"].append(smpl) # Experiment beam["y"].append( bl.sourceHeight + np.tan(2 * cfg["cm_pitch"]) * (bl.fm.center[1] - bl.cm.center[1] - dz) + dy - + np.tan(2 * (cfg["cm_pitch"] - cfg["fm_rotx"])) * (cfg["smpl"] - bl.fm.center[1]) + + np.tan(2 * (cfg["cm_pitch"] - cfg["fm_rotx"])) * (smpl - bl.fm.center[1]) ) elif cfg["mo1_mode"] == "Pinkbeam": beam["x"].append(bl.fm.center[1]) # FM beam["y"].append( bl.sourceHeight + np.tan(2 * cfg["cm_pitch"]) * (bl.fm.center[1] - bl.cm.center[1]) ) - beam["x"].append(cfg["smpl"]) # Experiment + beam["x"].append(smpl) # Experiment beam["y"].append( bl.sourceHeight + np.tan(2 * cfg["cm_pitch"]) * (bl.fm.center[1] - bl.cm.center[1]) - + np.tan(2 * (cfg["cm_pitch"] - cfg["fm_rotx"])) * (cfg["smpl"] - bl.fm.center[1]) + + np.tan(2 * (cfg["cm_pitch"] - cfg["fm_rotx"])) * (smpl - bl.fm.center[1]) ) return beam diff --git a/debye_bec/bec_ipython_client/plugins/digital_twin_core/motion_worker.py b/debye_bec/bec_ipython_client/plugins/digital_twin_core/motion_worker.py new file mode 100644 index 0000000..dfbadf5 --- /dev/null +++ b/debye_bec/bec_ipython_client/plugins/digital_twin_core/motion_worker.py @@ -0,0 +1,269 @@ +"""Move an axis through BEC""" + +import threading +import time +from typing import Callable + +from bec_lib import bec_logger + +# pylint: disable=E0402 +from ....devices.absorber import STATUS as ABS_STATUS +from .types import BeamlineId + +logger = bec_logger.logger + + +class MotionWorker: + """ + Executes motion on the specified motor and includes some safety during + motion for certain motors. Pure Python / Qt-independent: reports + progress via plain callables instead of Qt signals, so it can run on + any thread (or be unit-tested without a QApplication). + """ + + def __init__( + self, + beamline: BeamlineId, + dev, + motor, + target_pos: float, + on_position_changed: Callable[[float], None] | None = None, + on_error: Callable[[], None] | None = None, + on_finished: Callable[[], None] | None = None, + ): + self.beamline = beamline + self.dev = dev + self.motor = motor + self._target = target_pos + self._stop_flag = threading.Event() + self.on_position_changed = on_position_changed + self.on_error = on_error + self.on_finished = on_finished + self.last_exception: Exception | None = None + + def _emit_position_changed(self, pos: float): + if self.on_position_changed is not None: + self.on_position_changed(pos) + + def _emit_error(self): + if self.on_error is not None: + self.on_error() + + def _emit_finished(self): + if self.on_finished is not None: + self.on_finished() + + def stop(self): + """Sets the stop flag""" + self._stop_flag.set() + + def run(self): + """Prepares the movement based on the axis (motor)""" + match self.motor: + case ( + "sldi_gapx" + | "sldi_gapy" + | "sldi_centerx" + | "sldi_centery" + | "sl1_gapx" + | "sl1_gapy" + | "sl1_centerx" + | "sl1_centery" + | "bm1_try" + | "sl2_gapx" + | "sl2_gapy" + | "sl2_centerx" + | "sl2_centery" + | "bm2_try" + | "es0wi_try" + | "es1_try" + | "es2_try" + | "ot_try" + | "ot_rotx" + | "ot_es1_trz" + | "ot_es2_trz" + | "es1ic0_try" + | "es1ic1_try" + | "es1ic2_try" + ): + self.motion() + case "cm_trx": + self.motion( + abs_closed=True, + surveyed_axes=[{"device": self.dev["cm_roty"], "abs_tol": 0.05}], + ) + case "cm_roty": + self.motion( + abs_closed=True, surveyed_axes=[{"device": self.dev["cm_trx"], "abs_tol": 0.05}] + ) + case "cm_try": + self.motion( + abs_closed=True, + surveyed_axes=[ + {"device": self.dev["cm_rotx"], "abs_tol": 0.05}, + {"device": self.dev["cm_rotz"], "abs_tol": 0.05}, + ], + ) + case "cm_rotx": + self.motion( + abs_closed=True, + surveyed_axes=[ + {"device": self.dev["cm_try"], "abs_tol": 0.05}, + {"device": self.dev["cm_rotz"], "abs_tol": 0.05}, + ], + ) + case "cm_rotz": + self.motion( + abs_closed=True, + surveyed_axes=[ + {"device": self.dev["cm_try"], "abs_tol": 0.05}, + {"device": self.dev["cm_rotx"], "abs_tol": 0.05}, + ], + ) + case "cm_bnd": + if self.beamline == "x01da": + p1 = ( + 1 / (self.dev.cm_bnd_radius.read()["cm_bnd_radius"]["value"] * 1e3) + 0.0284 + ) / 2e-6 + p2 = (1 / (self._target * 1e3) + 0.0284) / 2e-6 + else: + p1 = 541900 / self.dev.cm_bnd_radius.read()["cm_bnd_radius"]["value"] - 32570 + p2 = 541900 / self._target - 32570 + self._target = p2 - p1 + self.motion(relative=True, rb={"device": self.dev["cm_bnd_radius"]}) + case "mo1_try" | "mo1_trx" | "mo1_roty": + self.motion(abs_closed=True) + case "mo1_bragg_angle": + if self.beamline == "x01da": + self.motion() + else: # x10da needs to move goniometer + self.motion(alias="mo1_rotx") + case "fm_trx": + self.motion( + abs_closed=True, + surveyed_axes=[{"device": self.dev["fm_roty"], "abs_tol": 0.05}], + ) + case "fm_roty": + self.motion( + abs_closed=True, surveyed_axes=[{"device": self.dev["fm_trx"], "abs_tol": 0.05}] + ) + case "fm_try": + if self.beamline == "x01da": + abs_tol = 0.05 + else: # superxas mirror less stable thus needs higher tolerance + abs_tol = 0.2 + self.motion( + abs_closed=True, + surveyed_axes=[ + {"device": self.dev["fm_rotx"], "abs_tol": abs_tol}, + {"device": self.dev["fm_rotz"], "abs_tol": abs_tol}, + ], + ) + case "fm_rotx": + if self.beamline == "x01da": + abs_tol = 0.05 + else: # superxas mirror less stable thus needs higher tolerance + abs_tol = 0.2 + self.motion( + abs_closed=True, + surveyed_axes=[ + {"device": self.dev["fm_try"], "abs_tol": abs_tol}, + {"device": self.dev["fm_rotz"], "abs_tol": abs_tol}, + ], + ) + case "fm_rotz": + if self.beamline == "x01da": + abs_tol = 0.05 + else: # superxas mirror less stable thus needs higher tolerance + abs_tol = 0.2 + self.motion( + abs_closed=True, + surveyed_axes=[ + {"device": self.dev["fm_try"], "abs_tol": abs_tol}, + {"device": self.dev["fm_rotx"], "abs_tol": abs_tol}, + ], + ) + case "fm_bnd": + if self.beamline == "x01da": + p1 = ( + 1 / (self.dev.fm_bnd_radius.read()["fm_bnd_radius"]["value"] * 1e3) + + 4.28e-5 + ) / 1.84e-9 + p2 = (1 / (self._target * 1e3) + 4.28e-5) / 1.84e-9 + else: + p1 = ( + 593088.7 / self.dev.fm_bnd_radius.read()["fm_bnd_radius"]["value"] + + 26124.41 + ) + p2 = 593088.7 / self._target + 26124.41 + self._target = p2 - p1 + self.motion(relative=True, rb={"device": self.dev["fm_bnd_radius"]}) + case _: + logger.warning(f"Motor {self.motor} not integrated in digital twin!") + + def motion( + self, + abs_closed: bool = False, + relative: bool = False, + rb=None, + surveyed_axes=None, + alias=None, + ): + """ + Moves an axis while surverying a set of axes (if set). + Example surveyed_axes: + [{'device': bec_device_object, 'abs_tol': 0.1},] + + Args: + surveyed_axes (list): List of dictionaries of devices + """ + logger.info(f"Move axis {self.motor} to target {self._target}, move_relative={relative}") + try: + if alias: + self.motor = alias + if abs_closed: + if self.dev.abs.status.get() == ABS_STATUS.OPEN: + status = self.dev.abs.close() + status.wait(timeout=5) + if surveyed_axes is not None: + for surv_ax in surveyed_axes: + surv_ax["name"] = surv_ax["device"].dotted_name + surv_ax["old_value"] = surv_ax["device"].read(cached=True)[surv_ax["name"]][ + "value" + ] + if rb is not None: + rb["name"] = rb["device"].dotted_name + status = self.dev[self.motor].move(self._target, relative=relative) + last_check = time.time() + update_interval = 0.1 + while status.status == "RUNNING": + now = time.time() + if time.time() - last_check < update_interval: + time.sleep(0.01) + last_check = now + if self._stop_flag.is_set(): + self.dev[self.motor].stop() + self._stop_flag.clear() + if rb is not None: + self._emit_position_changed(rb["device"].read(cached=True)[rb["name"]]["value"]) + else: + self._emit_position_changed( + self.dev[self.motor].read(cached=True)[self.motor]["value"] + ) + if surveyed_axes is not None: + for surv_ax in surveyed_axes: + fb = surv_ax["device"].read(cached=True)[surv_ax["name"]]["value"] + if abs(fb - surv_ax["old_value"]) > surv_ax["abs_tol"]: + self.dev[self.motor].stop() + self.last_exception = RuntimeError( + f"{surv_ax['name']} deviated beyond tolerance {surv_ax['abs_tol']} during {self.motor} move" + ) + self._emit_error() + self._emit_finished() + return + self._emit_finished() + except Exception as e: + logger.error(f"Error during movement of {self.motor}: {e}") + self.last_exception = e + self._emit_error() + self._emit_finished() diff --git a/debye_bec/bec_ipython_client/plugins/digital_twin_core/types.py b/debye_bec/bec_ipython_client/plugins/digital_twin_core/types.py index d5c0393..7c66f50 100644 --- a/debye_bec/bec_ipython_client/plugins/digital_twin_core/types.py +++ b/debye_bec/bec_ipython_client/plugins/digital_twin_core/types.py @@ -32,7 +32,9 @@ class ConfigDict(TypedDict): fm_trx (float): FM translation x. fm_qy (float): FM qy value. fm_gain_height (int): FM gain height. - smpl (float): Sample value. + ot_es1_trz (float): Position of ES1 table. + es1man_trz (float): Position of Sample Manipulator TRZ + smpl_to_xrd (float): Sample to XRD detector distance """ energy: float @@ -49,7 +51,9 @@ class ConfigDict(TypedDict): fm_trx: float fm_qy: None | float fm_gain_height: int - smpl: float + ot_es1_trz: float + es1man_trz: float + smpl_to_xrd: None | float class DataDict(TypedDict): diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index 2a01e02..0901da6 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -42,7 +42,6 @@ from .panels.input_panel import InputPanel from .panels.mover_panel import MoverPanel from .panels.plots import SideviewPlot, SurfacePlots from .panels.settings_panel import SettingsPanel -from .widgets.qt_widgets import ComboBox, InputNumberField logger = bec_logger.logger @@ -133,11 +132,11 @@ class DigitalTwin(BECWidget, QWidget): self.input.fm_rotx.value_changed_connect(self.calc_assistant) self.input.fm_focx.value_changed_connect(self.calc_assistant) self.input.fm_focy.value_changed_connect(self.calc_assistant) - match self.input.smpl: - case InputNumberField(): - self.input.smpl.value_changed_connect(self.calc_assistant) - case ComboBox(): - self.input.smpl.activated_connect(self.calc_assistant) + if self.beamline == "x01da": + self.input.ot_es1_trz.value_changed_connect(self.calc_assistant) + else: + self.input.table.activated_connect(self.calc_assistant) + self.input.es1man_trz.value_changed_connect(self.calc_assistant) self.input.adapt_reality.clicked_connect(self.adapt_reality) self.settings.load_offsets.clicked_connect(self.load_offsets) @@ -216,6 +215,7 @@ class DigitalTwin(BECWidget, QWidget): "fm_rotz", "bm2_try", "es0wi_try", + "es1man_trz", ] if self.beamline == "x01da": # X01DA specific devices devices.extend( @@ -226,6 +226,7 @@ class DigitalTwin(BECWidget, QWidget): "sl2_gapy", "ot_try", "ot_es1_trz", + "ot_es2_trz", ] ) if self.beamline == "x10da": # X10DA specific devices @@ -344,7 +345,9 @@ class DigitalTwin(BECWidget, QWidget): self.calc_fm_reflectivity() self.calc_cm_fm_harm_suppr() self.calc_fm_ideal_pitch() - case "smpl": + case "ot_es1_trz": + self.calc_fm_ideal_pitch() + case "es1man_trz": self.calc_fm_ideal_pitch() self.calc_positions() self.calc_assistant_sideview() @@ -380,12 +383,15 @@ class DigitalTwin(BECWidget, QWidget): assert cm_trx is not None, f"No cm_trx found for given stripe {cm_stripe}!" assert fm_trx is not None, f"No fm_trx found for given stripe {fm_stripe}!" - match self.input.smpl: - case InputNumberField(): - smpl = self.input.smpl.value() - case ComboBox(): - table = self.input.smpl.currentText() - smpl = self.core.table_to_smpl_pos(table) + if self.beamline == "x01da": + ot_es1_trz = self.input.ot_es1_trz.value() + smpl_to_xrd = self.input.smpl_to_xrd.value() + else: + table = self.input.table.currentText() + ot_es1_trz = self.core.table_to_smpl_pos(table) + smpl_to_xrd = None + + es1man_trz = self.input.es1man_trz.value() config: ConfigDict = { "energy": self.input.energy.value(), @@ -402,7 +408,9 @@ class DigitalTwin(BECWidget, QWidget): "fm_trx": fm_trx, "fm_qy": fm_qy, "fm_gain_height": 1, - "smpl": smpl, + "ot_es1_trz": ot_es1_trz, + "es1man_trz": es1man_trz, + "smpl_to_xrd": smpl_to_xrd, } # Apply offsets @@ -441,13 +449,16 @@ class DigitalTwin(BECWidget, QWidget): fm_stripe = self.core.fm_trx_to_stripe(-fm_trx) fm_rotx = self.dev.fm_rotx.read(cached=True)["fm_rotx"]["value"] fm_rotx_real = 2 * cm_pitch - fm_rotx + es1man_trz = self.dev.es1man_trz.read(cached=True)["es1man_trz"]["value"] - match self.input.smpl: - case InputNumberField(): - smpl = self.dev.ot_es1_trz.read(cached=True)["ot_es1_trz"]["value"] - case ComboBox(): - table = self.input.smpl.currentText() - smpl = self.core.table_to_smpl_pos(table) + if self.beamline == "x01da": + ot_es1_trz = self.dev.ot_es1_trz.read(cached=True)["ot_es1_trz"]["value"] + ot_es2_trz = self.dev.ot_es2_trz.read(cached=True)["ot_es2_trz"]["value"] + smpl_to_xrd = ot_es2_trz - ot_es1_trz - es1man_trz + 32 + else: + table = self.input.table.currentText() + ot_es1_trz = self.core.table_to_smpl_pos(table) + smpl_to_xrd = None raw = { # Config in SI units! "energy": mo1_bragg["mo1_bragg"]["value"], @@ -464,7 +475,9 @@ class DigitalTwin(BECWidget, QWidget): "fm_trx": fm_trx, "fm_qy": None, "fm_gain_height": 1, - "smpl": smpl, + "ot_es1_trz": ot_es1_trz, + "es1man_trz": es1man_trz, + "smpl_to_xrd": smpl_to_xrd, } config = cast(ConfigDict, raw) # logger.info(f'Config created: {config}') @@ -517,7 +530,9 @@ class DigitalTwin(BECWidget, QWidget): if self.beamline == "x01da": self.mover.ot_try.set_feedback(self.dev.ot_try.read(cached=True)["ot_try"]["value"]) self.mover.ot_rotx.set_feedback(self.dev.ot_rotx.read(cached=True)["ot_rotx"]["value"]) - self.mover.ot_es1_trz.set_feedback(smpl) + self.mover.ot_es1_trz.set_feedback(ot_es1_trz) + self.mover.ot_es2_trz.set_feedback(ot_es2_trz) + self.mover.es1man_trz.set_feedback(es1man_trz) self.mover.es0wi_try.set_feedback( self.dev.es0wi_try.read(cached=True)["es0wi_try"]["value"] ) @@ -552,8 +567,14 @@ class DigitalTwin(BECWidget, QWidget): pos["fm_trx"] = self.dev.fm_trx.read(cached=True)["fm_trx"]["value"] pos["fm_rotx"] = self.dev.fm_rotx.read(cached=True)["fm_rotx"]["value"] pos["fm_bnd_radius"] = self.dev.fm_bnd_radius.read(cached=True)["fm_bnd_radius"]["value"] + pos["es1man_trz"] = self.dev.es1man_trz.read(cached=True)["es1man_trz"]["value"] if self.beamline == "x01da": pos["ot_es1_trz"] = self.dev.ot_es1_trz.read(cached=True)["ot_es1_trz"]["value"] + pos["ot_es2_trz"] = self.dev.ot_es2_trz.read(cached=True)["ot_es2_trz"]["value"] + pos["smpl_to_xrd"] = pos["ot_es2_trz"] - pos["ot_es1_trz"] - pos["es1man_trz"] + 32 + else: + table = self.ask_table_selection(self.input.table.currentText()) + pos["ot_es1_trz"] = self.core.table_to_smpl_pos(table) # Removing offsets pos = self.core.remove_offsets(pos) @@ -577,14 +598,14 @@ class DigitalTwin(BECWidget, QWidget): fm_rotx_real = 2 * pos["cm_rotx"] - pos["fm_rotx"] self.input.fm_rotx.set_number(fm_rotx_real) - match self.input.smpl: - case InputNumberField(): - smpl = pos["ot_es1_trz"] - self.input.smpl.set_number(pos["ot_es1_trz"]) - case ComboBox(): - table = self.ask_table_selection(self.input.smpl.currentText()) - smpl = self.core.table_to_smpl_pos(table) - self.input.smpl.set_current_text(table) + if self.beamline == "x01da": + self.input.ot_es1_trz.set_number(pos["ot_es1_trz"]) + self.input.smpl_to_xrd.set_number(pos["smpl_to_xrd"]) + else: + table = self.ask_table_selection(self.input.table.currentText()) + self.input.table.set_current_text(table) + + smpl = pos["ot_es1_trz"] + pos["es1man_trz"] fm_focx, fm_focy = self.core.calc_beamsize( h_acc, v_acc, fm_stripe, -fm_rotx_real * 1e-3, pos["fm_bnd_radius"] * 1e6, smpl @@ -866,7 +887,9 @@ class DigitalTwin(BECWidget, QWidget): self.mover.ot_try.set_target(out["ot_try"]["value"]) self.mover.ot_rotx.set_target(out["ot_rotx"]["value"]) self.mover.ot_es1_trz.set_target(out["ot_es1_trz"]["value"]) + self.mover.ot_es2_trz.set_target(out["ot_es2_trz"]["value"]) self.mover.es0wi_try.set_target(out["es0wi_try"]["value"]) + self.mover.es1man_trz.set_target(out["es1man_trz"]["value"]) if self.beamline == "x10da": self.mover.es1_try.set_target(out["es1_try"]["value"]) self.mover.es1ic0_try.set_target(out["es1ic0_try"]["value"]) @@ -918,12 +941,14 @@ class DigitalTwin(BECWidget, QWidget): Literal["Defocused", "Focused", "Manual"], self.input.fm_focus.currentText() ) fm_stripe = self.input.fm_stripe.currentText() - match self.input.smpl: - case InputNumberField(): - smpl = self.input.smpl.value() - case ComboBox(): - table = self.input.smpl.currentText() - smpl = self.core.table_to_smpl_pos(table) + es1man_trz = self.input.es1man_trz.value() + if self.beamline == "x01da": + ot_es1_trz = self.input.ot_es1_trz.value() + else: + table = self.input.table.currentText() + ot_es1_trz = self.core.table_to_smpl_pos(table) + smpl = ot_es1_trz + es1man_trz + sldi_hacc = self.input.sldi_hacc.value() * 1e-3 sldi_vacc = self.input.sldi_vacc.value() * 1e-3 fm_focx = self.input.fm_focx.value() diff --git a/debye_bec/bec_widgets/widgets/digital_twin/panels/input_panel.py b/debye_bec/bec_widgets/widgets/digital_twin/panels/input_panel.py index 5c4cd1b..3b4a377 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/panels/input_panel.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/panels/input_panel.py @@ -173,7 +173,45 @@ class InputPanel(QWidget): # Sample self.cm_fm_harm_suppr = NumberIndicator("Total Suppression Factor at x eV", "", decimals=0) - self.smpl = self._create_smpl(beamline) + + self.es1man_trz = InputNumberField( + "es1man_trz", + "Sample Manipulator TRZ", + unit="mm", + init=0, + decimals=1, + single_step=1, + ll=-60, + hl=60, + ) + + if beamline == BeamlineId.X01DA: + self.ot_es1_trz = InputNumberField( + "ot_es1_trz", + "ES1 Position", + unit="mm", + init=23511, + decimals=0, + single_step=100, + ll=23000, + hl=30000, + ) + self.smpl_to_xrd = InputNumberField( + "smpl_to_xrd", + "Sample to XRD Det", + unit="mm", + init=320, + decimals=0, + single_step=10, + ll=320, + hl=6000, + ) + self.smpl_group = Group( + "Sample Position", [self.ot_es1_trz, self.es1man_trz, self.smpl_to_xrd] + ) + else: + self.table = ComboBox("table", "Sample Position", ["ES1", "ES2"]) + self.smpl_group = Group("Sample Position", [self.ot_es1_trz, self.es1man_trz]) # Assemble complete assistant group self.input_group = Group( @@ -186,25 +224,9 @@ class InputPanel(QWidget): self.mo1_ass_group, self.fm_ass_group, self.cm_fm_harm_suppr, - self.smpl, + self.smpl_group, ], ) self._layout.addWidget(self.input_group) self._layout.addStretch() - - def _create_smpl(self, beamline: BeamlineId) -> Union[InputNumberField, ComboBox]: - match beamline: - case BeamlineId.X01DA: - return InputNumberField( - "smpl", - "Sample Position", - unit="mm", - init=23511, - decimals=0, - single_step=100, - ll=23000, - hl=30000, - ) - case BeamlineId.X10DA: - return ComboBox("smpl", "Sample Position", ["ES1", "ES2"]) diff --git a/debye_bec/bec_widgets/widgets/digital_twin/panels/mover_panel.py b/debye_bec/bec_widgets/widgets/digital_twin/panels/mover_panel.py index 148d0ef..bd43e05 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/panels/mover_panel.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/panels/mover_panel.py @@ -343,6 +343,16 @@ class MoverPanel(QWidget): deadband=5, ) self.mover_widgets.append(self.ot_es1_trz) + self.es1man_trz = MoveWidget( + beamline=beamline, + dev=dev, + motor="es1man_trz", + label="MANIP TRZ", + unit="mm", + decimals=2, + deadband=0.1, + ) + self.mover_widgets.append(self.es1man_trz) if beamline == "x10da": self.es1_try = MoveWidget( @@ -390,13 +400,25 @@ class MoverPanel(QWidget): self.mover_widgets.append(self.es1ic2_try) if beamline == "x01da": - self.es1_mov_group = Group("Experimental Station 1", [self.ot_es1_trz]) + self.es1_mov_group = Group("Experimental Station 1", [self.ot_es1_trz, self.es1man_trz]) else: self.es1_mov_group = Group( "Experimental Station 1", [self.es1_try, self.es1ic1_try, self.es1ic2_try] ) # Experimental Station 2 + if beamline == "x01da": + self.ot_es2_trz = MoveWidget( + beamline=beamline, + dev=dev, + motor="ot_es2_trz", + label="ES2 TRZ", + unit="mm", + decimals=0, + deadband=5, + ) + self.mover_widgets.append(self.ot_es2_trz) + if beamline == "x10da": self.es2_try = MoveWidget( beamline=beamline, @@ -409,6 +431,9 @@ class MoverPanel(QWidget): ) self.mover_widgets.append(self.es2_try) + if beamline == "x01da": + self.es2_mov_group = Group("Experimental Station 2", [self.ot_es2_trz]) + else: self.es2_mov_group = Group("Experimental Station 2", [self.es2_try]) # Assemble complete mover group @@ -428,6 +453,7 @@ class MoverPanel(QWidget): self.ot_mov_group, self.es0_mov_group, self.es1_mov_group, + self.es2_mov_group, ], ) else: diff --git a/debye_bec/bec_widgets/widgets/digital_twin/widgets/move_widget.py b/debye_bec/bec_widgets/widgets/digital_twin/widgets/move_widget.py index 6df4065..1347d30 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/widgets/move_widget.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/widgets/move_widget.py @@ -1,7 +1,6 @@ """Move widget to display an axis and also move it through BEC""" import threading -import time from typing import Literal, Optional from bec_lib import bec_logger @@ -11,15 +10,13 @@ from bec_widgets.utils.colors import get_accent_colors # pylint: disable=E0611 from qtpy.QtCore import Property # type: ignore[attr-defined] from qtpy.QtCore import Signal # type: ignore[attr-defined] -from qtpy.QtCore import QObject, QPropertyAnimation, Qt, QThread +from qtpy.QtCore import QObject, QPropertyAnimation, Qt from qtpy.QtGui import QTransform from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QPushButton, QWidget +from .....bec_ipython_client.plugins.digital_twin_core.motion_worker import MotionWorker from .....bec_ipython_client.plugins.digital_twin_core.types import BeamlineId -# pylint: disable=E0402 -from .....devices.absorber import STATUS as ABS_STATUS - logger = bec_logger.logger @@ -124,222 +121,13 @@ class StatusIcon(QWidget): self._label.setPixmap(icon) -class MotionWorker(QObject): - """ - Executes motion on the specified motor and includes some safety during - motion for certain motors. - """ +class _WorkerSignals(QObject): + """Tiny QObject just to own signals, since MotionWorker itself is no longer a QObject.""" position_changed = Signal(float) error = Signal() finished = Signal() - def __init__(self, beamline: BeamlineId, dev, motor, target_pos: float): - super().__init__() - self.beamline = beamline - self.dev = dev - self.motor = motor - self._target = target_pos - self._stop_flag = threading.Event() - - def stop(self): - """Sets the stop flag""" - self._stop_flag.set() - - def run(self): - """Prepares the movement based on the axis (motor)""" - match self.motor: - case "sldi_gapx" | "sldi_gapy" | "sldi_centerx" | "sldi_centery": - self.motion() - case "cm_trx": - self.motion( - abs_closed=True, - surveyed_axes=[{"device": self.dev["cm_roty"], "abs_tol": 0.05}], - ) - case "cm_roty": - self.motion( - abs_closed=True, surveyed_axes=[{"device": self.dev["cm_trx"], "abs_tol": 0.05}] - ) - case "cm_try": - self.motion( - abs_closed=True, - surveyed_axes=[ - {"device": self.dev["cm_rotx"], "abs_tol": 0.05}, - {"device": self.dev["cm_rotz"], "abs_tol": 0.05}, - ], - ) - case "cm_rotx": - self.motion( - abs_closed=True, - surveyed_axes=[ - {"device": self.dev["cm_try"], "abs_tol": 0.05}, - {"device": self.dev["cm_rotz"], "abs_tol": 0.05}, - ], - ) - case "cm_rotz": - self.motion( - abs_closed=True, - surveyed_axes=[ - {"device": self.dev["cm_try"], "abs_tol": 0.05}, - {"device": self.dev["cm_rotx"], "abs_tol": 0.05}, - ], - ) - case "cm_bnd": - if self.beamline == "x01da": - p1 = ( - 1 / (self.dev.cm_bnd_radius.read()["cm_bnd_radius"]["value"] * 1e3) + 0.0284 - ) / 2e-6 - p2 = (1 / (self._target * 1e3) + 0.0284) / 2e-6 - else: - p1 = 541900 / self.dev.cm_bnd_radius.read()["cm_bnd_radius"]["value"] - 32570 - p2 = 541900 / self._target - 32570 - self._target = p2 - p1 - self.motion(relative=True, rb={"device": self.dev["cm_bnd_radius"]}) - case "mo1_try" | "mo1_trx" | "mo1_roty": - self.motion(abs_closed=True) - case "mo1_bragg_angle": - if self.beamline == "x01da": - self.motion() - else: # x10da needs to move goniometer - self.motion(alias="mo1_rotx") - case "sl1_centery" | "sl1_gapy" | "bm1_try": - self.motion() - case "fm_trx": - self.motion( - abs_closed=True, - surveyed_axes=[{"device": self.dev["fm_roty"], "abs_tol": 0.05}], - ) - case "fm_roty": - self.motion( - abs_closed=True, surveyed_axes=[{"device": self.dev["fm_trx"], "abs_tol": 0.05}] - ) - case "fm_try": - if self.beamline == "x01da": - abs_tol = 0.05 - else: # superxas mirror less stable thus needs higher tolerance - abs_tol = 0.2 - self.motion( - abs_closed=True, - surveyed_axes=[ - {"device": self.dev["fm_rotx"], "abs_tol": abs_tol}, - {"device": self.dev["fm_rotz"], "abs_tol": abs_tol}, - ], - ) - case "fm_rotx": - if self.beamline == "x01da": - abs_tol = 0.05 - else: # superxas mirror less stable thus needs higher tolerance - abs_tol = 0.2 - self.motion( - abs_closed=True, - surveyed_axes=[ - {"device": self.dev["fm_try"], "abs_tol": abs_tol}, - {"device": self.dev["fm_rotz"], "abs_tol": abs_tol}, - ], - ) - case "fm_rotz": - if self.beamline == "x01da": - abs_tol = 0.05 - else: # superxas mirror less stable thus needs higher tolerance - abs_tol = 0.2 - self.motion( - abs_closed=True, - surveyed_axes=[ - {"device": self.dev["fm_try"], "abs_tol": abs_tol}, - {"device": self.dev["fm_rotx"], "abs_tol": abs_tol}, - ], - ) - case "fm_bnd": - if self.beamline == "x01da": - p1 = ( - 1 / (self.dev.fm_bnd_radius.read()["fm_bnd_radius"]["value"] * 1e3) - + 4.28e-5 - ) / 1.84e-9 - p2 = (1 / (self._target * 1e3) + 4.28e-5) / 1.84e-9 - else: - p1 = ( - 593088.7 / self.dev.fm_bnd_radius.read()["fm_bnd_radius"]["value"] - + 26124.41 - ) - p2 = 593088.7 / self._target + 26124.41 - self._target = p2 - p1 - self.motion(relative=True, rb={"device": self.dev["fm_bnd_radius"]}) - case "sl2_centery" | "sl2_gapy" | "bm2_try": - self.motion() - case "ot_try" | "ot_rotx" | "ot_es1_trz": - self.motion() - case "es0wi_try": - self.motion() - case "es1_try" | "es2_try": - self.motion() - case "es1ic0_try" | "es1ic1_try" | "es1ic2_try": - self.motion() - case _: - logger.warning(f"Motor {self.motor} not integrated in digital twin!") - - def motion( - self, - abs_closed: bool = False, - relative: bool = False, - rb=None, - surveyed_axes=None, - alias=None, - ): - """ - Moves an axis while surverying a set of axes (if set). - Example surveyed_axes: - [{'device': bec_device_object, 'abs_tol': 0.1},] - - Args: - surveyed_axes (list): List of dictionaries of devices - """ - logger.info(f"Move axis {self.motor} to target {self._target}, move_relative={relative}") - try: - if alias: - self.motor = alias - if abs_closed: - if self.dev.abs.status.get() == ABS_STATUS.OPEN: - status = self.dev.abs.close() - status.wait(timeout=5) - if surveyed_axes is not None: - for surv_ax in surveyed_axes: - surv_ax["name"] = surv_ax["device"].dotted_name - surv_ax["old_value"] = surv_ax["device"].read(cached=True)[surv_ax["name"]][ - "value" - ] - if rb is not None: - rb["name"] = rb["device"].dotted_name - status = self.dev[self.motor].move(self._target, relative=relative) - last_check = time.time() - update_interval = 0.1 - while status.status == "RUNNING": - now = time.time() - if time.time() - last_check < update_interval: - time.sleep(0.01) - last_check = now - if self._stop_flag.is_set(): - self.dev[self.motor].stop() - self._stop_flag.clear() - if rb is not None: - self.position_changed.emit(rb["device"].read(cached=True)[rb["name"]]["value"]) - else: - self.position_changed.emit( - self.dev[self.motor].read(cached=True)[self.motor]["value"] - ) - if surveyed_axes is not None: - for surv_ax in surveyed_axes: - fb = surv_ax["device"].read(cached=True)[surv_ax["name"]]["value"] - if abs(fb - surv_ax["old_value"]) > surv_ax["abs_tol"]: - self.dev[self.motor].stop() - self.error.emit() - self.finished.emit() - break - self.finished.emit() - except Exception as e: - logger.error(f"Error during movement of {self.motor}: {e}") - self.error.emit() - self.finished.emit() - class MoveWidget(QWidget): """ @@ -361,8 +149,9 @@ class MoveWidget(QWidget): self.motor = motor self.deadband = deadband self.status = Status.IN_POSITION - self._thread: QThread | None = None + self._thread: threading.Thread | None = None self._worker: MotionWorker | None = None + self._signals: _WorkerSignals | None = None self.text_color = (0, 0, 0) @@ -488,7 +277,7 @@ class MoveWidget(QWidget): def _on_button_clicked(self): """Starts or stops motion depending on current situation""" - if self._thread and self._thread.isRunning(): + if self._thread and self._thread.is_alive(): self._stop_motion() else: self._start_motion() @@ -503,18 +292,22 @@ class MoveWidget(QWidget): self._set_status(Status.MOVING) self._apply_button_style("stop") - self._worker = MotionWorker(self.beamline, self.dev, self.motor, target) - self._thread = QThread() - self._worker.moveToThread(self._thread) - - self._thread.started.connect(self._worker.run) - self._worker.position_changed.connect(self._on_position_changed) - self._worker.error.connect(self._on_error) - self._worker.error.connect(self._thread.quit) - self._worker.finished.connect(self._on_motion_finished) - self._worker.finished.connect(self._thread.quit) - self._thread.finished.connect(self._cleanup_thread) + self._signals = _WorkerSignals() + self._signals.position_changed.connect(self._on_position_changed) + self._signals.error.connect(self._on_error) + self._signals.finished.connect(self._on_motion_finished) + self._signals.finished.connect(self._cleanup_thread) + self._worker = MotionWorker( + self.beamline, + self.dev, + self.motor, + target, + on_position_changed=self._signals.position_changed.emit, + on_error=self._signals.error.emit, + on_finished=self._signals.finished.emit, + ) + self._thread = threading.Thread(target=self._worker.run, daemon=True) self._thread.start() def _on_error(self): @@ -547,20 +340,16 @@ class MoveWidget(QWidget): def _cleanup_thread(self): """Cleaning up of the mover thread""" - if self._thread: - self._thread.deleteLater() - self._thread = None - if self._worker: - self._worker.deleteLater() - self._worker = None + self._thread = None + self._worker = None + self._signals = None def shutdown(self): """Cleaning up of the mover when shutting down the application""" if self._worker: self._worker.stop() - if self._thread: - self._thread.quit() - self._thread.wait(2000) # max 2 s grace period + if self._thread and self._thread.is_alive(): + self._thread.join(2.0) # max 2 s grace period class AbsorberWidget(QWidget):