diff --git a/jungfrau_gui/globals.py b/jungfrau_gui/globals.py index 1db1125..8786a79 100644 --- a/jungfrau_gui/globals.py +++ b/jungfrau_gui/globals.py @@ -92,3 +92,6 @@ def get_git_info(): default_HT = 200000.00 # V backlash = [100, 80, 0, 0] + +# overshoot/preload used for two-step jog moves +preload = [2000, 2000, 0, 1] # X,Y,Z in nm (2 µm), TX in deg (1°) \ No newline at end of file diff --git a/jungfrau_gui/ui_components/tem_controls/task/stage_centering_task.py b/jungfrau_gui/ui_components/tem_controls/task/stage_centering_task.py index 20a6917..9b642b9 100644 --- a/jungfrau_gui/ui_components/tem_controls/task/stage_centering_task.py +++ b/jungfrau_gui/ui_components/tem_controls/task/stage_centering_task.py @@ -71,7 +71,7 @@ def run(self): if tilt_X_abs < 5: if np.abs(movexy[0]) < self.thresholds['dxy_min'] and np.abs(movexy[1]) < self.thresholds['dxy_min']: - logging.info(f'Vector already small enough (< {self.thresholds['dxy_min']} um): {movexy[0]}, {movexy[1]}') + logging.info(f"Vector already small enough (< {self.thresholds['dxy_min']} um): {movexy[0]}, {movexy[1]}") return logging.info(f'Move X: {movexy[0]} um, Y: {movexy[1]} um with MAG: {magnification[2]}') self.control.trigger_movewithbacklash.emit(np.sign(movexy[0]) > 0, -movexy[0]*globals.UM_TO_NM, globals.backlash[0], False) diff --git a/jungfrau_gui/ui_components/tem_controls/task/task_manager.py b/jungfrau_gui/ui_components/tem_controls/task/task_manager.py index 1a9b16e..f92744a 100644 --- a/jungfrau_gui/ui_components/tem_controls/task/task_manager.py +++ b/jungfrau_gui/ui_components/tem_controls/task/task_manager.py @@ -26,6 +26,8 @@ from ..gaussian_fitter_mp import GaussianFitterMP +from jungfrau_gui.ui_components.tem_controls.task.tem_dispatcher import TEMDispatcher + def on_new_best_result_in_main_thread(result_dict): # This runs in the main thread. We can safely update GUI elements, logs, etc. print("New best result =>", result_dict) @@ -56,6 +58,7 @@ class ControlWorker(QObject): trigger_getteminfo = Signal(str) trigger_centering = Signal(bool, str) trigger_movewithbacklash = Signal(int, float, float, bool) + trigger_move_parking = Signal(int, float, float, bool) # always preload actionFit_Beam = Signal() # originally defined with QuGui # actionAdjustZ = Signal() @@ -64,6 +67,7 @@ def __init__(self, tem_action): #, timeout:int=10, buffer=1024): super().__init__() self.cfg = ConfigurationClient(redis_host(), token=auth_token()) self.client = TEMClient(globals.tem_host, 3535, verbose=False) + self.tem = TEMDispatcher(self.client) self.task = Task(self, "Dummy") self.task_thread = QThread() @@ -85,6 +89,10 @@ def __init__(self, tem_action): #, timeout:int=10, buffer=1024): self.trigger_getteminfo.connect(self.getteminfo) self.trigger_centering.connect(self.centering) self.trigger_movewithbacklash.connect(self.move_with_backlash) + self.trigger_move_parking.connect(self.move_parking_with_preload) + + self._net_away = {0: 0.0, 1: 0.0, 2: 0.0, 3: 0.0} # X,Y,Z,TX signed net move + # self.actionAdjustZ.connect(self.start_adjustZ) self.beam_fitter = None @@ -749,6 +757,10 @@ def stop_task(self): def shutdown(self): logging.info("Shutting down control") try: + try: + self.tem.shutdown() + except Exception: + pass # self.client.exit_server() # logging.warning("TEM server is OFF") # time.sleep(0.12) @@ -758,6 +770,8 @@ def shutdown(self): logging.error(f'Shutdown of Task Manager triggered error: {e}') pass + # KT's implementation of backlash corrected fast movements + # The routine moves the stage in one direction, but with a reduced/corrected value @Slot(int, float, float, bool) def move_with_backlash(self, moverid=0, value=10, backlash=0, button=False, scale=1): """ @@ -786,7 +800,7 @@ def move_with_backlash(self, moverid=0, value=10, backlash=0, button=False, scal elif direction == 1 and np.sign(self.tem_status["stage.GetPos_diff"][axis]) < 0: backlash = 0 - logging.debug(f"xyz0, dxyz0 : {list(map(lambda x, y: f'{x/globals.UM_TO_NM:8.3f}{y/globals.UM_TO_NM:8.3f}', self.tem_status['stage.GetPos'][:3], self.tem_status['stage.GetPos_diff'][:3]))}, " + logging.debug(f"xyz0, dxyz0 : {list(map(lambda x, y: f'{x/1e3:8.3f}{y/1e3:8.3f}', self.tem_status['stage.GetPos'][:3], self.tem_status['stage.GetPos_diff'][:3]))}, " f"{self.tem_status['stage.GetPos'][3]:6.2f} {self.tem_status['stage.GetPos_diff'][3]:6.2f}, {backlash}" ) @@ -819,10 +833,173 @@ def move_with_backlash(self, moverid=0, value=10, backlash=0, button=False, scal return if moverid < 2 and button: # display the previous move to user + # logging.info(f"Moved stage {value*scale/1e3:.1f} um in X-direction") if moverid == 0: self.tem_action.tem_stagectrl.movex10ump.setStyleSheet('background-color: rgb(53, 53, 53); color: rgb(128, 128, 255);') self.tem_action.tem_stagectrl.movex10umn.setStyleSheet('background-color: rgb(53, 53, 53); color: white;') else: self.tem_action.tem_stagectrl.movex10ump.setStyleSheet('background-color: rgb(53, 53, 53); color: white;') self.tem_action.tem_stagectrl.movex10umn.setStyleSheet('background-color: rgb(53, 53, 53); color: rgb(128, 128, 255);') - logging.debug(f"xyz1, dxyz1 : {list(map(lambda x, y: f'{x/globals.UM_TO_NM:8.3f}{y/globals.UM_TO_NM:8.3f}', self.tem_status['stage.GetPos'][:3], self.tem_status['stage.GetPos_diff'][:3]))}, {self.tem_status['stage.GetPos'][3]:6.2f} {self.tem_status['stage.GetPos_diff'][3]:6.2f}, {backlash}") + logging.debug(f"xyz1, dxyz1 : {list(map(lambda x, y: f'{x/1e3:8.3f}{y/1e3:8.3f}', self.tem_status['stage.GetPos'][:3], self.tem_status['stage.GetPos_diff'][:3]))}, {self.tem_status['stage.GetPos'][3]:6.2f} {self.tem_status['stage.GetPos_diff'][3]:6.2f}, {backlash}") + + + # ************************************************ + # Routines for lag-corrected fast movement actions + # ************************************************ + + def _SetTXRel_timeout(self, val: float, timeout_ms: int): + # Calls underlying TEMClient with custom timeout, without modifying TEMClient. + return self.client._send_message("SetTXRel", val, timeout_ms=timeout_ms) + + def _SetXRel_timeout(self, val: float, timeout_ms: int): + return self.client._send_message("SetXRel", val, timeout_ms=timeout_ms) + + def _SetYRel_timeout(self, val: float, timeout_ms: int): + return self.client._send_message("SetYRel", val, timeout_ms=timeout_ms) + + def _SetZRel_timeout(self, val: float, timeout_ms: int): + return self.client._send_message("SetZRel", val, timeout_ms=timeout_ms) + + + def _two_step_sequence(self, axis_fn, value, preload, settle_s=0.1): + """ + Build an atomic sequence for preload-compensated moves: + value > 0: (value+preload), wait, (-preload) + value < 0: (value-preload), wait, (+preload) + """ + if value == 0: + return [] + if preload == 0: + return [(axis_fn, (value,), {})] + + if value > 0: + return [ + (axis_fn, (value + preload,), {}), + (time.sleep, (settle_s,), {}), + (axis_fn, (-preload,), {}), + ] + else: + return [ + (axis_fn, (value - preload,), {}), + (time.sleep, (settle_s,), {}), + (axis_fn, (preload,), {}), + ] + + + @Slot(int, float, float, bool) + def move_parking_with_preload(self, moverid=0, value=10.0, preload=0.0, button=False, scale=1.0): + """ + Relative stage jog with optional backlash (preload) compensation. + + If a direction change is detected, applies a two-step move to take up slack: + +dir: (value + preload) then (-preload) + -dir: (value - preload) then (+preload) + Otherwise, sends a single relative move. + + Args: + moverid (int): 0/1:+X/-X, 2/3:+Y/-Y, 4/5:+Z/-Z, 6/7:+TX/-TX. + value (float): Relative move (nm for X/Y/Z, deg for TX); sign matches moverid. + preload (float): Overshoot amount (same units as value); 0 disables. + button (bool): If True, updates X jog button highlight. + scale (float): Multiplier applied to `value` before sending. + """ + # Ask for fresh status (coalesced, won't spam) + QTimer.singleShot(0, lambda: self.send_to_tem("#info", asynchronous=True)) + + axis = moverid // 2 + movement_value = value * scale + effective_preload = float(preload) #if (apply_preload and preload != 0) else 0.0 + + # set longer timeout for slow rotation speeds + MOVE_TIMEOUT_MS = 30000 if axis == 3 else 5000 + + # pick axis function + axis_fn = self._axis_fn_for_axis(axis, MOVE_TIMEOUT_MS) + + # Build atomic job sequence and enqueue on single TEM lane + jobs = self._two_step_sequence(axis_fn, movement_value, effective_preload, settle_s=0.05) + if jobs: + self.tem.post_sequence(jobs) + + self._record_away_and_enable_back(axis, movement_value) + + # UI styling for X buttons + if moverid < 2 and button: + if moverid == 0: + self.tem_action.tem_stagectrl.movex10ump.setStyleSheet( + "background-color: rgb(53, 53, 53); color: rgb(128, 128, 255);" + ) + self.tem_action.tem_stagectrl.movex10umn.setStyleSheet( + "background-color: rgb(53, 53, 53); color: white;" + ) + else: + self.tem_action.tem_stagectrl.movex10ump.setStyleSheet( + "background-color: rgb(53, 53, 53); color: white;" + ) + self.tem_action.tem_stagectrl.movex10umn.setStyleSheet( + "background-color: rgb(53, 53, 53); color: rgb(128, 128, 255);" + ) + + + @Slot(int) + def move_back_no_preload(self, axis=0): + """ + Move back by the last recorded away move on this axis. + axis: 0=X,1=Y,2=Z,3=TX + """ + away_accumulated = float(self._net_away.get(axis, 0.0)) + if away_accumulated == 0.0: + logging.warning(f"No recorded away move for axis {axis}") + self._clear_away_and_disable(axis) + return + + back_value = -away_accumulated + MOVE_TIMEOUT_MS = 30000 if axis == 3 else 5000 + axis_fn = self._axis_fn_for_axis(axis, MOVE_TIMEOUT_MS) + + self.tem.post_sequence([(axis_fn, (back_value,), {})]) + + self._clear_away_and_disable(axis) + + def _axis_fn_for_axis(self, axis: int, timeout_ms: int): + # Helper to choose corresponding TEMClient routine as fct of the set axis + if axis == 0: + return lambda v: self._SetXRel_timeout(v, timeout_ms) + if axis == 1: + return lambda v: self._SetYRel_timeout(v, timeout_ms) + if axis == 2: + return lambda v: self._SetZRel_timeout(v, timeout_ms) + if axis == 3: + return lambda v: self._SetTXRel_timeout(v, timeout_ms) + raise ValueError(f"Invalid axis {axis}") + + + def _record_away_and_enable_back(self, axis, movement_value): + # record the net “away” move so 'Back' buttons know what to do + self._net_away[axis] += movement_value + + # enable back button corresponding to the latest preloaded fast movement + if axis == 0: # translation (X) + QTimer.singleShot(0, lambda: self.tem_action.tem_stagectrl.back_x.setEnabled(True)) + if axis == 3: # rotation (TX) + QTimer.singleShot(0, lambda: self.tem_action.tem_stagectrl.back_tx.setEnabled(True)) + + + def _clear_away_and_disable(self, axis): + # Clear 'away' as compensation has been completed + self._net_away[axis] = 0.0 + + # Disable back buttons + if axis == 0: # translation (X) + QTimer.singleShot(0, lambda: self.tem_action.tem_stagectrl.back_x.setEnabled(False)) + if axis == 3: # rotation (TX) + QTimer.singleShot(0, lambda: self.tem_action.tem_stagectrl.back_tx.setEnabled(False)) + + # Uncolor translation buttons + if axis == 0: + self.tem_action.tem_stagectrl.movex10ump.setStyleSheet( + "background-color: rgb(53, 53, 53); color: white;" + ) + self.tem_action.tem_stagectrl.movex10umn.setStyleSheet( + "background-color: rgb(53, 53, 53); color: white;" + ) diff --git a/jungfrau_gui/ui_components/tem_controls/task/tem_dispatcher.py b/jungfrau_gui/ui_components/tem_controls/task/tem_dispatcher.py new file mode 100644 index 0000000..b0ae5a7 --- /dev/null +++ b/jungfrau_gui/ui_components/tem_controls/task/tem_dispatcher.py @@ -0,0 +1,77 @@ +import queue +import threading +import time +import logging + +class TEMDispatcher: + """ + Single-threaded command lane for TEM calls. + - post(fn, ...): fire-and-forget + - call(fn, ...): wait for result + - post_sequence([...]): run multiple commands atomically (no interleave) + - post_latest(key, fn, ...): keep only the newest request for a key (ideal for polling) + """ + def __init__(self, client): + self.client = client + self._q = queue.Queue() + self._stop = threading.Event() + self._latest_token = {} # key -> token + self._thread = threading.Thread(target=self._loop, name="TEM-IO", daemon=True) + self._thread.start() + + def shutdown(self): + self._stop.set() + self._q.put(None) + + def _loop(self): + while not self._stop.is_set(): + item = self._q.get() + if item is None: + break + + kind = item[0] + try: + if kind == "call": + _, fn, args, kwargs, done, out = item + out["result"] = fn(*args, **kwargs) + done.set() + + elif kind == "post": + _, fn, args, kwargs = item + fn(*args, **kwargs) + + elif kind == "sequence": + _, fns = item + for fn, args, kwargs in fns: + fn(*args, **kwargs) + + elif kind == "latest": + _, key, token, fn, args, kwargs = item + # skip stale polls + if self._latest_token.get(key) == token: + fn(*args, **kwargs) + + except Exception as e: + logging.warning(f"TEMDispatcher error in {kind}: {type(e).__name__}: {e}") + + def post(self, fn, *args, **kwargs): + self._q.put(("post", fn, args, kwargs)) + + def call(self, fn, *args, timeout=None, **kwargs): + done = threading.Event() + out = {} + self._q.put(("call", fn, args, kwargs, done, out)) + ok = done.wait(timeout) + return out.get("result") if ok else None + + def post_sequence(self, fns): + """ + fns = [(fn, args_tuple, kwargs_dict), ...] + executed back-to-back in the TEM thread. + """ + self._q.put(("sequence", fns)) + + def post_latest(self, key, fn, *args, **kwargs): + token = object() + self._latest_token[key] = token + self._q.put(("latest", key, token, fn, args, kwargs)) diff --git a/jungfrau_gui/ui_components/tem_controls/tem_action.py b/jungfrau_gui/ui_components/tem_controls/tem_action.py index 9ee91cd..e4a1672 100644 --- a/jungfrau_gui/ui_components/tem_controls/tem_action.py +++ b/jungfrau_gui/ui_components/tem_controls/tem_action.py @@ -88,17 +88,36 @@ def __init__(self, parent, grandparent): self.control.updated.connect(self.on_tem_update) - # Move X positive 10 micrometers - self.tem_stagectrl.movex10ump.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(0, 10000, globals.backlash[0], True)) - # Move X negative 10 micrometers - self.tem_stagectrl.movex10umn.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(1, -10000, globals.backlash[0], True)) - # Move TX positive 10 degrees - self.tem_stagectrl.move10degp.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(6, 10, globals.backlash[3], False)) - # Move TX negative 10 degrees - self.tem_stagectrl.move10degn.clicked.connect(lambda: self.control.trigger_movewithbacklash.emit(7, -10, globals.backlash[3], False)) + # Away X: +10 um, always preload (e.g. +12 then -2) + self.tem_stagectrl.movex10ump.clicked.connect( + lambda: self.control.trigger_move_parking.emit(0, 10000, globals.preload[0], True) + ) + self.tem_stagectrl.movex10umn.clicked.connect( + lambda: self.control.trigger_move_parking.emit(1, -10000, globals.preload[0], True) + ) + + # Away TX: +10 deg, preload (e.g. +11 then -1) + self.tem_stagectrl.move10degp.clicked.connect( + lambda: self.control.trigger_move_parking.emit(6, 10, globals.preload[3], False) + ) + self.tem_stagectrl.move10degn.clicked.connect( + lambda: self.control.trigger_move_parking.emit(7, -10, globals.preload[3], False) + ) + + # Stage translation move back (X=0) with no preload + self.tem_stagectrl.back_x.clicked.connect( + lambda: self.control.move_back_no_preload(0) + ) + + # Stage rotation move back (TX=3) with no preload + self.tem_stagectrl.back_tx.clicked.connect( + lambda: self.control.move_back_no_preload(3) + ) + # Set Tilt X Angle to 0 degrees self.tem_stagectrl.move0deg.clicked.connect( lambda: threading.Thread(target=self.control.client.SetTiltXAngle, args=(0,)).start()) + self.tem_stagectrl.go_button.clicked.connect(self.go_listedposition) self.tem_stagectrl.addpos_button.clicked.connect(lambda: self.add_listedposition()) self.trigger_additem.connect(self.add_listedposition) diff --git a/jungfrau_gui/ui_components/tem_controls/ui_tem_specific.py b/jungfrau_gui/ui_components/tem_controls/ui_tem_specific.py index b52af45..d004444 100644 --- a/jungfrau_gui/ui_components/tem_controls/ui_tem_specific.py +++ b/jungfrau_gui/ui_components/tem_controls/ui_tem_specific.py @@ -70,6 +70,7 @@ def initUI(self): stage_ctrl_label.setFont(font_big) stage_ctrl_section.addWidget(stage_ctrl_label) + # Speed radio buttons self.hbox_rot = QHBoxLayout() rot_label = QLabel("Rotation Speed:", self) self.rb_speeds = QButtonGroup() @@ -86,6 +87,7 @@ def initUI(self): stage_ctrl_section.addSpacing(10) stage_ctrl_section.addLayout(self.hbox_rot) + # Fast movement buttons self.hbox_move = QHBoxLayout() move_label = QLabel("Fast movement:", self) self.movestages = QButtonGroup() @@ -102,6 +104,15 @@ def initUI(self): self.hbox_move.addWidget(move_label, 1) stage_ctrl_section.addLayout(self.hbox_move) + # Fast movement back buttons (typically going back to crystal before data collection) + self.hbox_back = QHBoxLayout() + self.back_x = QPushButton('Back (X)', self) + self.back_tx = QPushButton('Back (TiltX)', self) + self.hbox_back.addWidget(self.back_x) + self.hbox_back.addWidget(self.back_tx) + stage_ctrl_section.addLayout(self.hbox_back) + + for i in self.rb_speeds.buttons(): self.hbox_rot.addWidget(i, 1) i.setEnabled(False) @@ -110,6 +121,9 @@ def initUI(self): self.hbox_move.addWidget(i, 1) i.setEnabled(False) + self.back_x.setEnabled(False) + self.back_tx.setEnabled(False) + self.hbox_magmode = QHBoxLayout() mode_label = QLabel("Magnification Mode:", self) self.mag_modes = QButtonGroup()